diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..74fdacc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,19 @@ +name: Build offline NX release +on: + workflow_dispatch: +permissions: + contents: read +jobs: + package: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -r requirements-build.txt + - run: python scripts/build_release.py --output dist + - uses: actions/upload-artifact@v4 + with: + name: nx-mcp-windows-offline + path: dist/* diff --git a/INTERACTIVE-NX.md b/INTERACTIVE-NX.md new file mode 100644 index 0000000..51281dd --- /dev/null +++ b/INTERACTIVE-NX.md @@ -0,0 +1,13 @@ +# Interactive NX MCP, v2606 + +Play `examples/start_nx_interactive.py` once in graphical NX. The journal returns. A pinned Win32 timer callback drains one authenticated bridge request at a time on the registering NX UI thread. The HTTP/MCP process remains separate; it never calls NXOpen. The NX MCP control window shows activity and provides Pause, Resume, and Stop. Long native operations can temporarily block NX; pause/stop take effect between calls. + +Agent mode locks NX model editing with `UI.LockAccess`. Pause releases it for manual work. Pausing invalidates agent references and checkpoints: manual changes have no operation receipt and must not be undone by a later agent rollback. Reacquire references after resuming. Closing the control panel stops the bridge and unlocks NX, leaving the application and parts open. + +`nx_screenshot` uses `Part.Views.CreateImageExportBuilder` and returns a PNG artifact plus MCP image content. It captures the displayed CAD viewport, never the desktop. White/transparent/original backgrounds and shaded/shaded-with-edges/static-wireframe styles are exposed. Width and height are advisory: the installed graphics driver can use the actual device size, so both requested and actual resolution are returned. These are viewport renderings, not photorealistic ray tracing. Images above 8 MiB use chunked artifact download. `nx_view_info` reports the display-part view axes, NX view origin, absolute origin, scale, and style. + +`nx_check_interference` examines the cross-product of two selected bodies/components, including nested body occurrences. `nx_check_clearance` examines every distinct body pair in selected groups or the full assembly. Conservative boxes only prune provably separated pairs; reported distances use native geometry. Native solid intersection distinguishes penetration, contact and separation and measures pairwise overlap volumes in mm³. Temporary interference solids are rolled back explicitly: NX builder Reset alone does not remove them. Body/feature counts are checked afterward. Pairwise overlap volumes are not geometric union volume. Custom reference-set edge cases and general solid-validity certification remain outside the validated scope. + +Use a stable operation ID for mutation retry. A queued request that expires before execution is discarded. If execution already began, a transport timeout reports an unknown outcome; query the durable receipt. Do not send simultaneous NXOpen mutations. Save, manual handoff, and part lifecycle changes can invalidate native recovery marks; query checkpoint state. + +Validation includes native GUI thread identity, visible sketch/extrusion, pause/resume, native PNGs, cubes with 500 mm³ overlap, contact and 2 mm separation, a 3 mm clearance requirement, and a rotated nested assembly. Saved flags and checkpoints survive collision inspection. The prior eleven batch regression groups remain separate evidence; tool status does not imply every NX feature has been tested. diff --git a/README.md b/README.md index 1e7c309..fb4890a 100644 --- a/README.md +++ b/README.md @@ -1,138 +1,121 @@ # NX MCP Server -NX MCP is a local Model Context Protocol server for Siemens NX automation. The -`0.2.0.dev0` line replaces the unverified direct-attach design with two explicit -processes: +NX MCP lets an MCP client inspect and edit Siemens NX through an NX-owned bridge. +The sidecar validates requests and manages transport; NXOpen calls run serially +on the NX thread. The sidecar imports without NX installed. ```text -MCP client <--stdio--> Python sidecar <--authenticated loopback JSON-RPC--> NX bridge <--NXOpen--> NX +MCP client → Python sidecar → authenticated loopback bridge → NXOpen / NX ``` -The sidecar can start without NX. Tool calls fail with `NX_BRIDGE_UNAVAILABLE` -until an NX journal starts the bridge. +This fork targets **NX 2606 on Windows**. Upstream's NX 2506 batch evidence is +historical and does not establish cross-version compatibility for these additions. +See the [capability matrix](docs/capability-matrix.md) for per-tool evidence and +limits. “Tested” applies to the recorded fixtures, not every option of a builder. -## Current status +## Choose a tool profile -The sidecar, bridge protocol, input/output schemas, workspace confinement, and -core workflow have automated coverage. The Python bridge passed the documented -20-run batch workflow on Siemens NX 2506 (`ugraf` 2506.4021) on 2026-08-21. -It remains opt-in while a non-blocking NX GUI event pump is validated; the -bundled Python Journal runner is intentionally batch-only. +| Profile | Exposure | Configuration | +| --- | --- | --- | +| Default | 16 original core tools | No experimental opt-in | +| Integration | 189 tools | `NX_MCP_ENABLE_EXPERIMENTAL=1` | +| Agent | 13 entry points; discover/invoke integration tools on demand | Integration opt-in plus `NX_MCP_SURFACE=agent` | -The default `tools/list` exposes only these 16 tools: +The legacy environment flag enables the integration profile; it is **not** a +per-tool test status. Use `nx_capabilities` for that distinction. Journal execution +requires a separate `NX_MCP_ENABLE_JOURNAL=1` and is disabled by default. -- Status: `nx_status` -- Files: `nx_create_part`, `nx_open_part`, `nx_save_part`, `nx_close_part`, `nx_export_step` -- Queries: `nx_list_sketches`, `nx_list_bodies`, `nx_list_features` -- Sketch: `nx_create_sketch`, `nx_sketch_line`, `nx_sketch_rectangle`, `nx_finish_sketch` -- Modeling: `nx_extrude` -- Recovery/view: `nx_undo`, `nx_fit_view` +## Start graphical NX -The 34 old tools outside the certified surface remain unverified and hidden by -default. `NX_MCP_ENABLE_EXPERIMENTAL=1` registers them through the bridge; -Journal tools additionally require `NX_MCP_ENABLE_JOURNAL=1`. - -## Requirements - -- Windows with a local native Siemens NX installation (validated on NX 2506) -- Python 3.10+ -- The package installed in the sidecar interpreter -- An NX journal that can import `nx_mcp` (the bundled Journal examples load - the checkout's `src` directory automatically; the NX side has no `mcp` or - `pydantic` dependency) -- A dedicated test/project directory configured as `NX_MCP_WORKSPACE` - -Install the sidecar and development dependencies: +Install Python 3.10+ and the package in the external sidecar environment: ```powershell python -m pip install -e ".[dev]" ``` -## Internal feasibility run - -1. Set `NX_MCP_WORKSPACE` to a disposable directory. -2. For the target-build feasibility test only, set - `NX_MCP_ALLOW_UNVERIFIED_PYTHON_BRIDGE=1` in the NX environment. -3. Set `NX_MCP_BRIDGE_STOP_FILE` to a new path inside the workspace, then run - `examples/start_nx_bridge.py` with `run_journal.exe -nx`. The journal pumps - requests on NX's main thread and writes an authenticated session descriptor - to `%LOCALAPPDATA%\nx-mcp\bridge.json`. -4. Configure the MCP client to launch the sidecar: - -```json -{ - "mcpServers": { - "nx-mcp": { - "command": "python", - "args": ["-m", "nx_mcp.server"], - "env": { - "NX_MCP_WORKSPACE": "D:\\NX_MCP_WORKSPACE" - } - } - } -} -``` - -5. Run the real-NX acceptance loop from an external PowerShell 7 terminal: +1. Set `NX_MCP_WORKSPACE` in the NX environment to a dedicated CAD directory, + such as `D:\NX_MCP_WORKSPACE`. +2. In graphical NX, play `examples/start_nx_interactive.py`. The journal returns; + a retained Win32 timer dispatches queued calls on the NX UI thread. +3. Start the sidecar using the same workspace and the graphical descriptor: ```powershell -python -m nx_mcp.real_smoke --workspace D:\NX_MCP_WORKSPACE --iterations 20 --run-prefix acceptance +$env:NX_MCP_WORKSPACE = 'D:\NX_MCP_WORKSPACE' +$env:NX_MCP_BRIDGE_DESCRIPTOR = Join-Path $env:LOCALAPPDATA 'nx-mcp\interactive-bridge.json' +$env:NX_MCP_ENABLE_EXPERIMENTAL = '1' +$env:NX_MCP_ENABLE_JOURNAL = '0' +$env:NX_MCP_SURFACE = 'agent' +python -m nx_mcp.server ``` -6. Create the configured stop file when finished; the journal stops the bridge - cleanly. +Configure the MCP client with that executable, arguments and environment. The NX +journal loads this checkout's `src` directory; the NX-side process does not need +`mcp` or `pydantic`. Do not attach batch and graphical hosts to the same workspace. +See [graphical lifecycle](INTERACTIVE-NX.md) and [setup details](docs/tools.md). + +The optional `python -m nx_mcp.http_surface` entrypoint serves `/mcp` and +`/agent/mcp`. It requires separate network access controls; loopback bridge +authentication does not authenticate the HTTP endpoint. Stdio avoids network exposure. + +## Workflows -Do not use production parts for this test. The batch bridge is not evidence of -interactive GUI responsiveness; use a non-blocking NX UI scheduler or the -agreed minimal C# NX-side bridge before enabling an interactive pilot. +| Area | Features and contracts | +| --- | --- | +| Files and artifacts | [Nested folders, open/save paths](docs/tools.md), uploads/downloads, checksums, assembly dependency packages and inline PNGs | +| Inspection | [Assembly bounds, distance and interference](docs/tools.md), topology selection, validity and measured properties | +| Sketches and solids | [Curve editing, expressions and previews](docs/tools.md); [dimensions, relations and native patterns](docs/tools.md) | +| Display | [Visibility, color, transparency, collision highlights and sections](docs/tools.md); camera and render controls | +| Assemblies and drawings | [Exploded views](docs/tools.md), trace lines, BOMs, balloons and [editable annotations](docs/tools.md) | +| Sheet metal | [Native operations, flat patterns and bend tables](docs/tools.md); per-operation schemas and tested option scope | +| Freeform and direct editing | [Splines, meshes, bridge/trim/sew/thicken, face edits and sampled analysis](docs/tools.md) | +| Manufacturing | [Native threads, PMI/GD&T and annotation refresh](docs/tools.md) | +| Agent use | [Discovery, compact results, pagination, snapshots and retention](docs/agent-surface.md) | -## Security model +## References, recovery and paths -- IPC binds only to `127.0.0.1` on a random port and requires a random 256-bit - session token. -- Every file argument is relative to `NX_MCP_WORKSPACE`; traversal, absolute - paths, and resolved links outside the workspace are rejected. -- Journal execution and all 34 legacy tools are disabled by default. Both the - sidecar and NX bridge must receive the opt-in environment flags. -- Object IDs are opaque and valid only for the current part session. +Use returned opaque IDs rather than display names. References include owner and +session context and can become stale after close, rollback or manual handoff. +Reacquire them through inspection tools when that happens. -## Local quality gates +Assign a unique `operation_id` to mutations. After uncertain delivery, query +`nx_operation_status` before retrying. Reusing the same ID and arguments can +return the committed receipt without applying the mutation twice. Checkpoints +are session-bound; NX save can expire native undo marks. Recovery does not undo +arbitrary external file writes or survive process restart as a model checkpoint. -The ordinary suite does not require NX. Install the Git hooks once, then use -the same checks as CI: +Paths refer to the NX host. Use explicit project subfolders; relative paths are +resolved from `NX_MCP_WORKSPACE`, and absolute paths must remain inside it. +Traversal, resolved links outside it and internal `.nx-mcp` files are rejected. +See [path semantics](docs/tools.md). + +## Validation and limitations + +Run the ordinary quality gates without NX: ```powershell -python -m pip install -e ".[dev]" -python -m pre_commit install --install-hooks python -m pre_commit run --all-files python -m pytest -q -p no:cacheprovider -m "not real_nx" --basetemp .pytest-tmp ``` -The pre-commit hook runs file and style checks. The pre-push hook runs the -non-real-NX pytest suite and the sidecar mypy gate. Tests marked `legacy` cover -the opt-in 0.1 surface; tests marked `fake_nx` do not validate NXOpen itself. -Hosted CI runs the core suite across supported Python and OS combinations, -runs legacy mock-NX tests separately, and enforces at least 78% branch -coverage in its canonical Ubuntu/Python 3.12 coverage job. - -Real NX acceptance is intentionally separate. Dispatch -`.github/workflows/real-nx.yml` from a dedicated self-hosted Windows runner -labelled `self-hosted`, `windows`, and `nx`, with `NX_RUN_JOURNAL` set to the -absolute path of `run_journal.exe`. - -See [architecture](docs/architecture.md), [0.1 migration](docs/migration-0.2.md), -and [real NX validation](docs/real-nx-validation.md) for implementation and -release gates. - -## Star History - - - - Star History Chart - +Hosted CI checks supported OS/Python combinations, sidecar types and branch +coverage. Fake NX tests cover API boundaries; they do not establish geometry +correctness. Native runners under `examples/validate_*.py` use disposable fixtures +and document their environment variables. See [native validation](docs/real-nx-validation.md) +and [release acceptance](docs/real-nx-validation.md). + +The [validation guide](docs/real-nx-validation.md) records 918 automated passes +and scoped dev20–dev22 live checks. Historical receipts identify their runtime commits and are +not current-version blanket certification. Current experimental gaps are tracked +in [capability closeout](docs/capability-matrix.md). + +NXOpen mutations are serialized. Long native calls can block graphical NX; +cancellation is cooperative between batch children. Sampled surface, thickness +and draft analysis does not establish global extrema or standards compliance. +Individual sheet-metal options retain narrower evidence than their tool family. + +## Upstream contribution + +[Draft PR #5](https://github.com/DreamEnding/NX_MCP/pull/5) proposes this integration. +The [review outline](docs/tools.md) describes possible extraction +boundaries. The fork retains upstream history and its MIT license; private CAD +and machine provisioning are excluded. diff --git a/constraints-windows.txt b/constraints-windows.txt new file mode 100644 index 0000000..771ec99 --- /dev/null +++ b/constraints-windows.txt @@ -0,0 +1,4 @@ +# Versions verified with NX v2606; transitive pins live in requirements-windows.lock. +mcp==1.29.1 +pydantic==2.13.5 +pywin32==311 diff --git a/docs/agent-surface.md b/docs/agent-surface.md new file mode 100644 index 0000000..a3bfebf --- /dev/null +++ b/docs/agent-surface.md @@ -0,0 +1,141 @@ +# Agent surface + +The full profile remains compatible: 189 tools with existing defaults. The opt-in +agent profile lists 13 tools: eight core tools plus `nx_discover_tools`, `nx_invoke` +`nx_result`, `nx_inspect` and `nx_result_cleanup`. All 189 underlying tools remain available, subject to their +existing capability status and native prerequisites. + +For stdio, set `NX_MCP_SURFACE=agent`, `NX_MCP_ENABLE_EXPERIMENTAL=1` and +`NX_MCP_WORKSPACE`. The dual HTTP entrypoint `python -m nx_mcp.http_surface` serves +full compatibility at `/mcp` and the agent profile at `/agent/mcp` on port 8765. +Both use the same bridge and serial NX main-thread queue. They must not be used +to issue concurrent mutations. The host must restrict network access as before. + +## Discover, invoke, inspect + +1. `nx_discover_tools(query="nx_extrude", include_schema=true)` returns the exact + original input/output schemas, description and agent defaults. Broad queries + return at most ten descriptions by default (maximum twenty); use `next_offset`. +2. `nx_invoke(tool="nx_extrude", arguments={"sketch_id":"...","distance":5, + "operation_id":"extrude_unique_001"})` validates the original schema and + calls the existing implementation. Unsupported fields still fail before NX. +3. Compact results retain opaque references, owner-part identity, occurrence + paths, units, all warnings, mutation outcome and change counts. Long arrays + contain at most twenty entries with explicit `omitted` metadata. Repeated + reference metadata is omitted. Unknown change counts remain null. +4. `nx_result(result_id="result_...", field="/bodies", offset=20)` expands an + immutable response snapshot. `detail="full"` expands selected object metadata; + selected arrays remain paged. This never repeats the original mutation. + +`nx_invoke(detail="full")` selects the legacy defaults and full original result +**when making a new call**. Do not repeat a mutation to obtain detail; use +`nx_result` instead. After uncertain transport delivery, query +`nx_operation_status` with the original operation ID before retrying. Snapshot +IDs are not mutation receipt IDs. Snapshots persist under `.nx-mcp/agent-results` +subject to the configured retention policy; they do not keep NX +references alive. Storage failures fall back to the original full response and +never relabel committed geometry as a failed operation. + +Part/component inventories default to compact pages of twenty. Component poses +are omitted unless `include_transforms=true`. Existing filters and explicit +arguments override profile defaults. Every inventory page remains explicit about +its returned and total counts. Geometry vectors/scalars are preserved; use full +snapshot detail for omitted nested arrays and metadata. + +### Oversized native results + +The bridge keeps a bounded receipt when a native result exceeds 512 KiB. It retains the operation outcome and restore ID and adds `full_result` with a snapshot ID, checksum and size. Read it with `nx_invoke(tool="nx_read_result", arguments={"result_id":"result_...","field":"/objects","offset":0,"limit":20})`, or call `nx_read_result` directly on the full surface. Arrays, strings and objects are paged; nested omissions provide paths for further reads. Do not repeat the mutation to obtain omitted data. + +Bridge snapshots live in `.nx-mcp/bridge-results` under the configured retention age/size limits. These are separate snapshot stores: `nx_result` reads agent snapshots; `nx_read_result` reads bridge snapshots. Expiration does not delete durable operation records. Delivery/storage errors preserve known outcomes; interrupted calls with unknown outcomes still require `nx_operation_status`. + +## Artifacts + +Downloads default to metadata; eligible files have an MCP resource link at +`nx-artifact://workspace/...`. Resource reads return binary content up to 8 MiB. +Internal `.nx-mcp` service files and paths outside the workspace are rejected. +PNG captures retain native MCP image content. Resource bytes should be consumed +by the client outside model text; client behavior determines actual image/token +cost. Larger files use existing chunked downloads programmatically: + +```sh +python scripts/download_artifact.py project/model.step ./model.step \ + --url http://192.168.52.10:8765/agent/mcp +``` + +This streams directly to disk and verifies size and SHA-256. Explicit base64 +responses are available through `nx_invoke(detail="full")` for such clients; +compact text excludes binary data. Snapshot detail can contain original binary +fields: do not request these into model context. + +## Measuring efficiency + +Install the `benchmark` extra and run `scripts/benchmark_agent_surface.py` on an +operations JSONL transcript. It measures exact tokens under the named tokenizer, +not character estimates. Full and compact projections use the same recorded +native tasks. Reports explicitly exclude duplicate transport text, image tokens, +discovery and extra expansion calls. They are **not an autonomous-agent A/B +benchmark or a provider bill**. Optional observed usage JSON can be attached; +missing provider input/cache/output usage remains `unavailable`. + +For a real agent comparison, run the same exploded drawing, sheet-metal tray and +imported-part edit tasks with fresh sessions on each profile. Record every +model's provider input/cached/output usage, discovery calls, expansions, retries, +artifact handling, and native task assertions. Sum all workers and repairs. +Do not claim total token savings from response projection alone. + +## Reviewed discovery and task receipts (dev18) + +Exact-schema discovery also returns prerequisites, supported object kinds and a +minimal example for reviewed common workflows, plus version-specific native +capability evidence. Unreviewed tools explicitly return null examples/object +kinds rather than guessed recipes. The original description and schema remain +authoritative. Effects identify geometry/assembly mutation, visibility, saving, +file writes and reference invalidation; null means not reviewed, and true may +be conditional on arguments. Read-only tools explicitly have false mutation +effects. Names alone do not establish safety. + +Compact receipts suggest read-only next actions using references actually +returned by NX: topology inspection for new bodies, constraint diagnostics for +sketches and artifact metadata retrieval. They preserve geometry values and +recovery fields; next actions are never executed implicitly. + +`nx_inspect` provides consistent name/text and kind filtering before paging for +features, sketches, faces, edges and annotations. Return counts and units are +explicit. Coordinate frames are passed through from the native result; absent +frame metadata is `not_reported`. Reuse `result_id` for stable pages without +another NX call, or omit it to capture current geometry. Annotations may require +multiple serial native reads; snapshot capture is not an atomic NX transaction. + +Snapshot retention defaults to seven days and 256 MiB. Set positive integer +`NX_MCP_RESULT_MAX_AGE_SECONDS` and `NX_MCP_RESULT_MAX_BYTES` environment values +to configure it. Pruning runs on writes and protects the newly returned snapshot. +`nx_result_cleanup` defaults to a dry-run count/byte preview; `dry_run=false` +applies cleanup using optional overrides without changing persistent policy. +Only matching snapshot files are eligible. Symlinks, other files and the separate +mutation recovery directory are excluded. Missing/expired snapshot IDs return +`NX_RESULT_EXPIRED`; query mutation outcomes through `nx_operation_status`. + +## Fresh-agent trials + +`scripts/agent_benchmark_client.py` provides a transparent CLI for recording +actual agent tool requests and responses. Full-profile discovery filters the +full catalog locally before presenting matching schemas; agent-profile discovery +uses the server tool. This is a code-capable client comparison, not a measurement +assuming every full-profile schema is injected into model context. Each trial +uses a fresh agent, identical analytic tasks, isolated CAD paths and exclusive +serial ownership of NX. Record provider usage when exposed; otherwise mark it +unavailable. A single pair is diagnostic evidence, not a statistical efficiency +claim. Projection benchmarks remain separately labeled. + +### Findings addressed after the fresh-agent trial + +Spaced discovery queries now match all words, ranking tool-name matches first. +For example, `create part` finds `nx_create_part`. Set +`include_output_schema=false` with `include_schema=true` when only the input +contract and guidance are needed; the compatibility default still includes both. + +Compact projection preserves complete backend pages and their count/cursor. +An explicitly requested 100-row page is not truncated again to twenty. Default +part/component pages remain twenty. Other summarized arrays retain explicit +omission metadata and snapshot expansion. Inspection hints do not point back +to the same topology, diagnostic or artifact-metadata call. diff --git a/docs/architecture.md b/docs/architecture.md index ca046ea..100f82a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,17 +30,62 @@ operation; saving clears these native marks. Builders are destroyed from `finally` blocks. File operations are independently confined to the configured workspace on both sides of the process boundary. -## Certification boundary - -`server.py` explicitly registers the 16 certified tools. Legacy modules under -`tools/` are imported only when `NX_MCP_ENABLE_EXPERIMENTAL=1` is set on both -processes; Journal tools require `NX_MCP_ENABLE_JOURNAL=1` as well. A tool may -join the default surface only after strict boundary tests and a real-NX -contract test pass for the target build. - -The listener thread only queues requests. `pump_bridge()` executes them on the -NX journal's main thread, which is required by NXOpen. The bundled runner pumps -while waiting for `NX_MCP_BRIDGE_STOP_FILE`, so it is a batch feasibility path, -not an interactive GUI integration. If a target build cannot provide a -non-blocking GUI scheduler, the NX-side executor moves to a minimal C# plugin; -the JSON-RPC and MCP contracts remain unchanged. +## Profiles and graphical hosting + +The default exposes the original 16 tools. `NX_MCP_ENABLE_EXPERIMENTAL=1` +enables the extended integration; it does not classify every tool as untested. +The capability manifest separates native, sidecar-only and experimental evidence. +Journal execution retains a separate opt-in. The optional agent profile discovers +and invokes registered tools; it does not bypass their validation or permissions. + +The batch runner calls `pump_bridge()` on the journal main thread. The graphical +runner retains a Win32 timer callback and returns from the journal. The callback +executes one queued operation at a time on the NX UI thread. Pause releases input +for manual editing and invalidates references/checkpoints; resume requires fresh +inspection. Agent mode intentionally disables the NX main window even while idle; use +`nx_ui_control(mode="manual")` or **Pause / manual** to navigate or edit. This +handoff invalidates object references and checkpoints. The panel distinguishes +reserved idle, active operation and manual mode, paints before native execution, +and reports the last operation duration. Its window is owned by NX so the Pause +control stays above NX without being globally topmost. It does not continuously repaint an +unchanged label. + +`nx_ui_control(mode="status")` reads a timestamped snapshot without joining the +NX execution queue. `snapshot_age_seconds` is the age of the last main-thread +sample; `operation_elapsed_seconds` grows while an operation is running. These +are observations, not a hang detector or proof of kernel responsiveness. No +worker thread calls NXOpen. A native call holding the Python GIL can still delay +this endpoint. `.nx-mcp/ui-state.json` records the last sample before/after work +and approximately once a second while idle for external diagnosis. + +A long native call can block the UI and the panel. Pause and Stop take effect +after it returns; they cannot abort a native builder. Cooperative batch cancellation +is checked between child operations, not during a native builder call. + +## Integration references and recovery + +References carry session/generation identity, owner-part context and separate +journal/display names. Occurrences also carry assembly context. Closed or rolled +back geometry cannot be resolved through stale IDs. Geometric selectors are +re-evaluated rules, not permanent topological identities. + +Mutation receipts under `.nx-mcp/operations` store request fingerprints and explicit +outcomes. Retry with the same ID/arguments returns a committed receipt; conflicts +are rejected. An interrupted process can leave an unknown outcome requiring +reconciliation. Native checkpoints do not survive restart or expired NX undo marks. +Read-only inspection does not intentionally discard recovery history. + +Result snapshots under `.nx-mcp/agent-results` are separate from mutation receipts. +Retention/cleanup affects only those snapshots. A snapshot does not keep referenced +NX objects alive. See [agent contracts](agent-surface.md). + +## Files and transport + +Both process boundaries confine paths to the workspace. Relative and absolute +NX-host paths are accepted inside it; traversal, external resolved links and +internal state access are rejected. Filesystem publication has separate rollback +semantics from model undo. Assembly packages include referenced dependencies. + +The NX bridge is authenticated loopback IPC. The optional HTTP sidecar is a +separate transport and needs its own access controls; it is not authenticated +by the private bridge token. Stdio is the default transport. diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md new file mode 100644 index 0000000..bb8dc71 --- /dev/null +++ b/docs/capability-matrix.md @@ -0,0 +1,233 @@ +# Capability evidence matrix + +Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by hand. +Run `python scripts/generate_capability_matrix.py` to regenerate, or add `--check` to detect drift. + +Manifest revision: **2606-manufacturing-r1**. NX: **v2606**. Bridge protocol: **1**. +Canonical manifest SHA-256: `508138fabac46f909bad567d313ac31c714e59c99268d5ecb95f5a579b72097f`. + +These labels report manifest evidence, not certification or independent verification of its claims. Native-tested means status `tested` with an evidence type beginning `real_NX_`; only the stated scope and NX version are covered. Contract/sidecar-tested does not establish native CAD correctness. Experimental includes untested entries and tested entries without a recognized evidence type. Unavailable capabilities are explicitly recorded by the manifest; absence from this matrix is not proof of availability or unavailability. + +| Tool classification | Count | +| --- | ---: | +| Native-tested | 181 | +| Contract/sidecar-tested | 8 | +| Experimental | 0 | +| Unavailable | 0 | + +## Tools + +| Tool | Classification | Manifest status | Evidence type | Tested scope / caveat | +| --- | --- | --- | --- | --- | +| nx_activate_drawing | Native-tested | tested | real_NX_v2606_scoped | Native drawing/modeling switching on saved fixture parts; unrelated parts preserved. | +| nx_activate_part | Native-tested | tested | real_NX_v2606 | Used by loaded-part opening; work/display activation; modified flags preserved | +| nx_add_base_view | Native-tested | tested | real_NX_v2606_scoped | Native top base view of a single-body part; sheet placement verified in exported PDF. | +| nx_add_component | Native-tested | tested | real_NX_v2606 | Initial placement, typed references and nested source assembly | +| nx_add_detail_drawing_view | Native-tested | tested | real_NX_v2606_scoped | Circular detail with explicit model-coordinate center and radius mapped into the parent drawing view; scaled view and exported PDF. | +| nx_add_dimension | Native-tested | tested | real_NX_v2606_scoped | Native horizontal edge dimension with computed size10mm; exported PDF visually reviewed. Aligned/vertical variations are not separately kernel-tested. | +| nx_add_flat_pattern_view | Native-tested | tested | real_NX_v2606_scoped | Native Flat Pattern named view on metric drawing; PDF exported and visually reviewed. | +| nx_add_projection_view | Native-tested | tested | real_NX_v2606_scoped | Native right projected view and associative parent; exported PDF visually reviewed. | +| nx_add_section_drawing_view | Native-tested | tested | real_NX_v2606_scoped | Simple native section through a circular hole center; explicit scale and hatch visible in exported PDF. Complex stepped sections are not included. | +| nx_assembly_constraint | Native-tested | tested | real_NX_v2606_scoped | Native fix and face-distance constraints; actual separation measured. Other exposed relation types retain narrower validation. | +| nx_batch | Native-tested | tested | real_NX_v2606 | Two-step partial failure fully rolled back; serial execution on journal thread | +| nx_bend_table | Native-tested | tested | real_NX_v2606_scoped | Native flat-pattern bend table create/edit, column ordering, evaluated row readback and angle update from 75 to 80 degrees; PDF visually reviewed. | +| nx_bind_parameter | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Native EXTRUDE end expression binding and dependent update; EXTRUDE start and PATTERN count/spacing share builder paths but are not separately native-tested. | +| nx_blend | Native-tested | tested | real_NX_v2606_scoped | Native single-edge radius1 blend on a cube; installed AddChainset API and cleanup verified. | +| nx_boolean | Native-tested | tested | real_NX_v2606_scoped | Overlapping1000mm3 cubes: unite1500, subtract500, intersect500mm3 verified analytically. | +| nx_bridge_surface | Native-tested | tested | real_NX_v2606_scoped | Native full-edge planar G0/G1/G2 bridge creation; requested constraints are not independent geometric certification. | +| nx_cancel_operation | Contract/sidecar-tested | tested | local_contract_tests | Sidecar contract accepts cancellation only for running batches and creates the cancellation marker; batch boundary seam verifies rollback. Live non-running request rejected. No mid-NXOpen interruption claim. | +| nx_capabilities | Native-tested | tested | real_NX_v2606_scoped | Live exact-tool manifest filtering, NX version and API-detection response. API presence is not a geometry correctness certificate. | +| nx_chamfer | Native-tested | tested | real_NX_v2606_scoped | Native single-edge symmetric-offset chamfer on a cube; explicit edge collector and tolerance. | +| nx_check_clearance | Native-tested | tested | real_NX_v2606_interactive | Native 2 mm gap flagged below 3 mm requirement; bounded pair selection and conservative broad phase | +| nx_check_interference | Native-tested | tested | real_NX_v2606_interactive | 10 mm cubes: 500 mm^3 overlap, touching and 2 mm gap; rotated nested occurrence overlap; cleanup preserves saved flags and checkpoint | +| nx_checkpoint | Native-tested | tested | real_NX_v2606 | In-session model checkpoint and available-state inspection | +| nx_checkpoint_state | Native-tested | tested | real_NX_v2606 | Checks actual NX mark availability, including save expiration | +| nx_clear_highlights | Native-tested | tested | real_NX_v2606_public_MCP | Clears MCP-owned native highlights without persistent appearance changes | +| nx_close_part | Native-tested | tested | real_NX_v2606 | Blocks closing prototypes referenced by other loaded assemblies before saving. Native edited-child guard passed; close parents first. Reports all parts actually closed and invalidates their references. | +| nx_component_action | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_component_array | Native-tested | tested | real_NX_v2606_scoped | Native associative rectangular 3x2 and circular 4-instance patterns, including seed. | +| nx_copy_project | Native-tested | tested | real_NX_v2606_scoped | Native clone of saved assembly and prototype, rewritten dependencies, source hashes and manifest; partial-file cleanup covered locally. | +| nx_create_directory | Contract/sidecar-tested | tested | local_contract_tests | Workspace-scoped directory creation and idempotent existing-directory reporting. | +| nx_create_drawing | Native-tested | tested | real_NX_v2606_scoped | Native metric A3 sheet at 1:1 first-angle projection; sheet opening and typed reference. | +| nx_create_explosion | Native-tested | tested | real_NX_v2606_scoped | Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged. | +| nx_create_part | Native-tested | tested | real_NX_v2606 | Fresh millimeter parts in isolated NX test workspace | +| nx_create_parts_list | Native-tested | tested | real_NX_v2606_scoped | Native assembly drawing BOM: three repeated instances aggregate to quantity 3 using installed column defaults. | +| nx_create_path_sketch | Native-tested | tested | real_NX_v2606_scoped | Native edge-path sketch with arc-length percentage, orienting face and frame read-back; successful secondary contour flange. | +| nx_create_reference_set | Native-tested | tested | real_NX_v2606_scoped | Native SOLIDS reference set containing one block body; saved and consumed by three assembly occurrences. | +| nx_create_sketch | Native-tested | tested | real_NX_v2606 | XY, XZ, YZ and an offset arbitrary orthonormal basis; actual frames and curve coordinates checked | +| nx_curve_analysis | Native-tested | tested | real_NX_v2606_scoped | Native derivative evaluation on owned line/spline curves; singular handling unit-tested; sampling is not a global extrema certificate. | +| nx_delete_explosion | Native-tested | tested | real_NX_v2606_scoped | Native unassociated explosion deletion followed by empty explosion inventory; in-use rejection and stale invalidation covered locally. | +| nx_delete_feature | Native-tested | tested | real_NX_v2606_scoped | Native extrusion deletion after Save As with a reacquired feature ID; resulting body count zero. Dependency cascades beyond this fixture are not independently tested. | +| nx_dimension_format | Native-tested | tested | real_NX_v2606_scoped | Native associative dimension readback, computed value, decimal precision, units and physical asymmetric tolerances. | +| nx_display_info | Native-tested | tested | real_NX_v2606_public_MCP | Body/face and nested occurrence color, transparency and explicit blank state | +| nx_download_file | Contract/sidecar-tested | tested | local_contract_test | Chunk bytes, full checksum and boundary/overwrite tests | +| nx_draft | Native-tested | tested | real_NX_v2606_scoped | Native 5 degree face draft with analytic volume and explicit angle/distance tolerances. | +| nx_drawing_table | Native-tested | tested | real_NX_v2606_scoped | Native title-block definition and editable revision table with evaluated cell readback and reviewed PDF. | +| nx_drawing_view_info | Native-tested | tested | real_NX_v2606_scoped | Native scale, sheet coordinates, border and containment readback for base, detail and section views; millimeter and inch sheets. | +| nx_edit_annotation | Native-tested | tested | real_NX_v2606_scoped | Native associative balloon movement; rename/delete have local contract coverage pending native acceptance. | +| nx_edit_assembly_constraint | Native-tested | tested | real_NX_v2606_scoped | Native suppression toggle and distance 5->12 edit; actual component separation verified after rebuilding solve network. | +| nx_edit_component_pattern | Native-tested | tested | real_NX_v2606_scoped | Native rectangular 4x3 and circular 5-instance edits; expression and instance readback. | +| nx_edit_dimension_format | Native-tested | tested | real_NX_v2606_scoped | Native 80 mm dimension formatted to two decimals and +0.05/-0.02 mm without changing its value or two associations; exported PDF visually verified. | +| nx_edit_drawing_view | Native-tested | tested | real_NX_v2606_scoped | Base and section style edits, placement, scale and save/reopen tested. Construction filtering excludes sheet-owned section curves. Fonts include 0 invisible. hidden_lines/self_hidden are native processing toggles: false can expose occluded edges. Centerline preference remains read-only. | +| nx_edit_explosion | Native-tested | tested | real_NX_v2606_scoped | Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged. | +| nx_edit_explosion_trace | Native-tested | tested | real_NX_v2606_scoped | Native managed edge-anchored trace endpoint percentages and offsets edited in a two-component service assembly; rendered and included in drafting view. | +| nx_edit_faces | Native-tested | tested | real_NX_v2606_scoped | Native directed move, signed offset, replace and delete/heal on controlled solids; analytic volume checks. Arbitrary vendor imports unverified. | +| nx_edit_feature | Native-tested | tested | real_NX_v2606 | Extrusion distance 46.25 and native linear-pattern count/pitch; unsupported edit unchanged | +| nx_edit_sketch | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_explosion_info | Native-tested | tested | real_NX_v2606_scoped | Native exploded and assembled occurrence poses and typed associated view references, including nested assembly. | +| nx_explosion_trace | Native-tested | tested | real_NX_v2606_scoped | Native traceline with persistent component/edge handles; exact endpoints preserved after save/reopen and updated by MCP placement changes. Manual edits require MCP refresh. | +| nx_export_drawing_pdf | Native-tested | tested | real_NX_v2606_scoped | Native full-sheet PDF with explicit high-resolution shaded images and updated view lists. Saved/reopened shaded solid and section verified visually; unloaded unsuppressed prototypes fail preflight. Hidden-edge invisibility verified with processing on and font 0. | +| nx_export_explosion_animation | Native-tested | tested | real_NX_v2606_scoped | Three native frames with fixed camera, pose interpolation and restored state; fully framed visual review. Failure cleanup unit-tested. | +| nx_export_flat_pattern | Native-tested | tested | real_NX_v2606_scoped | Native DXF and Trumpf GEO export; staged file publication, checksums. DXF entity geometry inspected. | +| nx_export_planar_dxf | Native-tested | tested | real_NX_v2606_scoped | Analytic lines, circles and arcs from planar sketches and faces; principal/custom frames, layers and inch-to-mm conversion tested in NX. XY sketch/face output independently parsed with ezdxf; splines are rejected. | +| nx_export_step | Native-tested | tested | real_NX_v2606 | Solid/assembly exports verified by import counts, volumes, exact bounds and transforms | +| nx_extrude | Native-tested | tested | real_NX_v2606_scoped | Native offset/symmetric/arbitrary-direction extrusion, through-all subtraction and up-to-face solids; analytic volume and bounds checked. | +| nx_face_analysis | Native-tested | tested | real_NX_v2606_scoped | Native sampled plane normal/curvature and signed draft; trimmed-domain filtering. Not global draft certification. | +| nx_feature_parameters | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Native extrusion-owned expression enumeration; other feature kinds depend on exposed GetExpressions results. | +| nx_find_geometry | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Native trimmed BREP point-to-face/edge distance; selector queries, principal plane filter and radius filter. Highest/lowest retain conservative center ordering. | +| nx_finish_preview | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_finish_sketch | Native-tested | tested | real_NX_v2606 | Principal/custom sketch completion and subsequent extrusion | +| nx_fit_view | Native-tested | tested | real_NX_v2606_scoped | Native fit on a displayed solid fixture in graphical NX; framing quality remains view-dependent. | +| nx_flat_pattern_orientation_edges | Native-tested | tested | real_NX_v2606_scoped | Native planar formed-web straight boundary discovery; returned edge created a Flat Pattern on first attempt. Geometric candidates only; not a kernel acceptance guarantee. | +| nx_geometry_anchor | Native-tested | tested | real_NX_v2606_scoped | Owned face persistent handle, owner-part identity and exact native resolution after save/reopen; no nearest-geometry fallback. | +| nx_get_bounding_box | Native-tested | tested | real_NX_v2606 | Part and two-level assembly; conservative and exact with axis-aligned WCS | +| nx_get_feature_info | Native-tested | tested | real_NX_v2606 | Extrude and Pattern Feature expressions and dependencies | +| nx_highlight_collisions | Native-tested | tested | real_NX_v2606_public_MCP | Native highlights on two intersecting nested body occurrences; clear pair not highlighted; inline viewport verified | +| nx_highlight_objects | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_hole | Native-tested | tested | real_NX_v2606_scoped | Native cylindrical subtraction with numeric coordinates, target body and direction; not a threaded/drill-tip HolePackage feature. | +| nx_import_geometry | Native-tested | tested | real_NX_v2606 | STEP solids and nested assembly through WorkPart importer, normal new-part creation; source prototypes closed explicitly; names preflighted | +| nx_inspection_report | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_list_annotations | Native-tested | tested | real_NX_v2606_scoped | Native BOM, balloon and managed sheet-metal PMI enumeration and text. | +| nx_list_assembly_constraints | Native-tested | tested | real_NX_v2606_scoped | Native typed constraint references, geometry/occurrence references, expressions, suppression and solver statuses. | +| nx_list_bodies | Native-tested | tested | real_NX_v2606_scoped | Native work-part inventory reports one extruded body and zero after feature deletion. | +| nx_list_component_patterns | Native-tested | tested | real_NX_v2606_scoped | Native linear, two-direction rectangular and circular pattern metadata and actual occurrence transforms. | +| nx_list_components | Native-tested | tested | real_NX_v2606 | Native loaded and unloaded prototype inventory retains occurrence references, paths and transforms with explicit load_state. Unknown source paths are nullable; unloaded subassemblies can hide descendants. No implicit loading. | +| nx_list_datums | Native-tested | tested | real_NX_v2606_scoped | Native owned datum planes/axes and coordinate-system enumeration on template part. | +| nx_list_dimensions | Native-tested | tested | real_NX_v2606_scoped | Native computed size and retention diagnostics; occurrence-edge dimension follows extrusion resize and explicitly rebinds after replacement. | +| nx_list_drawings | Native-tested | tested | real_NX_v2606_scoped | Native A3 sheet/view enumeration, dimensions, scale and active state. | +| nx_list_explosions | Native-tested | tested | real_NX_v2606_scoped | Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged. | +| nx_list_expressions | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_list_features | Native-tested | tested | real_NX_v2606_scoped | Native sketch/extrusion inventory and reacquisition of a renamed feature following Save As. | +| nx_list_open_parts | Native-tested | tested | real_NX_v2606 | Loaded names, paths, IDs, work/display status and modified flags | +| nx_list_reference_sets | Native-tested | tested | real_NX_v2606_scoped | Native body-only custom set enumeration and exact member count. | +| nx_list_sections | Native-tested | tested | real_NX_v2606_public_MCP | Native plane enumeration; active view state and saved flags preserved | +| nx_list_sketches | Native-tested | tested | real_NX_v2606_scoped | Native two-sketch work-part inventory with typed references. | +| nx_list_topology | Native-tested | tested | real_NX_v2606 | Face and edge enumeration; face references used in actual distance query Native face-restricted boundary enumeration and bidirectional adjacency used for flat-pattern orientation. | +| nx_loft | Native-tested | tested | real_NX_v2606_scoped | Native solid loft between square sections with analytic volume; sheet configuration has local contract coverage. | +| nx_mass_properties | Native-tested | tested | real_NX_v2606_scoped | Native solid mass, volume, center of gravity and centroidal inertia; 2700kg/m3 test cube matches analytic results. Nested rotated/translated two-body assembly: mass0.0054kg and CoG[0.005,0.035,0.035]m verified. | +| nx_mate_component | Native-tested | tested | real_NX_v2606_scoped | Native touch mate at zero clearance and offset mate at7mm verified by measured separation. Other mate types have narrower validation. | +| nx_material_info | Native-tested | tested | real_NX_v2606_scoped | Native physical material name and kg/m3 density readback; assembly occurrence prototypes supported. | +| nx_measure_angle | Native-tested | tested | real_NX_v2606_scoped | Native perpendicular sketch lines measure 90 degrees using typed references. Straight-edge and planar-face paths use native geometry; broader pair variants are not separately kernel-tested. | +| nx_measure_distance | Native-tested | tested | real_NX_v2606 | Body/body, face/face, nested component/body occurrences; closest points and units | +| nx_measure_volume | Native-tested | tested | real_NX_v2606 | Part and nested assembly sum, returned in mm^3; no union/mass claim Inch-part 0.5 cubic inch volume independently checked as 8193.532 mm3 using explicit native AnalysisUnit. | +| nx_mirror_body | Native-tested | tested | real_NX_v2606_scoped | Native body mirror about YZ origin plane; doubled total volume and reflected bounding box. | +| nx_model_health | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_model_summary | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_native_component_pattern | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | NX 2606 native associative linear pattern: 16 total occurrences of 14 mm seed at 16.5 mm pitch span 261.5 mm. | +| nx_open_part | Native-tested | tested | real_NX_v2606 | Open or activate saved workspace paths. Explicit load_components=true recovers unloaded prototypes on an already loaded parent without closing it; native occurrence pose and 1000 mm3 solid retained. | +| nx_operation_status | Contract/sidecar-tested | tested | local_contract_test | Durable committed/failed/unknown receipt tests; no crash reconstruction claimed | +| nx_package_assembly | Native-tested | tested | real_NX_v2606_scoped | Saved two-part assembly ZIP downloaded with verified checksum, two .prt members and valid archive CRCs; deeper dependency trees are outside this fixture. | +| nx_parts_list_balloons | Native-tested | tested | real_NX_v2606_scoped | Native associated grouped balloon created for an assembly drawing view. | +| nx_parts_list_column | Native-tested | tested | real_NX_v2606_scoped | Native BOM header/width edits, general column append/remove and evaluated values. | +| nx_parts_list_info | Native-tested | tested | real_NX_v2606_scoped | Native evaluated BOM rows and preference readback. | +| nx_pattern | Native-tested | tested | real_NX_v2606 | Native Pattern Feature; 16 total at 16.5 pitch, width 261.5; edit to 3 at 20 pitch | +| nx_pattern_components | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_pmi_datum | Native-tested | tested | real_NX_v2606_scoped | Native geometry-associated datum A on a planar face. | +| nx_pmi_fcf | Native-tested | tested | real_NX_v2606_scoped | Native single-frame flatness/parallelism annotations and datum A reference; all GD&T modifiers are not exposed. | +| nx_preview_change | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_read_result | Contract/sidecar-tested | tested | local_contract_tests | Immutable bounded bridge snapshots, JSON-pointer pagination, result cardinality and committed-outcome preservation on storage failure; 2032-object response fixture. | +| nx_rebuild_model | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Native DoUpdate success and health read-back; failed-update rollback covered by local fault injection. | +| nx_recognize_holes | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Annular solid: inner cylinder identified as bore, outer cylinder excluded; axis/radius/full circumference read-back. Coaxial grouping and partial-face reporting covered locally; no manufacturing feature inference. | +| nx_refresh_annotations | Native-tested | tested | real_NX_v2606_scoped | Native persistent bend PMI, transaction hook and save/reopen exercised through public MCP; automatic bend-table builders also rebuilt because the native flag alone left stale rows. | +| nx_rename_object | Native-tested | tested | real_NX_v2606_scoped | Native extrusion rename resolved again by returned name after Save As. Other object kinds are not independently covered by this fixture. | +| nx_render_view | Native-tested | tested | real_NX_v2606_scoped | Native Studio image capture, exact 800x600 and 640x480 PNGs; preset2/custom RGB tested; native viewport image visually reviewed. | +| nx_reposition_component | Native-tested | tested | real_NX_v2606_scoped | Native immediate-child 20-unit X translation and 90-degree Z rotation; identical operation-ID replay does not apply translation twice. | +| nx_resolve_geometry | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Exact point-to-face query re-evaluated after extrusion edit and save/close/reopen; ties rejected. Geometric rule, not immutable topology identity. | +| nx_resolve_geometry_anchor | Native-tested | tested | real_NX_v2606_scoped | Owned face survives save/reopen and rollback in a public USB connector STEP fixture; stale and wrong-owner rejection tested locally. | +| nx_restore_display | Native-tested | tested | real_NX_v2606_public_MCP | Reverse-order restore; invalid order rejected before mutation; face IDs retained across appearance and camera changes | +| nx_restore_presentation | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_revolve | Native-tested | tested | real_NX_v2606 | XY rectangular profile around global Y, boolean none, case-insensitive name lookup | +| nx_rollback | Native-tested | tested | real_NX_v2606 | Explicit checkpoint rollback; stale references rejected afterward | +| nx_save_as | Native-tested | tested | real_NX_v2606_scoped | Native metric solid saved into a nested folder; work-part filename changes and references are reacquired before deletion. Original saved prototype loads into the test assembly. | +| nx_save_part | Native-tested | tested | real_NX_v2606 | Saves only the work part, preserving drawing-preview context. A native 73-loaded-part fixture verified the saved path and file hash, unchanged unrelated files/flags, and an edited child remaining unsaved. Native save errors and unexpected changes return a partial-outcome receipt. Activate and save each component explicitly. | +| nx_save_presentation | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_screenshot | Native-tested | tested | real_NX_v2606_interactive | Native viewport PNG, white/transparent backgrounds, shaded/shaded-with-edges; requested dimensions advisory; actual device resolution returned | +| nx_section_control | Native-tested | tested | real_NX_v2606_public_MCP | Enable, disable and delete native dynamic sections without modifying solids | +| nx_section_view | Native-tested | tested | real_NX_v2606_public_MCP | Principal and arbitrary single-plane clips on solids and assemblies; native cap images; geometry bounds and volume unchanged | +| nx_set_camera | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_set_component_reference_set | Native-tested | tested | real_NX_v2606_scoped | Native direct-child assignment to three occurrences, unchanged translations, persisted drawing excludes prototype datums. Nested children require activating owner. | +| nx_set_component_transform | Native-tested | tested | real_NX_v2606 | Absolute immediate-child placement; repeated identical pose | +| nx_set_datum_visibility | Native-tested | tested | real_NX_v2606_scoped | Native blank/unblank snapshot restoration, and assembly-owned datum suppression confirmed by exported drawing PDF visual review. | +| nx_set_display | Native-tested | tested | real_NX_v2606_public_MCP | Named color and transparency; face attribute restoration; nested occurrence override leaves shared prototypes unchanged | +| nx_set_expression | Native-tested | tested | real_NX_v2606_and_local_stateful_seams | Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits. | +| nx_set_feature_parameters | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Native extrusion-owned Number formula edit and resulting bounds; preflight/rollback boundary tests. No blanket verification of other feature kinds. | +| nx_set_material | Native-tested | tested | real_NX_v2606_scoped | Local density-only physical material assignment, verified by UF native body density. | +| nx_set_sheet_metal_defaults | Native-tested | tested | real_NX_v2606_scoped | Value-mode thickness/radius/neutral factor and numeric read-back. Material/tool tables and custom bend tables remain experimental. | +| nx_set_view | Native-tested | tested | real_NX_v2606_scoped | Native Top, Back and Isometric selection on a solid fixture. Other canned views follow the same API but are not individually verified. | +| nx_set_visibility | Native-tested | tested | real_NX_v2606_public_MCP | Show/hide, nested isolation and restoration of previously hidden components | +| nx_sew | Native-tested | tested | real_NX_v2606_scoped | Native adjacent planar-sheet sewing; incomplete-sew and solid-fallback rejection covered by unit tests. | +| nx_sheet_metal_annotation | Native-tested | tested | real_NX_v2606_scoped | Native body/bend PMI with measured snapshot text and explicit refresh; no automatic numeric text update claim. | +| nx_sheet_metal_context | Native-tested | tested | real_NX_v2606_scoped | Modern graphical UG_APP_SBSM context; no legacy application switch or model undo mark. | +| nx_sheet_metal_defaults | Native-tested | tested | real_NX_v2606_scoped | Numeric defaults and bend-definition read-back; unsafe native material catalog enumeration intentionally not called. | +| nx_sheet_metal_feature | Native-tested | tested | real_NX_v2606_scoped | 34 native creation fixtures on NX v2606; flange builder edit verified. Individual options and other edit combinations remain experimental. Two-bend partial-width channel with Square/Round reliefs and independently calculated developed DXF dimensions; adjacent mitered flanges and flat export. | +| nx_sheet_metal_info | Native-tested | tested | real_NX_v2606_scoped | Native body recognition, thickness, inner bend faces, angle/radius/neutral factor read-back. | +| nx_sheet_metal_schema | Native-tested | tested | real_NX_v2606_scoped | Strict schemas for 34 native operation families; per-operation examples and validation scopes. | +| nx_shell | Native-tested | tested | real_NX_v2606_scoped | Native open-box shell: inward 1 mm thickness on 10 mm cube gives 424 mm3. Outward configuration separately checked locally. | +| nx_show_explosion | Native-tested | tested | real_NX_v2606_scoped | Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged. | +| nx_sketch_angle | Native-tested | tested | real_NX_v2606_scoped | Native driving angular dimension creation and expression; broader angle configurations remain unverified. | +| nx_sketch_arc | Native-tested | tested | real_NX_v2606 | Full circles on XY, XZ and YZ; resulting solid dimensions and volumes checked | +| nx_sketch_conflicts | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Native no-conflict query and explicit horizontal+vertical contradiction, bounded single-removal relief, restored constraint count/status. Not a minimal conflict set or general legacy relation verifier. | +| nx_sketch_constraint | Native-tested | tested | real_NX_v2606_scoped | Native horizontal constraint on an owned sketch line through the supported sketch editor. Other routed relations/dimensions retain their dedicated-tool scopes; midpoint is explicitly unsupported. | +| nx_sketch_diagnostics | Native-tested | tested | real_NX_v2606_public_MCP | Active/inactive whole-sketch native evaluation; underconstrained and fully fixed fixtures; remaining DOF, constraints and curve links; saved state preserved | +| nx_sketch_dimension | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Line length (XZ), horizontal/vertical distances and arc radius/diameter creation; associated expression edit. Reference mismatch guard covered locally. | +| nx_sketch_info | Native-tested | tested | real_NX_v2606_scoped | Native XY sketch with two perpendicular lines; frame and owned curve readback. Principal/custom-frame geometry also covered by the sketch creation fixtures. | +| nx_sketch_line | Native-tested | tested | real_NX_v2606 | Principal-plane profile coordinates checked against resultant solids | +| nx_sketch_primitive | Native-tested | tested | real_NX_v2606_scoped | Circle, horizontal slot and rounded rectangle: native curves and extruded analytic volumes. | +| nx_sketch_rectangle | Native-tested | tested | real_NX_v2606 | Principal/custom bases, multiple loops, retry deduplication and batch rollback | +| nx_sketch_relation | Native-tested | tested | real_NX_v2606_scoped_and_local_boundary_tests | Modern solver parallel/perpendicular/equal_length/equal_radius/concentric/coincident operations with actual geometric residual checks; line endpoints and arc centers. Legacy branch boundary-tested only. | +| nx_sketch_symmetry | Native-tested | tested | real_NX_v2606_scoped | Modern line-pair symmetry about a line; persistent Mirror relation and zero geometric residual. | +| nx_sketch_tangent | Native-tested | tested | real_NX_v2606_scoped | Modern line/circle tangent relation with persistent constraint enumeration and zero geometric residual. | +| nx_sketch_trim_extend | Native-tested | tested | real_NX_v2606_scoped | Native line trim and line extension against an explicit crossing line; reacquire geometry after edits. | +| nx_spline | Native-tested | tested | real_NX_v2606_scoped | Native associative 3D interpolation spline creation/edit and degree-2 control-pole curves; periodic variants unverified. | +| nx_standard_thread | Native-tested | tested | real_NX_v2606_scoped | Native Metric Coarse M6 x 1.0 internal/external symbolic/detailed threads; pitch and material-removal verification. Detailed Metric Fine M6x0.75 and Inch UNC 1/4-20, right and left handed. | +| nx_status | Native-tested | tested | real_NX_v2606_scoped | Live graphical NX 2606 connection, version and UI/main-thread status; restored saved session. | +| nx_surface_continuity | Native-tested | tested | real_NX_v2606_scoped | Bidirectional sampled UF geometry: matching planes pass G0/G1/G2, separated planes fail, tangent plane/quadratic join fails G2. Not a global certificate. | +| nx_surface_mesh | Native-tested | tested | real_NX_v2606_scoped | Native planar and quadratic Through Curve Mesh fixtures from two primary and two cross sections. | +| nx_sweep | Native-tested | tested | real_NX_v2606_scoped | Native square sketch swept along a straight guide sketch; boolean variants use existing boolean operation. | +| nx_thicken | Native-tested | tested | real_NX_v2606_scoped | Native 2 mm sheet thickening with independently checked 400 mm^3 solid volume. | +| nx_thread | Native-tested | tested | real_NX_v2606_scoped | Native manual symbolic and detailed internal/external thread creation with explicit start face and cylinder diameter. Standards-table fit classes not exposed. | +| nx_thread_catalog | Native-tested | tested | real_NX_v2606_scoped | Reads installed NX catalog in place; exact Metric Coarse M6 x 1.0 row selection without file transfer. | +| nx_transform_bodies | Native-tested | tested | real_NX_v2606_scoped | Associative body extraction plus native MoveObject; copy preserves source; absolute transform replacement verified by bounds. | +| nx_trim_sheet | Native-tested | tested | real_NX_v2606_scoped | Native line-boundary half-sheet trim using section and region point. | +| nx_ui_control | Native-tested | tested | real_NX_v2606_interactive | UI-thread identity, visible model mutations, manual pause rejection and resume; native panel | +| nx_undo | Native-tested | tested | real_NX_v2606 | Undo after read-only inspection; save boundary explicitly inspected | +| nx_update_assembly_documentation | Native-tested | tested | real_NX_v2606_scoped | Two-component assembly: extrusion resize and prototype replacement, fixed mates, clearance, explosion traces, BOM and drawing regeneration. Retained dimensions are reported and explicitly rebound. | +| nx_update_parts_list | Native-tested | tested | real_NX_v2606_scoped | Native BOM refresh after adding a fourth instance gives quantity 4. | +| nx_upload_file | Contract/sidecar-tested | tested | local_contract_test | Chunk replay, final checksum, atomic publication, no overwrite and workspace boundary tests | +| nx_view_info | Native-tested | tested | real_NX_v2606_interactive | Interactive display-part camera axes, origin and scale | +| nx_wall_thickness | Native-tested | tested | real_NX_v2606_scoped | Native inward-normal ray thickness: nine 5 mm plate samples. Not rolling-ball or global-minimum thickness. | +| nx_workspace_info | Contract/sidecar-tested | tested | live_sidecar_and_contract_tests | NX host workspace root and path conventions; no NX geometry calls. | +| nx_workspace_list | Contract/sidecar-tested | tested | live_sidecar_and_contract_tests | Live sidecar nested fixture directory with one-entry pagination; workspace boundary and pagination contracts tested locally. | + +## Explicitly unavailable capabilities + +These are capability names, separate from the tool counts above. + +- batch_model_viewport_image +- union_of_all_pairwise_interference_volumes + +## Manifest limitations + +- No general certification +- Native save expires undo marks +- Checkpoint recovery does not survive NX process restart +- Exact bounds require axis-aligned WCS +- Assembly import can conflict with loaded STEP prototype names +- Modified-object tracking is explicit-only; null means not comprehensive +- Batch structural preflight is not a geometric dry run +- Cooperative cancellation happens between operations only +- Absolute placement currently supports immediate children +- MCP desktop clients must refresh tool schemas after deployment +- CLIFF vendor STEP conversion still returns no bodies with NX v2606 translators; logs and rolled-back new-part outcome are returned. No vendor-geometry repair is claimed. +- Adam Tech vendor geometry has two self-intersecting faces (875315); health diagnostics resolve faces and error text. Native healing failed on a disposable copy and was rolled back. +- Drawing centerline visibility is inspectable but not writable: the tested native setter did not persist. diff --git a/docs/migration-0.2.md b/docs/migration-0.2.md index 7cb9baa..07dcc24 100644 --- a/docs/migration-0.2.md +++ b/docs/migration-0.2.md @@ -28,7 +28,7 @@ Use `nx_create_sketch` or `nx_list_sketches` to obtain `sketch_id`. Object IDs become stale after the part closes or is reopened and must then be queried again. -The 34 remaining 0.1 tools are hidden until `NX_MCP_ENABLE_EXPERIMENTAL=1` is -set on both processes. They remain unsupported until certified on real NX. +The extended integration is hidden until `NX_MCP_ENABLE_EXPERIMENTAL=1` is +set on both processes. The compatibility flag name does not describe per-tool verification. Consult the capability matrix for native-tested, sidecar-tested and experimental scopes. Journal execution additionally requires `NX_MCP_ENABLE_JOURNAL=1`, and journal paths are restricted to the workspace `journals/` directory. diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index 2d852cb..9882ac9 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -1,6 +1,6 @@ # Real NX validation gate -## Latest validated run +## Historical upstream batch run - Date: 2026-08-21 - NX: v2506 (`ugraf.exe` 2506.4021; `run_journal.exe` 2506.4000) @@ -21,7 +21,7 @@ not validate non-blocking interactive NX GUI responsiveness. - Whether NX is native or Teamcenter-managed mode - Test machine identifier and Windows version -Version 0.2 initially supports only this recorded NX build and native parts. +The upstream baseline was scoped to that build. The fork adds separately recorded NX 2606 graphical fixtures. ## Preconditions @@ -79,3 +79,186 @@ starts the Python bridge, then runs `pytest -m real_nx`. It requests the bridge to stop even when acceptance fails. Once the runner is reliable, make this workflow a required release/branch gate in the repository settings; the normal hosted CI deliberately excludes `real_nx` because it cannot provide Siemens NX. + +## NX 2606 integration evidence + +Dev18 runtime `9254c028eac8ffdfeed54201377ba62a052b0a1c` passed 868 ordinary tests +at 79.08% branch coverage, sidecar type checks and hosted CI. One dedicated NX +runner test was skipped in that suite. Separate graphical checks verified UI-thread +dispatch, stdio/HTTP, installed-source identity and native PNG delivery. Session +preservation checked 38 saved original parts and 116 occurrence paths, poses, +suppression states and reference sets. + +Two fresh agents completed analytic plate, three-instance assembly and STEP +round-trip fixtures. The comparison used dev17 full versus initial dev18 agent; +final pagination/discovery fixes received a separate follow-up. Provider usage +was unavailable; response tokenizer counts are not total model costs. + +Historical release receipts are retained in git history and local evidence, rather +than one documentation file per deployment. The [capability matrix](capability-matrix.md) +is generated from the runtime manifest. Do not promote a label based on builder +presence, a mock test or an unrelated native fixture. + +## Running integration suites + +Use isolated fixtures and a configured public MCP endpoint. Native scripts live +under `examples/validate_*.py`; each documents its endpoint/output variables. +`scripts/validate_native_release.py` coordinates the release suites. Run mutations +serially and preserve the original loaded parts, modified flags and work/display +selection. After failures, record outcomes and restore the session before retrying. + +`scripts/build_release.py --output ` packages a clean committed checkout +with locked Windows dependencies. Installation/rollback scripts preserve backups; +verify archive and installed-source hashes. Record exact runtime commit, NX build, +fixture checks and artifact hashes with each native run. Re-run changed behavior +on the release candidate instead of relabeling earlier evidence. + +## Remaining legacy entry points + +The dev19 closeout reproduced three failures in dev18: typed-reference angle +lookup, the missing sketch constraint enum, and feature deletion through +`FeatureCollection.ToArray`. The replacement handlers use live references and +supported native editing/update paths. `examples/validate_remaining_tools.py` +checks perpendicular-line angles, horizontal constraints, inventories, rename, +nested Save As, extrusion deletion, view selection, relative-placement replay, +explosion deletion and downloaded assembly ZIP contents. Save As references are +reacquired before subsequent edits. Non-running cancellation is an expected error; +cooperative cancellation is classified from sidecar/batch-boundary tests, not +from an unperformed mid-builder interruption test. + +The initial isolated run exposed the stale-reference assumption in the test; +it restored the original session and was retained as a failed attempt. The +corrected run passed on candidate `b2584eeef629791b04cf6ad68fe40a428bfe5aec`. +Per-option limits remain in the capability matrix even when the overall entry +point is marked tested. This does not make every exposed variant native-tested. + +Final dev19 runtime `d7d5dc36b915a631bdb6fe23987b9cb93a6787ae` passed the same +native suite with additional status/frame/page/pose assertions and graphical view +metadata checks. Windows source hashes, stdio/HTTP and inline PNG validation passed. +881 ordinary tests passed at 78.96% branch coverage; sidecar type checking covered +50 modules. [Hosted CI](https://github.com/xuio/NX_MCP/actions/runs/34057628840) +passed on that commit. Final preservation checks retained the original 38 saved +parts and 116 occurrences. The manifest reports 178 native-scoped and seven +sidecar/contract-scoped tools; individual option limits still apply. + +## Manufacturing regression checks (dev20) + +Isolated NX v2606 fixtures preserved the existing 71-part session. Four A3 drawing +views used an explicit 2:1 scale; an 80 mm coupon measured 160 mm in the exported +PDF. An associative dimension retained its value and two associations while its +PDF showed `80.00 +0.05/-0.02`. A blind pocket was dashed and a through-hole solid +after construction curves were erased per view. Model visibility was preserved. +Managed revision-table editing also passed after saving and reopening a part. + +Planar DXF checks covered XY, XZ, YZ, custom bases, analytic arcs, layer assignment +and inch-to-mm conversion. Independent parsing of the XY sketch and face exports +found four lines and one circle, 80 by 30 mm bounds, millimeter units and no DXF +audit errors. Arbitrary-origin/axis revolves were exercised separately. + +A normal STEP imported into a new part with two bodies. Failed vendor imports +returned translator settings, output paths and logs, removed their empty new part +and preserved the session. One vendor STEP still failed in NX's kernel conversion +with multiple translator settings. Another vendor part had two self-intersecting +faces; diagnostics identified both faces and error 875315. Healing on a disposable +copy failed and was rolled back. These are unresolved vendor/native limitations, +not successful repairs. Drawing centerline setters also did not persist, so the +API exposes their readback and rejects edits explicitly. + +Save-scope regression: the previous save implementation enabled native component +saving. It now saves only the active work part and does not run component-preview +saves. A native parent/child fixture confirmed that the child stayed modified and +its disk bytes were unchanged after saving the parent. The updated local suite +passed 907 tests at 78.74% branch coverage. + +Deployed runtime `337b81f64e8fe16c703641836e0272f6674a306e` passed STEP import +into a completely empty session (two bodies). The full and 13-entry agent MCP +profiles delivered a 2,032-object appearance result as a bounded committed +receipt with a 2.89 MB immutable snapshot; 20-item pages, operation-status +readback, idempotent replay and appearance restoration passed. All 71 loaded +parts were restored, with 538 recorded occurrence paths/transforms unchanged. +Reopening the corrected drawing through the deployed MCP retained all four 2:1 +views and physical tolerances; a newly downloaded PDF was parsed and visually +checked again. These checks used isolated fixtures, not production CAD edits. + +## Follow-up investigations and save verification + +The CLIFF model's outer shell and two void shells each imported when serialized +separately, but native recombination did not yield a solid that could pass volume +verification. This is diagnostic evidence, not an accepted conversion route. +Optimize Face with body cleanup and a 0.00001 mm tolerance completed on the Adam +Tech copy but left native health faults and volume unchanged; it was rolled back. +Independent OCP checks also found an invalid solid in the original Adam Tech STEP, +with eight unorientable faces. Reader findings are recorded separately; they do +not prove identical fault classification across kernels. + +The native centerline toggle still read back as enabled after setting it to false +with either default or view-style inheritance. Existing centerline annotation +objects are present, but their separate visibility requires further verification. +No unsupported preference setter is advertised as repaired. + +The dev21 candidate save audit passed in native NX with 73 loaded parts: the +parent save reported only its path, the edited child remained modified with +unchanged disk bytes, and all other loaded files/flags were unchanged. The +fixture was removed and the 71-part session preserved. Verification uses SHA-256, +size and modification time for loaded part files and native PartSaveStatus +errors. It does not cover external linked files or concurrent external writers. +Unexpected changes produce a partial-outcome error, not a disk-rollback claim. + +CLIFF reconstruction blocked native cleanup and required an authorized restart. +The saved 71-part session and all 538 occurrence paths/transforms were restored. +A separate centerline annotation probe did not return a completed result; its +visibility behavior remains unverified. No production CAD repair was applied. + +Deployed dev21 runtime `e9bb14f0b0e56b31d13e4b57dd1779013ee84501` +passed the same parent/edited-child save check through both full HTTP and the +agent gateway. Both returned committed audit receipts with the parent as the only +saved file and the child unchanged and still modified. The temporary fixture was +closed without saving the child, and all 71 original parts were retained. +The local suite passed 912 tests; sidecar type checks covered 55 modules. + +## A02 drafting and unloaded-prototype regressions (dev22) + +The section style failure was reproduced as native 630035 in per-view erasure: +`Part.Curves` included three sheet-owned section lines. UF view-dependency checks +exclude those lines while retaining model construction geometry. Section style +editing and adding another base view then succeeded; save/reopen retained style, +scale, arrows and section hatching. + +PDF export previously left `RasterImages` false. Enabling shaded raster output +produced a 400 dpi image in the native high-resolution fixture. Independent PDF +rendering also exposed a contract ambiguity: `hidden_lines=false` disables native +processing and can show obscured edges as visible. With processing enabled and +font 0 (Invisible), the reopened shaded PDF omitted occluded edges. Native +readback and rendered output were both checked; this is fixture-scoped evidence. + +A disposable parent/child test reproduced an unloaded `NXObject` prototype. +Inventory retained its source path and [10,20,30] placement. Explicit component +loading on the already loaded parent restored a 1000 mm3 solid. Closing an edited +referenced child was rejected before saving; no production component was closed. +The local suite passed 918 tests. CLIFF/Adam Tech source limitations and the +nonpersistent centerline preference are unchanged; no vendor conversion was +repeated or claimed repaired. + +Final runtime `6125b7dac2c85bc0df2c974bc032690bab4e3de0` passed the +public-MCP section/style, save/reopen, PDF download/checksum, explicit component +loading and referenced-prototype close guard checks. The agent gateway returned +font-0 readback and discovered `nx_component_action`. PDF export uses drafting +view updates without assuming a modeling undo mark; the first deployed-context +check caught that distinction, and the regression now covers it. Installed source +hashes and Windows stdio/HTTP checks passed. The 28-part session and 340 recorded +occurrence paths/transforms were restored. + +### Dev23 interactive UI follow-up + +The deployed v2606 host keeps its control panel above the NX owner window. Whole-VM +capture verified that the active-operation text and Pause control remain visible. +During a controlled 12-second sleep on the NX UI thread, three public MCP +`nx_ui_control(mode="status")` calls returned in 141, 47 and 47 ms while the +request remained active. This tests queue/transport independence, not recovery +from a hung native kernel or a call holding the Python GIL. Manual handoff +restored window input; resuming reserved it again. All 28 saved parts and 340 +occurrence placements were restored after deployment. + +The regression suite passed 922 tests; the 17 focused UI tests also passed after +the final owner-window change. Source hash checks, Windows stdio/HTTP discovery +(189 tools), agent discovery (13 tools), and inline viewport PNG delivery passed. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..4831a3c --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,535 @@ +# Integration tool contracts + +Use on-demand schemas for exact argument types and the [capability matrix](capability-matrix.md) for tested scope. The integration profile remains opt-in. This reference consolidates modeling, file and display semantics. + +## Project folders and explicit file paths + +### Example workflow + +```json +{"tool":"nx_workspace_info","arguments":{}} +{"tool":"nx_create_directory","arguments":{"path":"projects/controller/parts"}} +{"tool":"nx_create_part","arguments":{"path":"projects/controller/parts/controller_base.prt","units":"mm"}} +{"tool":"nx_save_part","arguments":{}} +{"tool":"nx_save_as","arguments":{"path":"projects/controller/revisions/controller_base_r02.prt"}} +{"tool":"nx_open_part","arguments":{"path":"D:/CAD/NX_MCP_WORKSPACE/projects/controller/parts/controller_base.prt","work":true,"display":true}} +{"tool":"nx_workspace_list","arguments":{"path":"projects/controller"}} +``` + +`nx_create_directory` creates missing parents and succeeds when the directory already exists. Part creation, Save As, and upload also create missing parent directories. Save As rejects existing files and changes the active part's filename. `nx_save_part` saves at the part's current filename; it takes no destination path. Opening an already-loaded file reuses it and supports explicit work/display activation. + +Use subfolders such as `parts/`, `assemblies/`, `revisions/`, `vendor/`, and `exports/`. Give simultaneously loaded parts unique basenames: native NX can reject two different files named `base.prt`, even in different directories. Save As does not relocate an assembly's referenced prototypes. Use `nx_package_assembly` for a package with dependencies; moving a whole existing project requires updating references separately. + +Absolute paths outside the workspace, traversal escapes, symlink escapes, and internal `.nx-mcp` state are rejected. To use another root, configure `NX_MCP_WORKSPACE` consistently for both bridge and sidecar and restart them after preserving the session. Local Mac files require `nx_upload_file` or an existing shared folder; a Mac path is not a Windows path. + +This changes path support only; it does not reorganize existing CAD files. + + +## NX MCP visualization tools — 5 September 2026 + +### Examples + +```json +{"tool":"nx_highlight_collisions","arguments":{"obj1":"DIRECT_CUBE","obj2":"ROTATED_SUB"}} +{"tool":"nx_set_display","arguments":{"objects":["ROTATED_SUB"],"color":"green","transparency":30}} +{"tool":"nx_set_visibility","arguments":{"objects":["ROTATED_SUB"],"mode":"isolate"}} +{"tool":"nx_section_view","arguments":{"origin":[0,0,5],"normal":[0,0,1],"cap":true}} +{"tool":"nx_sketch_diagnostics","arguments":{"sketch_id":""}} +``` + +Use returned opaque IDs where possible. Capture any result with `nx_screenshot`; native viewport PNGs arrive inline through MCP and as checksum-addressed workspace artifacts. + + +### Semantics and limits + +- Origins use display-part units and coordinates. Normals are normalized. **NX v2606 retains `dot(point-origin, normal) <= 0`.** The red lower / blue upper fixture verifies both normal directions in actual viewport images. No internal feature-toggle API is required. +- Work and display parts must match for visual tools. Edit an existing section by ID; an active section is not silently replaced. Sections clip the view and do not create sliced solids or drawing views. +- Restore appearance/visibility snapshots in reverse order. All references are preflighted before restoration. Manual handoff, rollback or part closure can invalidate snapshots. Restoration restores explicit values, not inherited occurrence-override state or the part's modified flag. Display changes may persist when saved. +- Visibility responses report explicit NX blank flags. Parent component, suppression, layer and reference-set state can further affect visible geometry. Isolation covers loaded bodies/components and their ancestors; datum/reference-curve visibility is outside its scope. +- Sketch diagnostics temporarily activate the target and evaluate the entire sketch, then restore native editing/work-region state through an invisible undo mark. Another active sketch is rejected. Persistent constraint counts exclude inferred solver relations; no minimal conflict set is invented. Over/inconsistent status is passed through when NX returns it; those solver states were not separately forced in this release's fixtures. +- Material assignment and photorealistic rendering remain future work. Untested legacy modeling, mates and constraint-authoring tools retain their prior experimental status. + + +### Verification + +The release includes native-probe, public-MCP and local-test evidence separately. Native tests cover active/inactive and fully fixed sketches, appearance/visibility restoration, and section lifecycle. Public tests cover nested collision highlights, instance appearance, isolation restoration, native renders, saved-state preservation, and manual handoff. The original controller session is preserved separately from disposable fixtures. + +## Authoring and review tools (NX v2606) + +### Select geometry and parameters + +`nx_find_geometry` enumerates faces or edges within a body, feature, component or full assembly. Filter planar faces by oriented normal, cylinders and circular edges by radius, and order by exact BREP nearest distance (dev6) or highest/lowest conservative bounding-box center. Coordinates and radii use work-part units. Nearest results include the closest point and native accuracy. Candidate results are paginated; use `nx_highlight_objects` to inspect choices, then `nx_clear_highlights`. + +`nx_list_expressions` returns formulas, numeric values, units, editability and stored immediate dependencies. Conditional formulas can have incomplete stored dependencies. `nx_set_expression` creates named Number expressions with mm/inch/degree/radian/unitless units or edits existing local, unlocked Number expressions. Edits preserve units. NX formula errors and failed updates roll back. `nx_bind_parameter` connects an existing expression to EXTRUDE start/end or PATTERN_FEATURE count/spacing. Units and dimensional compatibility are enforced by NX. This is not a general interface to every feature builder. + + +### Health and local editing + +`nx_model_health` reports native feature errors/warnings, suppression, unavailable prototypes and UF body-consistency faults. Assembly scope checks unique loaded unsuppressed prototypes. A sheet body is informational rather than automatically invalid. `healthy` only describes the listed checks; no design-intent, manufacturing, solver-conflict or unloaded-file certification is implied. `nx_rebuild_model` runs native DoUpdate for pending updates; it does not force every current feature to regenerate. + +`nx_edit_sketch` reopens an existing sketch, preflights ownership and operation structure, and applies up to 100 edits under one rollback mark. It preserves the prior active/inactive state and returns whole-sketch diagnostics. It supports line endpoints, arc center/radius/angles, adding lines, deleting owned curves/constraints, and adding fixed/horizontal/vertical constraints. Points are local `[x,y]`; arc angles are degrees. Another active sketch is rejected. Constraints are never silently removed to permit an edit. Edit a dimensional constraint through the associated expression reported by diagnostics; more constraint types remain future work. + +`nx_component_action` renames, suppresses, unsuppresses, removes or replaces an immediate child occurrence. Activate a nested component's owning assembly before editing it. Replacement targets one occurrence, requests relationship retention and checks placement afterward; native errors roll back. Suppression affects all arrangements. Removing an occurrence does not delete its prototype file. `nx_pattern_components` creates 2–100 total independent occurrences including the seed, with explicit direction and pitch. These are ordinary instances, not a native associative component pattern. + + +### Presentation and inspection artifacts + +`nx_set_camera` sets absolute camera rotation, view-space origin and scale. Rotation is a row-major orthonormal 3×3 matrix whose columns are NX view axes. Work and display parts must match. + +`nx_save_presentation` writes a new workspace JSON containing camera, active single-plane section, loaded geometry visibility, and explicit colors/transparency including per-face overrides. `nx_restore_presentation` resolves all saved journal locators before mutation. The owner part must match. Missing geometry rejects restoration; across revisions, verify journal identifiers still refer to intended entities. Datum visibility, materials and inherited-override semantics are outside this format. Display restoration may mark a part modified; it does not save the part. + +`nx_inspection_report` produces a workspace ZIP with HTML, structured JSON, SHA-256 manifest, overview screenshot, up to eight flagged-pair close-ups, and up to six section screenshots. Pair results retain native distance/contact/interference distinctions. `max_pairs` is an explicit work limit; exceeding it errors instead of implying unchecked pairs are clear. Temporary camera, visibility and section state is restored. Retrieve the ZIP through `nx_download_file`. Captures are native viewport PNGs, not photorealistic rendering. Files are never silently overwritten. + + +### LLM review workflow + +`nx_model_summary` provides overview counts/bounds/health plus paginated component, feature, expression and sketch sections. It separates owned-body counts from assembly occurrences and does not invent design dimensions. + +`nx_preview_change` accepts up to 25 supported expression, parameter, extrusion/pattern edit, sketch edit or placement operations. It applies them temporarily under an invisible checkpoint, captures before/after parameters, volume, bounds, health and optional viewport image, then **rolls back before returning**. Returned geometry references are stale after rollback. The preview token stores a session-scoped plan, not a persistent undo mark. + +`nx_finish_preview(action="accept")` re-resolves stored target locators and atomically reapplies the plan only while its owner part and mutation epoch are unchanged. Intervening mutations, failed mutations, saves, lifecycle changes or manual handoff expire acceptance. `action="discard"` removes the plan; the original geometry was already restored. Supply a stable operation ID to avoid applying an accepted plan twice. Accepted edits remain unsaved and undoable. Saves, imports, exports and other filesystem/session operations cannot be included in a preview plan. + + +## Advanced authoring on NX 2606 + +### Geometry selection + +`nx_find_geometry` now ranks `nearest` by native `UF.Modeling.AskMinimumDist3` against the trimmed face or edge. Results include distance, closest point and native accuracy; all use work-part coordinates and units. Highest/lowest still rank conservative bounding-box centers. This changes nearest ordering relative to dev5; clients must use `distance`, not `distance_to_bounds_center`, for clearance decisions. + +Each query returns a versioned `selector`. Pass that complete object to `nx_resolve_geometry` after a model edit or reopening its original part. The tool re-evaluates the rule and returns a fresh reference only when the best rank is unique within the requested tie tolerance. Owner journals must still resolve. A selector represents a geometric rule, such as “highest upward planar face,” rather than permanent topological identity. If topology changes, a different face can satisfy that rule. Ties and missing owners are explicit errors; refine the query rather than selecting an arbitrary candidate. + +`nx_recognize_holes` reports inward cylindrical faces, radius, axis, angular coverage and coaxial groups. Full circumferences and partial faces are distinguished. Coaxial grouping uses 0.001 part-unit radial tolerance and axis dot-product tolerance of 1e-8. These are BREP bore candidates, not inferred manufacturing features: through/blind termination, threads, fits and compound-hole classification are outside this tool. + + +### Associative assembly patterns + +Create a native linear pattern with: + +```json +{"component":"","direction":[1,0,0],"spacing":16.5,"count":16} +``` + +Send this to `nx_native_component_pattern`. Count includes the seed and is limited to 2–100. The seed must be an unsuppressed immediate child of the work assembly. The native `NXOpen.Assemblies.ComponentPattern` remains editable and survives save/reopen. `nx_edit_component_pattern` changes pitch and/or count; `nx_list_component_patterns` returns native association, parameter expressions and member poses. A 14 mm wide seed with 16 total instances at 16.5 mm pitch spans 261.5 mm. + +The installed Python collection requires `GetAllComponentPatterns()`; iterating it raises an NX argument error. The implementation checks the installed API and never substitutes independent occurrences for failed native pattern creation. + + +### Dimensions and relations + +`nx_sketch_dimension` creates line length, horizontal or vertical endpoint distances, or arc radius/diameter dimensions. Values use part units; annotation origin is local `[x,y]`. Reference dimensions measure current geometry and reject a supplied value that differs from the measured value by more than 0.001 part units. Driving dimensions return an editable expression ID; use `nx_set_expression` to change its formula later. + +`nx_sketch_relation` creates parallel, perpendicular, equal-length, equal-radius, concentric or coincident persistent relations. Coincident relations require explicit line start/end or arc center choices. Modern sketches use native Make Relation builders with curve1 stationary; curve2 and any connected geometry move through the native solver. A geometric residual check rejects unsatisfied results. Curves must belong to the named sketch. Operations restore its prior activation state and reject another active sketch. They never remove constraints implicitly. + +`nx_feature_parameters` exposes the expressions NX associates with a feature. `nx_set_feature_parameters` atomically changes 1–25 owned, editable local Number expressions by ID or exact expression name. It validates every target before editing and retains each expression's units. This broadens editing without guessing semantic labels or builder options. Native acceptance covers extrusion expressions; availability on another feature type is determined by its exposed expressions and editability, not by a blanket correctness claim about that feature. + + +### Conflict diagnostics + +`nx_sketch_conflicts` combines native solver status with bounded single-constraint-removal trials. Each trial and the surrounding activation/work-region changes are restored with NX undo marks. It reports checked/total, trial errors, completeness and constraints whose individual removal relieves the detected conflict. This is a sensitivity check, not a minimal unsatisfiable constraint set; several independent conflicts can yield no single-removal relief. + +NX 2606 can report `UnderConstrained` for a nonzero line with both persistent horizontal and vertical relations. The tool reports that directly provable contradiction separately as `explicit_conflict_pairs`. This additional rule covers that pair only; native status and an empty pair list do not prove every legacy relation is consistent. Cleanup failure is an explicit partial mutation outcome. + + +## Engineering tools on NX v2606 + +### Solid modeling + +- `nx_extrude` adds start offsets, symmetric total length, arbitrary work-part direction, through-all and up-to-face ends. A Boolean requires explicit target bodies. `distance` is the end coordinate along the extrusion direction; with `symmetric=true` it is the total length. Unsupported combinations are rejected. +- `nx_shell` uses positive wall thickness and optional removed face IDs. Its default thickness goes inward; `outward=true` reverses it. Native testing caught and corrected the NX flip convention. +- `nx_loft` joins ordered sketch profiles. Solid and sheet options are explicit; the native acceptance fixture covers a solid square-to-square loft. +- `nx_draft` requires faces, a stationary face on the same body, a direction and signed angle in degrees. Explicit native tolerances avoid zero-tolerance failures. +- `nx_transform_bodies` creates or edits an associative move. The mapping is `p_output = R * p_input + translation`; `R` is right-handed, orthonormal and row-major. Editing the returned feature replaces the mapping. Copying uses associative body extraction before the MoveObject feature, because the installed builder's CopyOriginal mode produced a non-associative BREP. +- `nx_blend` and `nx_chamfer` accept typed owned edge IDs from one body. They use native chain/collector APIs. Topology references must be reacquired afterward. +- `nx_hole` makes a cylindrical subtract along an explicit direction, default +Z. This preserves the earlier simple-cut semantics; it is not a threaded or drill-tip HolePackage feature. Specify a body when the part has multiple bodies. +- `nx_sweep` accepts distinct section and guide sketch IDs. Optional Boolean output requires one explicit target body. +- `nx_mirror_body` preserves the source and creates a native mirror feature about an origin XY/XZ/YZ datum plane. + +Feature results include all output bodies and result count. Invalid operations roll back; inspect the operation receipt after uncertain transport failure before retrying. + + +### Sketches + +`nx_sketch_primitive` adds editable circles, horizontal slots or rounded rectangles in sketch-local coordinates. `nx_sketch_trim_extend` uses explicit boundary curves and a local pick point. `nx_sketch_angle` creates a driving angular dimension. `nx_sketch_tangent` and `nx_sketch_symmetry` use the modern NX solver builders and verify actual geometric residuals and persistent constraints. Symmetry currently accepts two lines about a third line. Native tests cover line/circle tangency and line-pair symmetry; other curve combinations have narrower validation. + + +### Assemblies and materials + +`nx_component_array` creates native associative rectangular or circular patterns. Counts include the seed, with at most 100 total instances. Rectangular arrays can use two directions. Circular pitch is degrees and cannot wrap to duplicate the seed. `nx_edit_component_pattern` edits count/pitch and existing second-direction parameters; read-back includes native expressions and actual placements. + +`nx_assembly_constraint`, `nx_edit_assembly_constraint` and `nx_list_assembly_constraints` expose persistent constraints with typed references, expressions, suppression and native solver status. Creation uses immediate unsuppressed child occurrences and their occurrence faces/edges. Solving can move components. Native acceptance checks actual face separation after editing, because a solved status plus an updated expression initially left a stale pose until the network was rebuilt after the edit. `nx_mate_component` now maps its earlier mate names onto these native operations; touch/offset mating is checked geometrically. + +`nx_set_material` assigns a named local density-only physical material in kg/m³. It does not invent elastic, thermal or appearance properties. `nx_material_info` reads native assignments. `nx_mass_properties` returns summed solid mass, area, volume, center of gravity and centroidal inertia in SI, with explicit work-part WCS origin/basis. Overlaps are counted separately. The native fixtures include translated and rotated nested assemblies. + +`nx_copy_project` clones a saved loaded assembly into a new workspace directory, preserves relative prototype subfolders and rewrites native dependencies. A required basename prefix avoids loaded-part name conflicts. Source hashes and copied dependencies are checked, and a manifest is written. It copies rather than deletes source files; unsaved parts, existing destinations and unloaded dependencies are rejected. + + +### Rendering and drafting + +`nx_render_view` uses native Studio image capture for exact 128–4096 pixel dimensions. It supports original, white, transparent or custom RGB backgrounds, native lighting presets 1–5, and studio/shaded/edge styles. It restores temporary style and lighting settings and returns camera metadata, checksum, artifact path and an inline MCP image. This is NX rendering, not an AI reconstruction. The native fixtures cover preset 2 and exact 800×600 and 640×480 output; full-VM capture is a separate troubleshooting activity. + +Drawing creation uses native metric A0–A4 landscape sheets with first-angle projection. `nx_add_base_view` currently requires a single-body part and accepts sheet placement in mm. `nx_add_projection_view` creates an associative projected view. `nx_add_dimension` supports aligned/horizontal/vertical linear dimensions: one edge measures its endpoints; two edges measure their start vertices. `nx_export_drawing_pdf` exports all sheets at full sheet scale with text and a checksum. Native acceptance produced an A3 PDF with two views and a computed 10 mm dimension, then parsed and visually reviewed it. + +These are authoring tools, not a complete manufacturing drawing package: title blocks, GD&T, arbitrary detail/section drawings, radial dimensions and automatic annotation layout are not supplied by this release. + + +## Native exploded views (NX v2606) + +### Placement and recovery + +Each placement contains `component`, `translation: [x,y,z]`, and optionally +`rotation_matrix: [[...],[...],[...]]`. Translation is in the work assembly's +units and coordinates. Rotation is a right-handed orthonormal row-major matrix: +`p_assembly = R * p_component + translation`. Omitted rotation retains the +current exploded world orientation at request start. + +Up to 1000 unique occurrences can be placed/reset in one request. Inputs are +validated before mutation. Parents are processed before children, independently +of input order. Moving a parent carries its descendants; resetting a child +removes its local explosion offset and retains the parent's exploded placement. +Specifying both parent and child absolute placements gives each its requested +world pose. NX stores local post-transforms; the bridge converts and verifies +the actual native result before committing. + +Use a stable `operation_id` when retrying after a transport failure and query +`nx_operation_status`. Absolute placement is also repeatable under a new ID. +Checkpoint and rollback use the existing recovery system. Drawing views that +reference an edited explosion are updated before the edit commits. + + +### 3D and drawings + +With no view argument, `nx_show_explosion` returns to modeling, assigns the +explosion to the work view and fits it. Explicit model-view assignment updates +that saved view without activating it. Explicit drawing-view assignment updates +the drawing view. `nx_explosion_info.views` provides both kinds of typed IDs. + +`nx_add_base_view(drawing, scope="assembly", explosion=...)` creates an exploded +assembly view. Omit `explosion` for an assembled view. Body scope remains the +default for compatibility. Projected views retain their parent's explosion. +Native screenshot/render and PDF export tools work with these views; check +returned dimensions, warnings and artifact checksums. Datum/reference geometry +visibility affects output and should be configured for the intended drawing. + +When saving drawing parts, the bridge temporarily displays a drawing sheet to +preserve NX CGM preview data without a modal Save CGM prompt, then restores the +previous part/presentation. It does not disable global CGM preferences. + +Ordinary bounds, mass, clearance and collision queries still measure assembled +geometry. This release does not add exploded-state collision queries, automatic +explode layouts, trace lines, animation, BOMs or balloons. + + +### Verification + +`examples/validate_exploded_views.py` exercises the public MCP surface against +native NX, using an isolated nested assembly and restoring the original saved +session. It covers rotated parents, child absolute placement and reset, safe +retry, 3D capture, assembled/exploded/projected drawing views, update propagation, +PDF transfer, Save As/reopen, rollback, deletion guards and stale references. +Local tests separately cover partial native failure and strict input validation; +mocked failure injection is not evidence of native transport interruption. + +## Native sheet metal + +### Workflow + +1. Create or activate a work/display part with the existing path-aware part tools. +2. Finish any active sketch, then call `nx_sheet_metal_context`. This selects the + modern `UG_APP_SBSM` application. It does not enter the retired sheet-metal app. +3. Inspect/set stock defaults with `nx_sheet_metal_defaults` and + `nx_set_sheet_metal_defaults`. Creation defaults do not override existing features. +4. Call `nx_sheet_metal_schema(operation)` before creating/editing a feature. It + returns strict schemas, actual enums, native validation scope and example inputs. +5. Call `nx_sheet_metal_feature(operation, parameters, feature?)`. References must + belong to the work part. A feature ID requests an edit. Unknown fields are errors. +6. Inspect actual thickness and bend data with `nx_sheet_metal_info`, and check + native feature/body consistency with `nx_model_health`. +7. Create a `flat_pattern` feature; export it with `nx_export_flat_pattern` or place + its native named view on a drawing with `nx_add_flat_pattern_view`. + +All mutation calls run serially on the NX thread under the bridge's existing undo +and operation-receipt handling. Reuse the same operation ID when reconciling a lost +response. After rollback, reacquire object references; deliberately stale IDs are +rejected. Saving follows NX save-boundary semantics: inspect `nx_checkpoint_state` +before relying on an earlier checkpoint. Read-only schema and inspection calls do +not clear recovery history. + + +### Feature families + +| Area | Operations | +|---|---| +| Base and attached material | `tab`, `flange`, `contour_flange`, `lofted_flange` | +| Bending and edge finishing | `bend`, `jog`, `hem`, `break_corner` | +| Cuts and formed details | `normal_cutout`, `bead`, `dimple`, `louver`, `drawn_cutout`, `gusset`, `edge_rip` | +| Corners | `closed_corner`, `three_bend_corner`, `bulge_relief` | +| Conversion and flattening | `convert`, `from_solid`, `flat_solid`, `flat_pattern`, `unbend`, `rebend` | +| Bend modification | `bend_taper`, `resize_bend_angle`, `resize_bend_radius`, `resize_neutral_factor` | +| Advanced construction | `advanced_flange`, `variational_flange`, `bridge_bend`, `joggle`, `lightening_cutout`, `solid_punch` | + +Use the schema's real enum names. For example, modern closed corners use +`overlap_type`; the retired `closure_type` property is deliberately excluded. +Flanges use `CreateMultiFlangeBuilder`, and each list entry owns its edge selection, +length, angle and bend overrides. Supplying a list replaces that builder list. +Use returned expression IDs with `nx_set_expression` to edit dimensions without +reselecting the feature's original support geometry. + +### Choosing flange and unbend inputs + +The operation schema includes `prerequisites` and, where recorded, `example_evidence` +with a repository source and the tested fixture scope. Example coordinates and +dimensions describe that fixture's units; they are not converted for an inch part. +Replace every `$input_N` with a freshly selected typed reference. + +- `flange`: the public fixture creates a 100 × 80 × 2 mm XY tab, selects its + boundary edge nearest `[50, 0, 0]`, then uses + `{"flanges":[{"edges":["$input_1"],"length":20,"length_reference":"Inside","angle":90}]}`. + Each entry requires length and angle even with `length_option=Keypoint`; that + mode also needs its keypoint, and is not validated by this numeric-length + example. The separate channel fixture verifies `width_option=AtCenter` with + width 60 on a 100 mm edge. It does not verify every other width-position mode. +- `advanced_flange`: the recorded fixture uses the same tab boundary edge with + `{"edges":["$input_1"],"length":20,"angle":90}`. It leaves the mode and optional + references at native defaults. `ToReference`, inferred length, face collectors + and plane combinations need additional native validation; the exposed fields + alone do not establish their conditional requirements. +- `unbend` / `rebend`: the recorded fixture adds a 20 mm, 90-degree flange to + the tab. `$input_1` is its bend face from + `nx_sheet_metal_info.items[].bends[].face.id`, and `$input_2` is the original + largest planar web face from the same body. Use + `{"face_collector":["$input_1"],"reference_entity":"$input_2"}`. + After unbend, reacquire the current bend and web references before rebend. + Keep the original web stationary; selecting the flattened bend strip as the + stationary reference is not equivalent. Edge stationary references are exposed + but were not exercised by this fixture. + +For flat patterns, `x_axis_edge` must be a valid orientation edge on the selected +upward web face. Forming a flange changes that face boundary: reacquire a current +straight boundary/tangent edge instead of searching the original tab outer-edge +location. The four-wall agent fixture recovered from an invalid selection by +using the current web boundary. Invalid orientation was rolled back; this does +not establish that every edge on a curved or complex web is valid. + +Secondary contour flanges require an **along-path sketch**, created with +`nx_create_path_sketch`. Use its returned origin/basis/normal to place the profile. +An ordinary planar sketch in the same position is not equivalent. Secondary tabs +require a target body and matching thickness. Both attached workflows were +verified in native NX. + +Sections accept either a finished sketch ID or +`{"edges": ["edge ID"], "help_point": [x,y,z]}` / +`{"curves": ["curve ID"], "help_point": [x,y,z]}`. The explicit point controls the +native section selection. Existing sketches remain external inputs; the bridge +does not consume them as internal sketches. + +Coordinates and lengths use work-part units; expression angles use degrees and +neutral factors are unitless. Plane inputs use origin/normal; coordinate systems +use orthogonal x/y axes. Direction vectors are normalized. Sections and native +feature prerequisites still matter: a successful schema check is not proof that +selected geometry can be formed. + + +### Native fixture findings + +- Tabs, flanges, default thickness/radius/neutral factor and flange edits were + measured in native NX. A 100 × 80 × 2 mm base tab has volume 16,000 mm³. +- Lofted flange fixtures use open sections, endpoint points on those sections and + a positive bending segment count. Closed profiles were not a valid test fixture. +- Bead fixtures need positive angle and punched width; finite normal cutouts need + a positive depth. Native zero defaults are not necessarily usable. +- Louver sections must be assigned before use. Reading the uninitialized native + Section property throws. Formed and lanced, unrounded examples passed in both + depth directions; rounded variants remain unverified. +- Edge rip passed with a short interior slit on a planar sheet face. Ripping + selected edges of the exploratory tube did not pass and remains unverified. +- Three-bend corner passed on a convex corner with matching adjacent bends. + Bulge relief passed with an eligible bend-end edge. Arbitrary edge selections + were rejected; this does not show that the native feature is broken. +- Unbend/rebend use an initialized native face collector and the original web as + the stationary face. Selecting the newly flattened bend strip is not equivalent. +- Standard `Builder.Validate()` is used. The older `ValidateBuilderData` returned + inconsistent values on an unchanged valid tab and is not used for acceptance. + + +### Flat patterns, drawings and artifacts + +`nx_export_flat_pattern` exports native DXF or Trumpf GEO to a **new** workspace +file. It reports path, units, options, size and SHA-256. Files are staged beside the +destination, checked and published without overwriting existing artifacts. Retrieve +bytes with `nx_download_file`. Manufacturing format details beyond the tested +fixtures, including downstream machine compatibility, are not certified. + +`nx_add_flat_pattern_view` uses the model view generated by the Flat Pattern +feature, preserving the native developed geometry and bend lines. Position is in +sheet millimeters. Existing drawing/PDF tools apply. This does not automatically +create a dimensioned manufacturing drawing, bend table, BOM or balloon layout. + +NX may create auxiliary solid bodies for flat-pattern representations. A hidden +source solid can also remain after conversion. Part-wide bounds and summed volume +include such bodies. **Measure the intended folded body by ID** when comparing +physical-part dimensions or volume. Export checks must not use stale DXF header +extents; the acceptance fixture checks actual LINE entity coordinates. + +`nx_sheet_metal_annotation` creates native PMI associated with the selected body or +bend faces. Labels contain **measured snapshots**, explicitly identified in their +text. They contain actual thickness or bend angle/radius/neutral factor. Refresh +with the existing annotation ID after geometry changes; automatic numeric text +updates have not been implemented. Material catalog enumeration is not exposed: +`GetMaterialNames()` caused a native memory-access violation on the test host. + + +## Freeform, documentation and manufacturing + +### Curves and surfaces + +- `nx_spline`: create/edit associative 3D Studio Splines from interpolation points or control poles, including degree and periodicity. +- `nx_surface_mesh`: native Through Curve Mesh from intersecting primary/cross sections. +- `nx_bridge_surface`: full-edge bridge with G0, G1 or G2 constraints. +- `nx_trim_sheet`, `nx_sew`, `nx_thicken`: associative sheet trimming, sewing and signed face offsets. Incomplete sewing and a sheet fallback when a solid was requested are rejected and rolled back. +- `nx_curve_analysis`: sampled native derivatives, tangent, curvature, radius and spline data. +- `nx_surface_continuity`: bidirectional closest-point gaps, normal angles and orientation-aligned curvature-tensor differences. G2 uses the shape-operator Frobenius norm. Singular samples prevent a pass; sampled results do not certify global continuity. + +Native fixtures exercised spline creation/editing, planar meshes, G0/G1/G2 bridge creation, trim, sew, thicken and derivative analysis. Continuity checks distinguished matching planar sheets, separated sheets, and a tangent plane/quadratic-surface join with different curvature. A bridge builder's requested continuity is distinct from independent geometric verification. Periodic splines and all possible network topologies are not covered by these fixtures. + + +### Imported-face editing + +`nx_edit_faces` supports directed face translation, signed normal offset, replacement by another face, and deletion with healing. It creates native NX features and rejects irrelevant arguments. Reacquire face/edge references after topology changes. Tests used controlled solid fixtures with independently calculated volumes; this does not establish reliability on every vendor import or damaged B-rep. + + +### Assembly documentation + +`nx_create_parts_list`, `nx_parts_list_info` and `nx_update_parts_list` expose native BOMs with actual evaluated rows, installed column defaults and assembly traversal scope. `nx_parts_list_balloons` creates NX-associated callout groups in drawing views. Repeated instances aggregate according to the native key columns; native automatic placement still needs visual review. + +`nx_explosion_trace` creates a native automatic traceline attached to a named explosion. Its anchors store **native persistent handles** for component occurrences and prototype edges, not transient tags or journal strings. Save/reopen and subsequent placement changes were tested against exact endpoint coordinates. MCP explosion edits, show and animation refresh the endpoints. After manual NX geometry changes, call `nx_show_explosion` to refresh. Missing anchors fail explicitly. Collapsed traces are hidden; expanded managed traces are shown during refresh. This refresh mechanism is managed by MCP rather than an automatic NX callback. + +`nx_export_explosion_animation` writes a self-contained HTML player with native PNG frames, a scrubber and per-frame metadata. It uses linear translation and shortest-arc quaternion rotation. Show a modeling view and frame the entire motion first; the camera remains fixed. A temporary undo mark restores poses, view association and model state even when capture fails. This is presentation animation, not a collision-certified disassembly sequence. Retrieve the file through `nx_download_file`. Native drawing PDF export now temporarily prepares all sheet presentations and restores the previous drawing/modeling view, including when invoked after returning to 3D. Single-sheet and two-sheet exports from a modeling view were verified. + + +### Manufacturing and PMI + +- `nx_thread`: explicit manual pitch, diameters, length, start face, handedness and symbolic/detailed representation. Internal and external threads were created natively. These dimensions do not imply a standards-table fit class. +- `nx_pmi_datum` and `nx_pmi_fcf`: native geometry-associated datum symbols and single-frame geometric tolerances, with existing datum references and annotation editing. They cover the published fields, not every modifier or GD&T standard combination. +- `nx_face_analysis`: sampled normal, principal curvature/radius and signed draft angle against a pull direction. Draft is `asin(normal · pull)` in degrees. +- `nx_wall_thickness`: inward-normal rays from sampled face points to the first exit face, with exact native intersections and unresolved samples reported. A 5 mm plate measured 5 mm at every sampled point. It is neither a rolling-ball thickness algorithm nor a certified global minimum. + +Native sheet-metal flat patterns and DXF/GEO export remain available from [dev10](tools.md). That document distinguishes tested feature families from unavailable or unverified options; this release does not claim complete coverage of every licensed NX manufacturing module. + + +### Drawing and assembly documentation + +- `nx_list_drawings` enumerates work-part sheets, their sizes, scales and drafting views without activating them. `nx_activate_drawing` opens a sheet; a null drawing returns to modeling. Work/display parts must match and no sketch may be active. +- `nx_list_annotations` paginates notes, PMI, BOMs, balloons, bend tables and explosion traces, returning native subtypes and available text/positions. Drafting positions use sheet coordinates; PMI positions use part coordinates. +- `nx_edit_annotation` moves or renames annotations, or explicitly deletes one. Balloon movement retains native callout associations. Use the dedicated trace tool to change trace geometry. +- `nx_parts_list_column` edits, appends or removes zero-based BOM columns. Inspect existing `columns` first: `field` is the native default expression, such as ``. An appended general column requires a title, width and field. Callout and quantity columns retain their native types. Widths use sheet units. +- `nx_edit_explosion_trace` changes managed trace endpoint percentages along the anchored edges and native endpoint offsets. Persistent component/edge handles remain attached to the named explosion. Offsets use assembly units. This does not change assembled component placement. + + +### Threads and GD&T + +`nx_thread_catalog` reads the installed NX thread XML **in place**. It lists standards or a bounded page of sizes; selecting an exact standard and size returns the dimensional metadata needed for modeling. It does not transfer the catalog file. `nx_standard_thread` requires an unambiguous catalog row, including method and radial engagement when necessary. It uses the native `ThreadTable` builder, the actual selected cylinder diameter and explicit start face. Symbolic and detailed representations, handedness and direction are supported. Dimensions do not imply an unexposed fit class or a complete standards compliance check. + +`nx_pmi_fcf` additionally exposes tolerance and datum MMC/LMC/RFS modifiers, diameter/spherical-diameter/square zone shapes, projected height, tangent-plane and free-state flags. Omitted modifiers reset on editing. Native validation and the published preflight restrictions apply; this is not a full GD&T semantic standards validator. + + +### Bend tables and measured PMI + +`nx_bend_table` creates or edits an NX associative bend table for a native flat-pattern drafting view. Columns include bend ID/name, angle, direction and radius, in caller-selected order. Native automatic updating defaults to enabled. The response includes evaluated rows and settings. + +`nx_sheet_metal_annotation(automatic=true)` stores persistent source handles and measured values on the native annotation. Subsequent MCP model mutations refresh changed measurements **inside the same undo transaction**. If a source becomes invalid, the operation fails and rolls back rather than silently retaining obsolete values. Deleting the annotation first removes that dependency. Editing with `automatic=false` disables managed refresh and produces an explicit measured snapshot. + +Manual NX edits do not run the MCP transaction hook. Call `nx_refresh_annotations` afterward. MCP also commits native automatic bend-table builders in the same transaction: NX v2606's automatic flag alone left stale rows after reopening. Explicit refresh performs this rebuild after manual edits. Saved source handles are resolved within the owning part, and missing sources are rejected explicitly. + + +### Drawings and units + +- `nx_drawing_view_info`: actual view scale, sheet position, native border and sheet containment. Borders exclude separately placed annotations. +- `nx_list_dimensions`: computed native values and typed dimension references, including PMI and drawing dimensions, native retention status and whether the computed value remains valid. Linear drafting dimensions can reference assembly occurrence edges. +- `nx_edit_drawing_view`: absolute position and/or positive scale, with native readback. Aligned views can reject incompatible moves, which roll back. +- `nx_add_section_drawing_view`: a simple native section and section line. Select an owned edge and `cut_association` of `start`, `end`, or `arc_center`. Step and arrow directions are perpendicular vectors in the sheet XY plane. The scale is assigned explicitly; NX's creation API may otherwise inherit the parent's scale. +- `nx_add_detail_drawing_view`: a circular native detail. Center and radius use model coordinates/part units; the bridge maps them into the parent view. Managed boundary coordinates refresh with parent-view edits. The center is an explicit model coordinate, not a selected material point that moves with arbitrary geometry edits. +- `nx_drawing_table`: editable revision rows or a native NX title block. Caller supplies content; no approvals, dates or revisions are invented. Revision tables use their native section origin and title blocks their native annotation origin. Inspect the drawing before release. A native title-block definition replaces the original table section; the returned reference identifies the resulting title block. + +`nx_create_drawing` supports `units="mm"` or `"in"` independently of the work part. A0–A4 retain their physical dimensions. Drawing inspection reports per-sheet units. NXOpen placement points are converted from sheet to part units; UF drawing moves use sheet coordinates. Legacy `position_mm`, `origin_mm` and `spacing_mm` fields remain millimeter values; the additional sheet-unit fields identify the caller's actual coordinates. Model-unit names remain `mm` and `inch`. Projected-view spacing is verified on its free movement axis; NX maintains native associative alignment on the other axis, whose reference coordinate can differ from the parent. The result reports the actual sheet position. + +Native mass measurements require explicitly setting `MeasureBodies.InformationUnit` to `KilogramMillimeter`: supplying millimeter unit objects to `NewMassProperties` alone returns cubic inches in inch parts. Volume and interference volume are explicitly reported in mm³. + + +### Assembly changes and imported geometry + +`nx_update_assembly_documentation` explicitly solves existing mates, refreshes managed explosion traces, BOMs, measured annotations and drafting views in the active assembly. It reports actual constraints, evaluated rows, view borders and health. It temporarily opens drawing sheets for native regeneration and restores the prior view. It does not undo earlier prototype edits. Missing trace anchors or unsatisfied constraints fail the refresh transaction. + +Replacing a prototype may retain old drawing dimensions at their former values. Refresh reports `documentation_complete: false`, affected dimensions/annotations and warnings. Native retained balloons are also reported; delete obsolete callouts and recreate them from the updated BOM. Reassociate explicitly with `nx_add_dimension(dimension=existing_id, object1=replacement_edge, ...)`; this preserves the native dimension identity. + +Replacing a prototype may invalidate its edge anchors. Explicitly remove obsolete traces and create anchors on replacement geometry; the bridge never guesses correspondence between unrelated faces. + +`nx_geometry_anchor` stores a native persistent handle plus owner path and kind. `nx_resolve_geometry_anchor` verifies the owner and that the exact body, face or edge survives. A deleted entity returns `NX_STALE_REFERENCE`; geometric nearest-neighbor fallback is not automatic. Anchors are for owned prototype geometry, not assembly occurrences. + +`nx_edit_faces` returns native health with its repair result. Failed native edits include the action, selected face references and NX error code when available. The enclosing transaction rolls back unhealthy results. + + +## Legacy-compatible inspection and edits + +`nx_measure_angle` accepts typed work-part line, straight-edge and planar-face +references and returns degrees in [0,180]. Directions use line start/end, edge +vertex order or outward face normals. This is not an oriented dihedral angle; +curved entities and component occurrences are rejected. + +`nx_delete_feature` resolves a feature ID or unambiguous name, adds it to the +native update manager deletion list and reports deleted references. Dependent +geometry can be removed; inspect change records and reacquire topology afterward. + +`nx_sketch_constraint` routes owned curve IDs to supported sketch editors and +relation/dimension tools. Horizontal/vertical/fix use one curve; pair relations +use two curves in the same sketch. Only dimensional types accept a value. +Coincident uses start-to-start; use `nx_sketch_relation` for explicit endpoints. +Midpoint is explicitly unsupported. The input schema publishes the supported enum. + +## Manufacturing imports, drawings and planar export + +`nx_import_geometry(target="new_part", output_path="vendor/model.prt")` creates a millimeter part without requiring an existing work part. It uses installed STEP import settings (`step214ug.def`), preserves source bytes in an operation-specific staging directory and reports settings, output paths and translator log excerpts. Failed new-part imports close their newly created parts and restore the previous work/display selection. Retained diagnostic files are separate from model rollback. Translation success alone does not establish geometry health: run `nx_model_health`. Faults include decoded messages and typed face/edge/body references when resolvable. Self-intersecting vendor faces may require supplier repair; `nx_edit_faces(action="heal")` is not a general STEP repair guarantee. + +Drawing creation assigns and verifies the sheet scale. New body and assembly base views use it explicitly. `nx_edit_drawing_view(style=...)` controls hidden/visible fonts and widths, smooth/tangent edges, and wireframe/partial/full shading. Fonts 1..7 are solid, dashed, phantom, centerline, dotted, long dashed and dotted dashed. Widths are `thin`, `normal`, `thick` or native numbered widths `"1"`..`"9"`. New base views distinguish dashed hidden edges from solid visible edges. Centerline visibility remains read-only because the exercised NX v2606 setter did not persist; unsupported edits are rejected. + +`nx_dimension_format` reads native preferences. `nx_edit_dimension_format` sets decimal precision, trailing zeros, display units, separator and tolerance style/values. It preserves computed values and associativity; no measured text is overridden. Title blocks use a lower-right position anchor; revision tables use an upper-left anchor. Annotation moves require `[x,y,0]` in sheet coordinates. Section direction vectors require three components `[x,y,0]`. PDF exports reject existing paths before native mutation. + +`nx_export_planar_dxf` exports an owned sketch or planar face directly to 1:1 millimeter DXF, including inner loops. It retains LINE, ARC and CIRCLE entities and rejects unsupported curves rather than approximating them. The result reports the exact origin/basis and entity counts. Supply both orthonormal axes and an origin in the source plane to choose PCB coordinates. Optional layer overrides map source curve/edge IDs to layer names. No sheet-metal feature is required; export does not certify loop validity or fabrication readiness. + +`nx_display_info(objects=[...], count_only=true)` preflights appearance expansion without modifying display state. Changes are limited to 10,000 unique expanded bodies/faces; split larger selections after checking their counts. Oversized committed responses carry a paginated `full_result` handle; see the agent-surface recovery contract. + +`nx_batch` publishes a discriminated `{method,params}` schema for each supported child method. Lines and rectangles require an owning sketch ID and structured points; arcs use center/radius/angles and an optional sketch ID. Execution remains serial under one rollback mark. Custom revolve axes use both `axis_origin` and nonzero `axis_direction` in work-part coordinates; leave the principal-axis selector at its default. + +`nx_save_part` saves only the work-part file. Component edits require explicit +activation and saving of each component; saving an assembly does not imply +authorization to save its prototypes. This also applies to drawing-preview data. + +Save receipts identify `saved_files`, observed disk changes, target before/after +SHA-256 fingerprints, and unrelated modified parts whose file/state was preserved. +Verification covers all loaded part files and flags. Native per-part/object save +errors are returned; unexpected writes or flag changes produce a partial-failure +receipt and must be reconciled before retrying. Unreadable preflight files prevent +the save. External linked files and concurrent external writers are outside the +verification guarantee. + +### Assembly load state and drawing output + +`nx_list_components` reports `load_state` and a nullable `part_path`; an unloaded +prototype does not abort the inventory. Descendants of an unloaded subassembly +may not be available. `nx_open_part(path, load_components=true)` explicitly loads +unsuppressed prototypes even when the parent is already loaded, restoring the +session's partial-loading preference afterward. Component reference sets are +retained. `nx_close_part` rejects a prototype still referenced by a loaded parent +before saving it; close parent assemblies first. PDF export rejects unloaded +unsuppressed prototypes instead of silently omitting them. + +Drawing `hidden_lines` and `self_hidden` are native processing toggles, not simple +visibility switches. Disabling them can draw occluded edges as visible. To hide +obscured edges use `style={hidden_lines:true,self_hidden:true,hidden_font:0}`. +Font 0 is invisible, 1 solid, 2 dashed. Construction filtering preserves +sheet-owned section lines. PDF export updates all drawing views, includes shaded +raster images at native high resolution, and reports its effective output settings. + +Component replacement/removal/suppression is already exposed by +`nx_component_action`; use its exact schema. Native relationship retention is +requested on replacement; operation-specific limitations remain in the capability +matrix. Radial/angular/ordinate drawing dimensions are not added by these fixes. diff --git a/examples/nx_runtime_probe.py b/examples/nx_runtime_probe.py index 40527fe..334af94 100644 --- a/examples/nx_runtime_probe.py +++ b/examples/nx_runtime_probe.py @@ -26,6 +26,12 @@ def main() -> None: except Exception as error: # Report the runtime issue instead of mutating NX. bridge_import_error = f"{type(error).__name__}: {error}" + experimental_import_error = None + try: + from nx_mcp.experimental import execute_legacy # noqa: F401 + except Exception as error: + experimental_import_error = f"{type(error).__name__}: {error}" + session = NXOpen.Session.GetSession() result = { "python_version": sys.version, @@ -34,6 +40,7 @@ def main() -> None: "pydantic_available": importlib.util.find_spec("pydantic") is not None, "mcp_available": importlib.util.find_spec("mcp") is not None, "bridge_import_error": bridge_import_error, + "experimental_import_error": experimental_import_error, } destination = Path(output) destination.parent.mkdir(parents=True, exist_ok=True) diff --git a/examples/start_nx_interactive.py b/examples/start_nx_interactive.py new file mode 100644 index 0000000..14e5ea0 --- /dev/null +++ b/examples/start_nx_interactive.py @@ -0,0 +1,16 @@ +"""Play once in the open NX UI. Returns immediately; NX retains the UI-thread host.""" + +import os +import sys +from pathlib import Path + +root = Path(__file__).resolve().parents[1] / "src" +sys.path.insert(0, str(root)) +from nx_mcp.interactive import start # noqa: E402 - NX journal loads this checkout first + +workspace = os.environ.get("NX_MCP_WORKSPACE", r"D:\CAD\NX_MCP_WORKSPACE") +descriptor = os.environ.get( + "NX_MCP_UI_DESCRIPTOR", + str(Path(os.environ["LOCALAPPDATA"]) / "nx-mcp" / "interactive-bridge.json"), +) +start(workspace, descriptor) diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py new file mode 100644 index 0000000..c129ef1 --- /dev/null +++ b/examples/validate_advanced_tools.py @@ -0,0 +1,328 @@ +"""Live MCP acceptance for NX 2606 advanced authoring on disposable parts.""" + +import asyncio +import json +import math +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def run(call, root): + out = {"groups": []} + + async def new(name): + return await call("nx_create_part", path=str(root / (name + ".prt"))) + + async def box(name, w=14, h=10): + await new(name) + s = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", sketch_id=s, corner1={"x": 0, "y": 0}, corner2={"x": w, "y": 10} + ) + await call("nx_finish_sketch", sketch_id=s) + f = await call("nx_extrude", sketch_id=s, distance=h) + return (s, f) + + async def group(name, fn): + try: + out["groups"].append({"name": name, "status": "passed", "result": await fn()}) + except Exception: + out["groups"].append( + {"name": name, "status": "failed", "error": traceback.format_exc()} + ) + + async def geometry(): + s, f = await box("dev6-selection") + r = await call("nx_find_geometry", geometry_type="plane", normal=[0, 0, 1], near=[1, 2, 14]) + assert abs(r["items"][0]["distance"] - 4) < 1e-07, r + selector = r["selector"] + await call("nx_edit_feature", name=f["feature"]["id"], params={"distance": 12}) + r = await call("nx_resolve_geometry", selector=selector) + assert abs(r["match"]["distance"] - 2) < 1e-07, r + q = await call("nx_find_geometry", near=[7, 5, 6]) + try: + await call("nx_resolve_geometry", selector=q["selector"]) + except Exception: + pass + else: + raise AssertionError("Ambiguous selection accepted") + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=str(root / "dev6-selection.prt")) + r = await call("nx_resolve_geometry", selector=selector) + assert abs(r["match"]["distance"] - 2) < 1e-07 + return r + + await group("exact_selection_edit_reopen_ambiguity", geometry) + + async def holes(): + await new("dev6-bores") + s = (await call("nx_create_sketch"))["object"]["id"] + for rad in [10, 5]: + await call( + "nx_sketch_arc", sketch_id=s, cx=0, cy=0, radius=rad, start_angle=0, end_angle=360 + ) + await call("nx_finish_sketch", sketch_id=s) + await call("nx_extrude", sketch_id=s, distance=10) + r = await call("nx_recognize_holes") + assert r["total"] == 1, r + assert abs(r["items"][0]["radius"] - 5) < 1e-07 + assert r["items"][0]["full_circumference"] + return r + + await group("bore_axis_recognition", holes) + + async def pattern(): + await box("dev6-pattern-proto", h=4.6) + await call("nx_save_part") + await new("dev6-pattern-assembly") + c = ( + await call( + "nx_add_component", part_path=str(root / "dev6-pattern-proto.prt"), name="seed" + ) + )["object"]["id"] + r = await call( + "nx_native_component_pattern", component=c, direction=[1, 0, 0], spacing=16.5, count=16 + ) + assert r["total_instances"] == 16 and r["associative"], r + bounds = await call("nx_get_bounding_box", scope="assembly") + assert abs(bounds["max"][0] - bounds["min"][0] - 261.5) < 1e-06, bounds + p = r["object"]["id"] + edited = await call("nx_edit_component_pattern", pattern=p, count=4, spacing=20) + assert edited["total_instances"] == 4, edited + assert sorted(round(i["translation"][0], 6) for i in edited["instances"]) == [ + 0, + 20, + 40, + 60, + ], edited + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=str(root / "dev6-pattern-assembly.prt")) + read = await call("nx_list_component_patterns") + assert read["count"] == 1 and read["patterns"][0]["associative"] + return {"created": r, "edited": edited, "bounds": bounds, "reopened": read} + + await group("native_pattern_261_5_span_edit_reopen", pattern) + + async def dimensions(): + await new("dev6-dimensions") + s = (await call("nx_create_sketch", plane="XZ"))["object"]["id"] + c = ( + await call("nx_sketch_line", sketch_id=s, start={"x": 0, "y": 0}, end={"x": 10, "y": 0}) + )["object"]["id"] + await call("nx_finish_sketch", sketch_id=s) + r = await call( + "nx_sketch_dimension", + sketch_id=s, + curve=c, + dimension_type="length", + value=15, + origin=[5, 3], + ) + assert abs(r["expression"]["value"] - 15) < 1e-06, r + await call("nx_set_expression", expression=r["expression"]["object"]["id"], formula="18") + info = await call("nx_sketch_info", sketch_id=s) + assert abs(math.dist(info["curves"][0]["start"], info["curves"][0]["end"]) - 18) < 1e-06, ( + info + ) + await new("dev6-radius") + s = (await call("nx_create_sketch"))["object"]["id"] + c = ( + await call( + "nx_sketch_arc", sketch_id=s, cx=0, cy=0, radius=5, start_angle=0, end_angle=360 + ) + )["object"]["id"] + await call("nx_finish_sketch", sketch_id=s) + r = await call( + "nx_sketch_dimension", + sketch_id=s, + curve=c, + dimension_type="radius", + value=7, + origin=[8, 8], + ) + assert abs(r["expression"]["value"] - 7) < 1e-06, r + return r + + await group("native_dimensions_and_expression_edit", dimensions) + + async def relations(): + await new("dev6-relations") + s = (await call("nx_create_sketch"))["object"]["id"] + curves = [] + for y in [0, 5]: + curves.append( + ( + await call( + "nx_sketch_line", sketch_id=s, start={"x": 0, "y": y}, end={"x": 10, "y": y} + ) + )["object"]["id"] + ) + await call("nx_finish_sketch", sketch_id=s) + r = await call( + "nx_sketch_relation", + sketch_id=s, + curve1=curves[0], + curve2=curves[1], + relation="parallel", + ) + assert r["constraint"] + d = await call("nx_sketch_conflicts", sketch_id=s) + assert d["baseline_status"] in ["UnderConstrained", "WellConstrained"] + return {"relation": r, "diagnostic": d} + + await group("native_relations_and_diagnostics", relations) + + async def parameters(): + s, f = await box("dev6-parameters") + rows = (await call("nx_feature_parameters", feature=f["feature"]["id"]))["parameters"] + exp = next(r for r in rows if r["value"] == 10 and r["units"] == "mm") + r = await call( + "nx_set_feature_parameters", + feature=f["feature"]["id"], + values={exp["object"]["id"]: "22"}, + ) + bounds = await call("nx_get_bounding_box") + assert abs(bounds["max"][2] - 22) < 1e-06, bounds + return r + + await group("owned_feature_parameters", parameters) + + async def extras(): + results = [] + for dtype in ["horizontal", "vertical", "diameter"]: + await call("nx_create_part", path=str(root / ("dev6-" + dtype + ".prt"))) + s = (await call("nx_create_sketch"))["object"]["id"] + if dtype == "diameter": + c = ( + await call( + "nx_sketch_arc", + sketch_id=s, + cx=0, + cy=0, + radius=5, + start_angle=0, + end_angle=360, + ) + )["object"]["id"] + else: + c = ( + await call( + "nx_sketch_line", + sketch_id=s, + start={"x": 0, "y": 0}, + end={ + "x": 10 if dtype == "horizontal" else 0, + "y": 10 if dtype == "vertical" else 0, + }, + ) + )["object"]["id"] + await call("nx_finish_sketch", sketch_id=s) + r = await call( + "nx_sketch_dimension", + sketch_id=s, + curve=c, + dimension_type=dtype, + value=16, + origin=[15, 15], + ) + assert abs(r["expression"]["value"] - 16) < 1e-06 + results.append(r) + return results + + await group("horizontal_vertical_diameter_dimensions", extras) + + async def relations(): + results = [] + for relation in [ + "perpendicular", + "equal_length", + "equal_radius", + "concentric", + "coincident", + ]: + await call("nx_create_part", path=str(root / ("dev6-rel-" + relation + ".prt"))) + s = (await call("nx_create_sketch"))["object"]["id"] + curves = [] + for i in [0, 1]: + if relation in ["equal_radius", "concentric"]: + c = ( + await call( + "nx_sketch_arc", + sketch_id=s, + cx=i * 12, + cy=0, + radius=5 + i, + start_angle=0, + end_angle=360, + ) + )["object"]["id"] + else: + c = ( + await call( + "nx_sketch_line", + sketch_id=s, + start={"x": 0, "y": i * 5}, + end={"x": 10 + i, "y": i * 5}, + ) + )["object"]["id"] + curves.append(c) + await call("nx_finish_sketch", sketch_id=s) + kw = {"point1": "end", "point2": "start"} if relation == "coincident" else {} + r = await call( + "nx_sketch_relation", + sketch_id=s, + curve1=curves[0], + curve2=curves[1], + relation=relation, + **kw, + ) + assert r["constraint"] + info = await call("nx_sketch_info", sketch_id=s) + r["geometry_readback"] = info + results.append(r) + return results + + await group("two_curve_relation_types", relations) + out["passed"] = sum(g["status"] == "passed" for g in out["groups"]) + out["total"] = len(out["groups"]) + return out + + +async def main(): + endpoint = os.environ["NX_MCP_TEST_ENDPOINT"] + output = Path(os.environ.get("NX_ADVANCED_RESULTS", "advanced-results")) + output.mkdir(parents=True, exist_ok=True) + root = Path("dev6-validation-" + uuid.uuid4().hex[:8]) + async with ( + streamablehttp_client(endpoint) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + tools = {t.name: t for t in (await client.list_tools()).tools} + assert len(tools) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")) + assert tools["nx_resolve_geometry"].annotations.readOnlyHint + + async def call(method, **params): + r = await client.call_tool(method, params) + if r.isError: + raise RuntimeError(r.structuredContent or r.content) + return r.structuredContent + + result = await run(call, root) + result["workspace"] = str(root) + (output / "public-advanced-validation.json").write_text(json.dumps(result, indent=2)) + for group in result["groups"]: + print(group["name"], group["status"], group.get("error", ""), flush=True) + if result["passed"] != result["total"]: + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_annotation_recovery.py b/examples/validate_annotation_recovery.py new file mode 100644 index 0000000..c07358e --- /dev/null +++ b/examples/validate_annotation_recovery.py @@ -0,0 +1,121 @@ +"""Follow the documentation suite with automatic table, retry, opt-out and rollback checks.""" + +import asyncio +import json +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +out = Path(os.environ.get("NX_VALIDATION_OUTPUT", "documentation-manufacturing-results")) +fixture = json.loads((out / "documentation-manufacturing-validation.json").read_text())["fixture"] + + +async def main(): + result = {} + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (r, w, _), + ClientSession(r, w) as c, + ): + await c.initialize() + + async def call(n, **p): + v = await c.call_tool(n, p) + assert not v.isError, (n, v.structuredContent) + return v.structuredContent + + before = (await call("nx_list_open_parts"))["parts"] + original = next(p for p in before if p["work"]) + original_display = next(p for p in before if p["display"]) + assert fixture.startswith("documentation-validation-") + assert not any(p["modified"] or fixture in p["path"] for p in before), ( + "Save user parts and close prior validation fixtures first" + ) + checkpoint = None + try: + await call("nx_open_part", path=fixture + "/bracket.prt") + await call("nx_activate_drawing") + checkpoint = await call("nx_checkpoint", label="automatic annotation acceptance") + expressions = (await call("nx_list_expressions", limit=200))["items"] + angles = [x for x in expressions if x["units"] == "°" and x["value"] == 80] + assert len(angles) == 1 + token = "annotation-refresh-" + uuid.uuid4().hex + params = { + "expression": angles[0]["object"]["id"], + "formula": "85", + "operation_id": token, + } + first = await call("nx_set_expression", **params) + again = await call("nx_set_expression", **params) + assert ( + first["refreshed_annotations"] == again["refreshed_annotations"] + and again["replayed"] + ) + sheets = (await call("nx_list_drawings"))["sheets"] + await call("nx_activate_drawing", drawing=sheets[0]["object"]["id"]) + notes = (await call("nx_list_annotations"))["items"] + table = next(x for x in notes if x["native_type"] == "BendTable") + assert any("85,00" in row or "85.00" in row for row in table["rows"]), table + pmi = next(x for x in notes if x.get("managed_refresh")) + assert "85.000 deg" in " ".join(pmi["text"]) + result["automatic_without_table_edit"] = True + result["operation_retry_deduplicated"] = True + result["table_rows"] = table["rows"] + await call("nx_activate_drawing") + body = (await call("nx_list_bodies"))["objects"][0]["id"] + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + await call( + "nx_sheet_metal_annotation", + kind="bend", + body=body, + faces=[info["bends"][0]["face"]["id"]], + position=pmi["position"], + annotation=pmi["object"]["id"], + automatic=False, + ) + edit = await call( + "nx_set_expression", expression=angles[0]["object"]["id"], formula="82" + ) + assert all( + x["id"] != pmi["object"]["id"] for x in edit.get("refreshed_annotations", []) + ), "Disabled PMI was refreshed" + notes = (await call("nx_list_annotations"))["items"] + assert any( + "85.000 deg" in " ".join(x.get("text", [])) and x.get("managed_refresh") is False + for x in notes + ) + result["disable_preserves_measured_snapshot"] = True + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + checkpoint = None + notes = (await call("nx_list_annotations"))["items"] + assert any( + "80.000 deg" in " ".join(x.get("text", [])) and x.get("managed_refresh") + for x in notes + ) + result["rollback_restored_annotation_and_source"] = True + result["passed"] = True + except Exception: + result["error"] = traceback.format_exc() + raise + finally: + if checkpoint: + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + await call("nx_open_part", path=original["path"]) + parts = (await call("nx_list_open_parts"))["parts"] + for p in parts: + if fixture in p["path"]: + await call("nx_close_part", part=p["part"]["id"], save=True) + if original_display["path"] != original["path"]: + await call("nx_open_part", path=original_display["path"], work=False, display=True) + after = (await call("nx_list_open_parts"))["parts"] + assert {p["path"] for p in before} == {p["path"] for p in after} + result["session_restored"] = True + (out / "refresh-check.json").write_text(json.dumps(result, indent=2)) + print(json.dumps(result)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py new file mode 100644 index 0000000..15904cc --- /dev/null +++ b/examples/validate_authoring_tools.py @@ -0,0 +1,350 @@ +"""Live public MCP acceptance. Creates disposable parts; restore your original session afterward.""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +import zipfile +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def run(call, root): + out = {"groups": []} + + async def new(name): + return await call("nx_create_part", path=str(root / (name + ".prt"))) + + async def box(name, w=10, h=10): + await new(name) + s = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", sketch_id=s, corner1={"x": 0, "y": 0}, corner2={"x": w, "y": 10} + ) + await call("nx_finish_sketch", sketch_id=s) + f = await call("nx_extrude", sketch_id=s, distance=h) + return (s, f) + + async def group(name, fn): + try: + out["groups"].append({"name": name, "status": "passed", "result": await fn()}) + except Exception: + out["groups"].append( + {"name": name, "status": "failed", "error": traceback.format_exc()} + ) + + async def expressions(): + s, f = await box("expressions") + await call("nx_set_expression", expression="height", formula="12", create=True, units="mm") + await call( + "nx_bind_parameter", feature=f["feature"]["id"], parameter="end", expression="height" + ) + assert abs((await call("nx_get_bounding_box"))["max"][2] - 12) < 1e-07 + await call("nx_set_expression", expression="height", formula="18") + assert abs((await call("nx_measure_volume"))["volume_mm3"] - 1800) < 1e-05 + before = await call("nx_list_expressions") + r = await call("nx_model_health") + assert r["healthy"], r + try: + await call("nx_set_expression", expression="height", formula="missing_symbol + 1") + except Exception: + pass + else: + raise AssertionError("invalid formula accepted") + assert abs((await call("nx_get_bounding_box"))["max"][2] - 18) < 1e-07 + inch = await call( + "nx_set_expression", expression="inch_probe", formula="1", create=True, units="inch" + ) + assert abs(inch["expression"]["value"] - 1) < 1e-07, inch + await call("nx_rebuild_model") + return {"height": 18, "health": r, "expressions": before, "inch": inch} + + await group("expressions_binding_health_rollback", expressions) + + async def geometry(): + s, f = await box("selection") + r = await call( + "nx_find_geometry", + kind="face", + geometry_type="plane", + normal=[0, 0, 1], + order="highest", + ) + assert r["total"] == 1, r + assert abs(r["items"][0]["bounds_center"][2] - 10) < 1e-07 + await call("nx_highlight_objects", objects=[r["items"][0]["object"]["id"]]) + await call("nx_clear_highlights") + await new("circular-selection") + s = (await call("nx_create_sketch"))["object"]["id"] + await call("nx_sketch_arc", sketch_id=s, cx=0, cy=0, radius=5, start_angle=0, end_angle=360) + await call("nx_finish_sketch", sketch_id=s) + await call("nx_extrude", sketch_id=s, distance=10) + circles = await call( + "nx_find_geometry", kind="edge", geometry_type="circle", radius=5, near=[0, 0, 10] + ) + assert circles["total"] == 2, circles + curve = (await call("nx_sketch_info", sketch_id=s))["curves"][0]["object"]["id"] + await call( + "nx_edit_sketch", + sketch_id=s, + operations=[ + { + "action": "arc", + "curve": curve, + "center": [0, 0], + "radius": 6, + "start_angle": 0, + "end_angle": 360, + } + ], + ) + assert abs((await call("nx_get_bounding_box"))["max"][0] - 6) < 1e-07 + return {"planes": r, "circles": circles} + + await group("geometric_selection_and_highlight", geometry) + + async def sketches(): + await new("sketch-edit") + s = (await call("nx_create_sketch", plane="XZ"))["object"]["id"] + c = ( + await call("nx_sketch_line", sketch_id=s, start={"x": 0, "y": 0}, end={"x": 10, "y": 0}) + )["object"]["id"] + await call("nx_finish_sketch", sketch_id=s) + r = await call( + "nx_edit_sketch", + sketch_id=s, + operations=[ + {"action": "line", "curve": c, "start": [0, 0], "end": [12, 0]}, + {"action": "constraint", "curve": c, "type": "horizontal"}, + ], + ) + assert r["sketch"]["curves"][0]["end"] == [12, 0, 0], r + cons = r["diagnostics"]["constraints"] + assert cons, cons + s = (await call("nx_list_sketches"))["objects"][0]["id"] + ci = (await call("nx_sketch_info", sketch_id=s))["curves"][0]["object"]["id"] + constraint = (await call("nx_sketch_diagnostics", sketch_id=s))["constraints"][0]["object"][ + "id" + ] + await call( + "nx_edit_sketch", + sketch_id=s, + operations=[ + {"action": "delete", "object": constraint}, + {"action": "add_line", "start": [0, 0], "end": [0, 8]}, + ], + ) + assert (await call("nx_sketch_info", sketch_id=s))["curve_count"] == 2 + s = (await call("nx_list_sketches"))["objects"][0]["id"] + ci = (await call("nx_sketch_info", sketch_id=s))["curves"][-1]["object"]["id"] + await call("nx_edit_sketch", sketch_id=s, operations=[{"action": "delete", "object": ci}]) + assert (await call("nx_sketch_info", sketch_id=s))["curve_count"] == 1 + return r + + await group("sketch_reopen_edit_constraints_delete", sketches) + + async def assembly(): + await box("proto-a") + await call("nx_save_part") + await box("proto-b", w=8) + await call("nx_save_part") + await new("assembly") + a = (await call("nx_add_component", part_path=str(root / "proto-a.prt"), name="seed"))[ + "object" + ]["id"] + b = ( + await call( + "nx_add_component", + part_path=str(root / "proto-a.prt"), + name="other", + translation=[5, 0, 0], + ) + )["object"]["id"] + await call("nx_component_action", component=b, action="rename", name="second") + await call("nx_component_action", component=b, action="suppress") + assert any(c["suppressed"] for c in (await call("nx_list_components"))["components"]) + await call("nx_component_action", component=b, action="unsuppress") + await call( + "nx_component_action", + component=b, + action="replace", + part_path=str(root / "proto-b.prt"), + ) + rows = (await call("nx_list_components"))["components"] + assert any(c["part_path"].endswith("proto-b.prt") for c in rows) + a = next(c["object"]["id"] for c in rows if c["name"].casefold() == "seed") + r = await call( + "nx_pattern_components", component=a, direction=[1, 0, 0], spacing=20, count=4 + ) + assert r["total_instances"] == 4 + assert (await call("nx_list_components"))["count"] == 5 + last = (await call("nx_list_components"))["components"][-1]["object"]["id"] + await call("nx_component_action", component=last, action="remove") + assert (await call("nx_list_components"))["count"] == 4 + await call("nx_save_part") + return r + + await group("assembly_maintenance_and_instances", assembly) + + async def views(): + s, f = await box("views") + body = f["bodies"][0]["id"] + await call("nx_set_display", objects=[body], color="blue", transparency=30) + await call("nx_section_view", origin=[0, 0, 5], normal=[0, 0, 1]) + await call("nx_fit_view") + camera = await call("nx_view_info") + path = str(root / "presentation.json") + await call("nx_save_presentation", path=path) + await call("nx_set_display", objects=[body], color="red") + await call( + "nx_set_camera", + rotation=camera["rotation"], + origin=[1, 2, 3], + scale=camera["scale"] * 0.8, + ) + await call("nx_restore_presentation", path=path) + after = await call("nx_view_info") + assert math.dist(after["origin"], camera["origin"]) < 1e-07 + r = await call("nx_display_info", objects=[body]) + assert r["objects"][1]["transparency"] == 30, r + await call("nx_save_part") + return {"camera": after, "display": r} + + await group("saved_presentation_and_camera", views) + + async def report(): + await call("nx_open_part", path=str(root / "assembly.prt")) + before = await call("nx_view_info") + r = await call( + "nx_inspection_report", + path=str(root / "report.zip"), + minimum_clearance=2, + max_pairs=100, + section_planes=[{"origin": [0, 0, 5], "normal": [0, 0, 1]}], + ) + after = await call("nx_view_info") + assert math.dist(before["origin"], after["origin"]) < 1e-07 + assert r["capture_count"] >= 2 + return r + + await group("inspection_report_artifacts_restore", report) + + async def preview(): + s, f = await box("preview") + feature = f["feature"]["id"] + r = await call( + "nx_preview_change", + operations=[ + { + "method": "nx_edit_feature", + "params": {"name": feature, "params": {"distance": 22}}, + } + ], + ) + assert abs((await call("nx_get_bounding_box"))["max"][2] - 10) < 1e-07 + await call("nx_finish_preview", preview_id=r["preview_id"], action="accept") + assert abs((await call("nx_get_bounding_box"))["max"][2] - 22) < 1e-07 + feature = (await call("nx_list_features"))["objects"][-1]["id"] + q = await call( + "nx_preview_change", + operations=[ + { + "method": "nx_edit_feature", + "params": {"name": feature, "params": {"distance": 30}}, + } + ], + capture=False, + ) + await call("nx_set_expression", expression="marker", formula="1", create=True) + try: + await call("nx_finish_preview", preview_id=q["preview_id"], action="accept") + except Exception: + pass + else: + raise AssertionError("stale preview accepted") + return r + + await group("change_preview_accept_and_staleness", preview) + + async def summary(): + r = { + s: await call("nx_model_summary", section=s, limit=2) + for s in ["overview", "components", "features", "expressions", "sketches"] + } + return r + + await group("compact_summary_pagination", summary) + out["passed"] = sum(g["status"] == "passed" for g in out["groups"]) + out["total"] = len(out["groups"]) + return out + + +async def main(): + endpoint = os.environ["NX_MCP_TEST_ENDPOINT"] + output = Path(os.environ.get("NX_AUTHORING_RESULTS", "authoring-results")) + output.mkdir(parents=True, exist_ok=True) + root = Path("authoring-validation-" + uuid.uuid4().hex[:8]) + async with ( + streamablehttp_client(endpoint) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + tools = {t.name: t for t in (await client.list_tools()).tools} + assert len(tools) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")) + assert tools["nx_model_health"].annotations.readOnlyHint + assert not tools["nx_preview_change"].annotations.readOnlyHint + + async def call(method, **params): + result = await client.call_tool(method, params) + if result.isError: + raise RuntimeError(result.structuredContent or result.content) + return result.structuredContent + + result = await run(call, root) + result["workspace"] = str(root) + artifacts = [] + + def collect(value): + if isinstance(value, dict): + if "path" in value and "sha256" in value: + artifacts.append(value) + for item in value.values(): + collect(item) + elif isinstance(value, list): + for item in value: + collect(item) + + collect(result) + for artifact in artifacts: + content = bytearray() + while True: + chunk = await call( + "nx_download_file", path=artifact["artifact_path"], offset=len(content) + ) + content.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(content).hexdigest() == artifact["sha256"] + target = output / Path(artifact["path"].replace("\\", "/")).name + target.write_bytes(content) + artifact["local_path"] = str(target.resolve()) + if target.suffix == ".zip": + with zipfile.ZipFile(target) as archive: + manifest = json.loads(archive.read("manifest.json")) + for name, digest in manifest.items(): + assert hashlib.sha256(archive.read(name)).hexdigest() == digest + (output / "public-authoring-validation.json").write_text(json.dumps(result, indent=2)) + for group in result["groups"]: + print(group["name"], group["status"], group.get("error", ""), flush=True) + if result["passed"] != result["total"]: + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_documentation_manufacturing.py b/examples/validate_documentation_manufacturing.py new file mode 100644 index 0000000..2455014 --- /dev/null +++ b/examples/validate_documentation_manufacturing.py @@ -0,0 +1,580 @@ +"""Public MCP acceptance for realistic geometry and editable manufacturing documentation. + +Runs in disposable workspace parts, preserves previously saved session parts, +and downloads checksummed native artifacts. Requires interactive NX v2606. +""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "documentation-manufacturing-results")) + output.mkdir(parents=True, exist_ok=True) + prefix = "documentation-validation-" + uuid.uuid4().hex[:8] + receipt = {"fixture": prefix, "checks": [], "artifacts": [], "operations": []} + + def save(): + (output / "documentation-manufacturing-validation.json").write_text( + json.dumps(receipt, indent=2) + ) + + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(tool_name, **params): + response = await client.call_tool(tool_name, params) + receipt["operations"].append( + { + "tool": tool_name, + "error": response.isError, + "operation_id": response.structuredContent.get("operation_id"), + } + ) + save() + assert not response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def reject(tool_name, **params): + response = await client.call_tool(tool_name, params) + assert response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def artifact(meta, name): + data = bytearray() + while True: + chunk = await call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (output / name).write_bytes(data) + receipt["artifacts"].append({"file": name, "sha256": meta["sha256"], "size": len(data)}) + + async def new(name): + return await call("nx_create_part", path=prefix + "/" + name + ".prt", units="mm") + + async def nearest(owner, kind, coords, geometry_type="any"): + result = await call( + "nx_find_geometry", owner=owner, kind=kind, near=coords, geometry_type=geometry_type + ) + assert result["items"] + return result["items"][0]["object"]["id"] + + async def volume(body): + return (await call("nx_measure_volume", body=body))["volume_mm3"] + + async def block(height=5, circle=False): + sk = (await call("nx_create_sketch"))["object"]["id"] + if circle: + await call( + "nx_sketch_arc", + sketch_id=sk, + cx=0, + cy=0, + radius=5, + start_angle=0, + end_angle=360, + ) + else: + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 20, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sk) + return (await call("nx_extrude", sketch_id=sk, distance=height))["body"]["id"] + + async def spline(points, degree=1, method="through_points"): + return (await call("nx_spline", points=points, degree=degree, method=method))["curve"][ + "id" + ] + + async def mesh(x0=0, x1=20, curved=False): + primary = [] + for y in [0, 10]: + pts = ( + [[x0, y, 0], [x1, y, 0]] + if not curved + else [[x0, y, 0], [(x0 + x1) / 2, y, 0], [x1, y, 5]] + ) + primary.append( + [ + await spline( + pts, + degree=2 if curved else 1, + method="poles" if curved else "through_points", + ) + ] + ) + cross = [ + [await spline([[x0, 0, 0], [x0, 10, 0]])], + [await spline([[x1, 0, 5 if curved else 0], [x1, 10, 5 if curved else 0]])], + ] + return (await call("nx_surface_mesh", primary=primary, cross=cross))["body"]["id"] + + async def checked(name): + assert (await call("nx_model_health"))["healthy"] + receipt["checks"].append(name) + save() + + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before), "Save existing parts first" + original = next(p for p in before if p["work"]) + original_display = next(p for p in before if p["display"]) + try: + await new("enclosure") + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_primitive", + sketch_id=sk, + primitive="rounded_rectangle", + center=[0, 0], + width=120, + height=80, + radius=8, + ) + await call("nx_finish_sketch", sketch_id=sk) + ext = await call("nx_extrude", sketch_id=sk, distance=30) + body = ext["body"]["id"] + outer_area = 120 * 80 - (4 - math.pi) * 8**2 + assert math.isclose(await volume(body), outer_area * 30, rel_tol=1e-7) + await call( + "nx_shell", + body=body, + thickness=2, + remove_faces=[await nearest(body, "face", [0, 0, 30])], + ) + inner_area = 116 * 76 - (4 - math.pi) * 6**2 + expected = outer_area * 30 - inner_area * 28 + assert math.isclose(await volume(body), expected, rel_tol=1e-7) + walls = await call( + "nx_wall_thickness", body=body, faces=[await nearest(body, "face", [60, 0, 15])] + ) + assert math.isclose(walls["minimum_sampled_thickness"], 2, abs_tol=1e-6) + receipt["enclosure_volume"] = expected + receipt["enclosure_walls"] = walls + original_bounds = await call("nx_get_bounding_box", body=body, precision="exact") + await call("nx_set_view", orientation="isometric") + await call("nx_fit_view") + await artifact( + await call( + "nx_render_view", path=prefix + "/enclosure.png", style="shaded_with_edges" + ), + "enclosure.png", + ) + await call("nx_export_step", path=prefix + "/enclosure.step") + await call("nx_save_part") + await new("imported-housing") + await call("nx_import_geometry", path=prefix + "/enclosure.step") + bodies = (await call("nx_list_bodies"))["objects"] + assert len(bodies) == 1 + body = bodies[0]["id"] + assert math.isclose(await volume(body), expected, rel_tol=1e-7) + imported_bounds = await call("nx_get_bounding_box", body=body, precision="exact") + for key in ["min", "max"]: + assert all( + math.isclose(a, b, abs_tol=1e-6) + for a, b in zip(original_bounds[key], imported_bounds[key], strict=True) + ) + checkpoint = await call("nx_checkpoint", label="imported housing before local edit") + await call( + "nx_edit_faces", + faces=[await nearest(body, "face", [0, 0, 2])], + action="offset", + distance=1, + ) + assert not math.isclose(await volume(body), expected, rel_tol=1e-7) + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + body = (await call("nx_list_bodies"))["objects"][0]["id"] + assert math.isclose(await volume(body), expected, rel_tol=1e-7) + await call( + "nx_edit_faces", + faces=[await nearest(body, "face", [0, 0, 2])], + action="offset", + distance=0.5, + ) + edited_volume = await volume(body) + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=prefix + "/imported-housing.prt") + stale = await reject("nx_measure_volume", body=body) + assert stale["code"] == "NX_OBJECT_STALE" + body = (await call("nx_list_bodies"))["objects"][0]["id"] + assert math.isclose(await volume(body), edited_volume, rel_tol=1e-7) + await checked( + "rounded_enclosure_analytic_shell_step_roundtrip_local_edit_rollback_reopen" + ) + + async def curved_patch(poles): + primary = [] + for y in [0, 10]: + primary.append( + [await spline([[x, y, z] for x, z in poles], degree=2, method="poles")] + ) + cross = [[await spline([[x, 0, z], [x, 10, z]])] for x, z in [poles[0], poles[-1]]] + return (await call("nx_surface_mesh", primary=primary, cross=cross))["body"]["id"] + + await new("curved-continuity") + a = await curved_patch([[0, 0], [10, 0], [20, 5]]) + b = await curved_patch([[20, 5], [30, 10], [40, 20]]) + result = await call( + "nx_surface_continuity", + first=await nearest(a, "edge", [20, 5, 5]), + second=await nearest(b, "edge", [20, 5, 5]), + samples=7, + ) + receipt["curved_continuity"] = result + assert result["checks"] == {"G0": True, "G1": True, "G2": True} + c = await curved_patch([[20, 5], [30, 15], [40, 30]]) + result = await call( + "nx_surface_continuity", + first=await nearest(a, "edge", [20, 5, 5]), + second=await nearest(c, "edge", [20, 5, 5]), + samples=7, + ) + assert result["checks"]["G0"] and not result["checks"]["G1"] + await checked("curved_parabolic_G2_join_and_deliberate_tangent_discontinuity") + await new("thin-wall") + body = await block(height=0.2) + face = await nearest(body, "face", [10, 5, 0.2]) + thin = await call("nx_wall_thickness", body=body, faces=[face], tolerance=0.001) + assert math.isclose(thin["minimum_sampled_thickness"], 0.2, abs_tol=1e-7) + unresolved = await call("nx_wall_thickness", body=body, faces=[face], tolerance=0.3) + assert ( + unresolved["measured_count"] == 0 + and unresolved["minimum_sampled_thickness"] is None + ) + receipt["thin_wall"] = thin + await checked("thin_wall_measurement_and_outside_ray_origin_rejection") + await new("draft-transitions") + body = await block(height=20) + side = await nearest(body, "face", [20, 5, 10]) + bottom = await nearest(body, "face", [10, 5, 0]) + await call( + "nx_draft", faces=[side], stationary_face=bottom, direction=[0, 0, 1], angle=5 + ) + face = await nearest(body, "face", [20, 5, 10]) + positive = await call( + "nx_face_analysis", faces=[face], pull_direction=[0, 0, 1], minimum_draft=6 + ) + negative = await call( + "nx_face_analysis", faces=[face], pull_direction=[0, 0, -1], minimum_draft=6 + ) + angles = [x["signed_draft_degrees"] for x in positive["samples"]] + assert all(math.isclose(abs(x), 5, abs_tol=1e-5) for x in angles) + assert all( + math.isclose(x["signed_draft_degrees"], -y["signed_draft_degrees"], abs_tol=1e-5) + for x, y in zip(positive["samples"], negative["samples"], strict=True) + ) + assert { + positive["samples"][0]["classification"], + negative["samples"][0]["classification"], + } == {"negative", "below_minimum"} + receipt["draft"] = {"forward": positive, "reverse": negative} + await checked("known_five_degree_draft_signed_transition_and_threshold") + + await new("bracket") + await call("nx_sheet_metal_context") + await call( + "nx_set_sheet_metal_defaults", thickness=2, bend_radius=3, neutral_factor=0.33 + ) + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 100, "y": 80}, + ) + await call("nx_finish_sketch", sketch_id=sk) + tab = await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": sk, "thickness": 2}, + ) + body = tab["body"]["id"] + flange = await call( + "nx_sheet_metal_feature", + operation="flange", + parameters={ + "flanges": [ + { + "edges": [await nearest(body, "edge", [50, 0, 0])], + "length": 20, + "length_reference": "Inside", + "angle": 90, + } + ] + }, + ) + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + await call( + "nx_sheet_metal_annotation", + kind="bend", + body=body, + faces=[info["bends"][0]["face"]["id"]], + position=[50, -25, 20], + automatic=True, + ) + angle = next(x["object"]["id"] for x in flange["expressions"] if x["value"] == 90) + edited = await call("nx_set_expression", expression=angle, formula="75") + assert len(edited["refreshed_annotations"]) == 1 + notes = await call("nx_list_annotations") + assert any("75.000 deg" in " ".join(x.get("text", [])) for x in notes["items"]) + flat = await call( + "nx_sheet_metal_feature", + operation="flat_pattern", + parameters={ + "upward_face": await nearest(body, "face", [50, 40, 0]), + "x_axis_edge": await nearest(body, "edge", [50, 80, 0]), + "associative": True, + }, + ) + sheet = (await call("nx_create_drawing", name="Flat", size="A3"))["object"]["id"] + view = await call( + "nx_add_flat_pattern_view", + drawing=sheet, + flat_pattern=flat["feature"]["id"], + position=[150, 150], + ) + table = await call( + "nx_bend_table", + view=view["view"]["id"], + position=[20, 270], + columns=["BendID", "BendAngle", "BendRadius"], + ) + receipt["bend_table_before"] = table + await call("nx_activate_drawing") + await call("nx_set_expression", expression=angle, formula="80") + await call("nx_activate_drawing", drawing=sheet) + automatic_tables = [ + x + for x in (await call("nx_list_annotations"))["items"] + if x["native_type"] == "BendTable" + ] + assert automatic_tables[0]["rows"] != table["rows"], ( + "Automatic table remained stale before any table edit" + ) + table2 = await call( + "nx_bend_table", + view=view["view"]["id"], + table=table["table"]["id"], + position=[20, 270], + ) + receipt["bend_table_after"] = table2 + assert table2["rows"] != table["rows"] + await artifact( + await call("nx_export_drawing_pdf", path=prefix + "/bracket.pdf"), "bracket.pdf" + ) + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=prefix + "/bracket.prt") + await call("nx_activate_drawing") + refreshed = await call("nx_refresh_annotations") + assert refreshed["updated_count"] == 0 + await checked( + "native_bend_table_managed_PMI_transactional_update_and_persistent_reopen" + ) + + for detailed in [False, True]: + await new("standard-thread-" + str(detailed)) + rows = (await call("nx_thread_catalog", standard="Metric Coarse", size="M6 x 1.0"))[ + "items" + ] + row = next(r for r in rows if r["RadialEngage"] == "0.75" and r["Method"] == "CUT") + body = await block(height=10) + await call( + "nx_hole", + diameter=float(row["TapDrillDia"]), + depth=10, + x=10, + y=5, + z=10, + body=body, + direction=[0, 0, -1], + ) + v0 = await volume(body) + thread = await call( + "nx_standard_thread", + face=await nearest(body, "face", [12.5, 5, 5], "cylinder"), + start_face=await nearest(body, "face", [5, 5, 10]), + standard=row["Standard"], + size=row["Size"], + method=row["Method"], + radial_engage=row["RadialEngage"], + length=8, + detailed=detailed, + ) + assert math.isclose(thread["pitch"], 1, abs_tol=1e-8) and thread["internal"] + v1 = await volume(body) + assert v1 < v0 if detailed else math.isclose(v0, v1, rel_tol=1e-8) + datum = await call( + "nx_pmi_datum", + faces=[await nearest(body, "face", [5, 5, 10])], + letter="A", + position=[25, 0, 10], + ) + fcf = await call( + "nx_pmi_fcf", + faces=[await nearest(body, "face", [12.5, 5, 5], "cylinder")], + characteristic="Position", + tolerance=0.1, + position=[25, 5, 10], + datums=[datum["annotation"]["id"]], + material="MMC", + zone_shape="diameter", + datum_material=["RFS"], + projected_height=5, + ) + receipt.setdefault("threads", []).append( + {"result": thread, "before": v0, "after": v1, "fcf": fcf} + ) + await checked("standard_table_thread_" + str(detailed) + "_and_GDT_modifiers") + + await new("assembly") + for name, path, translation in [ + ("Housing", "enclosure", [0, 0, 0]), + ("Bracket", "bracket", [-50, -40, 4]), + ]: + await call( + "nx_add_component", + part_path=prefix + "/" + path + ".prt", + name=name, + translation=translation, + ) + components = (await call("nx_list_components"))["components"] + assert len(components) == 2 + bounds = await call("nx_get_bounding_box", scope="assembly", precision="exact") + receipt["assembly_bounds"] = bounds + explosion = (await call("nx_create_explosion", name="Service"))["object"]["id"] + placements = [ + { + "component": c["object"]["id"], + "translation": [ + c["translation"][0], + c["translation"][1], + c["translation"][2] + i * 60, + ], + } + for i, c in enumerate(components) + ] + await call("nx_edit_explosion", explosion=explosion, placements=placements) + await call("nx_show_explosion", explosion=explosion) + edges = [await nearest(c["object"]["id"], "edge", [0, 0, 10]) for c in components] + trace = await call( + "nx_explosion_trace", explosion=explosion, start_edge=edges[0], end_edge=edges[1] + ) + moved = await call( + "nx_edit_explosion_trace", + traceline=trace["traceline"]["id"], + start_percent=25, + end_percent=75, + start_offset=3, + end_offset=4, + ) + assert moved["start_offset"] == 3 and moved["end_offset"] == 4 + await artifact( + await call( + "nx_render_view", path=prefix + "/assembly.png", style="shaded_with_edges" + ), + "assembly.png", + ) + sheet = (await call("nx_create_drawing", name="Service", size="A3"))["object"]["id"] + bom = await call("nx_create_parts_list", drawing=sheet, position=[25, 270]) + edited = await call( + "nx_parts_list_column", + parts_list=bom["parts_list"]["id"], + action="edit", + index=1, + title="COMPONENT", + width=65, + ) + assert edited["columns"][1]["title"] == "COMPONENT" + appended = await call( + "nx_parts_list_column", + parts_list=bom["parts_list"]["id"], + action="append", + title="PART", + width=45, + field=bom["columns"][1]["field"], + ) + assert appended["column_count"] == 4 + await call( + "nx_parts_list_column", parts_list=bom["parts_list"]["id"], action="remove", index=3 + ) + view = ( + await call( + "nx_add_base_view", + drawing=sheet, + scope="assembly", + explosion=explosion, + position=[190, 160], + ) + )["object"]["id"] + balloons = await call( + "nx_parts_list_balloons", parts_list=bom["parts_list"]["id"], view=view + ) + assert balloons["balloon_count"] >= 1 + annotation = balloons["balloons"][0]["id"] + await call( + "nx_edit_annotation", + annotation=annotation, + position=[300, 150, 0], + name="Service callout", + ) + annotations = await call("nx_list_annotations") + assert any(x.get("position") == [300, 150, 0] for x in annotations["items"]) + sheets = await call("nx_list_drawings") + assert sheets["sheets"][0]["views"] + await artifact( + await call("nx_export_drawing_pdf", path=prefix + "/service.pdf"), "service.pdf" + ) + await call("nx_activate_drawing") + assert (await call("nx_list_drawings"))["modeling_active"] + await call("nx_activate_drawing", drawing=sheet) + await call("nx_save_part") + await checked( + "sheet_metal_assembly_explosion_trace_edit_BOM_columns_balloon_placement_drawings" + ) + receipt["passed"] = True + except Exception: + receipt["passed"] = False + receipt["error"] = traceback.format_exc() + raise + finally: + try: + await call("nx_open_part", path=original["path"], work=True, display=True) + fixtures = [ + p for p in (await call("nx_list_open_parts"))["parts"] if prefix in p["path"] + ] + for part in reversed(fixtures): + await call("nx_close_part", part=part["part"]["id"], save=True) + if original_display["path"] != original["path"]: + await call( + "nx_open_part", path=original_display["path"], work=False, display=True + ) + after = (await call("nx_list_open_parts"))["parts"] + assert {p["path"] for p in after} == {p["path"] for p in before} + assert not any(p["modified"] for p in after) + receipt["session_restored"] = True + finally: + save() + print(json.dumps({"passed": receipt["passed"], "checks": receipt["checks"]})) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py new file mode 100644 index 0000000..3e3a138 --- /dev/null +++ b/examples/validate_engineering_tools.py @@ -0,0 +1,581 @@ +"""Live NX 2606 engineering acceptance; isolated parts and session restoration. + +NX_MCP_URL selects the deployed server. NX_VALIDATION_OUTPUT holds receipts and +artifacts. NX_VALIDATION_GROUP optionally selects comma-separated named groups. +""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +from pathlib import Path, PureWindowsPath + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "engineering-results")) + output.mkdir(parents=True, exist_ok=True) + prefix = "engineering-validation-" + uuid.uuid4().hex[:8] + result = {"groups": [], "fixture": prefix} + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(method, **params): + response = await client.call_tool(method, params) + if response.isError: + raise RuntimeError((method, response.structuredContent)) + if method == "nx_render_view": + images = [c for c in response.content if c.type == "image"] + assert len(images) == 1, "Native render was not delivered inline" + data = base64.b64decode(images[0].data) + assert hashlib.sha256(data).hexdigest() == response.structuredContent["sha256"] + (output / "native-render.png").write_bytes(data) + return response.structuredContent + + def close(actual, expected): + assert math.isclose(actual, expected, rel_tol=1e-7, abs_tol=1e-10), (actual, expected) + + async def group(name, fn): + selected = os.environ.get("NX_VALIDATION_GROUP") + if selected and name not in selected.split(","): + return + try: + value = await fn() + result["groups"].append({"name": name, "passed": True, "result": value}) + except Exception: + result["groups"].append( + {"name": name, "passed": False, "error": traceback.format_exc()} + ) + (output / "engineering-validation.json").write_text(json.dumps(result, indent=2)) + print(name, result["groups"][-1]["passed"], flush=True) + + async def new(name): + return await call("nx_create_part", path=prefix + "/" + name + ".prt", units="mm") + + async def profile(w=10, h=10, origin=None): + s = (await call("nx_create_sketch", origin=origin))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=s, + corner1={"x": 0, "y": 0}, + corner2={"x": w, "y": h}, + ) + await call("nx_finish_sketch", sketch_id=s) + return s + + async def box(name): + await new(name) + s = await profile() + return await call("nx_extrude", sketch_id=s, distance=10) + + async def volume(body=None): + return (await call("nx_measure_volume", body=body))["volume_mm3"] + + async def face(body, normal, order="highest"): + return ( + await call( + "nx_find_geometry", + owner=body, + geometry_type="plane", + normal=normal, + order=order, + ) + )["items"][0]["object"]["id"] + + async def assembly(name): + await box(name + "_prototype") + path = (await call("nx_list_open_parts"))["parts"] + source = next(p["path"] for p in path if p["work"]) + await call("nx_save_part") + await new(name + "_assembly") + return (await call("nx_add_component", part_path=source, name="seed"))["object"][ + "id" + ], source + + before = await call("nx_list_open_parts") + assert not any(p["modified"] for p in before["parts"]), ( + "Save the current session before native acceptance" + ) + work = next((p for p in before["parts"] if p["work"]), None) + display = next((p for p in before["parts"] if p["display"]), None) + try: + assert len((await client.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") + ) + + async def limits(): + await new("offset") + s = await profile() + await call("nx_extrude", sketch_id=s, distance=5, start=-2) + close(await volume(), 700) + b = await call("nx_get_bounding_box") + close(b["min"][2], -2) + close(b["max"][2], 5) + await new("symmetric") + s = await profile() + await call("nx_extrude", sketch_id=s, distance=8, symmetric=True) + close(await volume(), 800) + b = await call("nx_get_bounding_box") + close(b["min"][2], -4) + close(b["max"][2], 4) + f = await box("through") + s = await profile(2, 2, [2, 2, -2]) + await call( + "nx_extrude", + sketch_id=s, + end_type="through_all", + boolean="subtract", + targets=[f["body"]["id"]], + ) + close(await volume(), 960) + f = await box("until") + s = await profile(2, 2) + target = await face(f["body"]["id"], [0, 0, 1]) + r = await call("nx_extrude", sketch_id=s, end_type="up_to_face", target_face=target) + close(await volume(r["body"]["id"]), 40) + return {"offset_volume": 700, "through_volume": 960, "until_volume": 40} + + await group("extrusion_limits", limits) + + async def shell_loft_draft(): + f = await box("shell") + top = await face(f["body"]["id"], [0, 0, 1]) + await call("nx_shell", body=f["body"]["id"], thickness=1, remove_faces=[top]) + close(await volume(), 424) + await new("loft") + a = await profile() + b = await profile(6, 6, [2, 2, 10]) + await call("nx_loft", sketches=[a, b]) + close(await volume(), 1960 / 3) + f = await box("draft") + side = await face(f["body"]["id"], [1, 0, 0]) + bottom = await face(f["body"]["id"], [0, 0, -1], "lowest") + await call( + "nx_draft", faces=[side], stationary_face=bottom, direction=[0, 0, 1], angle=5 + ) + close(abs(await volume() - 1000), 500 * math.tan(math.radians(5))) + return { + "shell_volume": 424, + "loft_volume": 1960 / 3, + "draft_volume": await volume(), + } + + await group("shell_loft_draft", shell_loft_draft) + + async def motion(): + f = await box("motion") + rotation = [[0, -1, 0], [1, 0, 0], [0, 0, 1]] + op = "test-copy-" + uuid.uuid4().hex + params = { + "bodies": [f["body"]["id"]], + "translation": [20, 30, 40], + "rotation_matrix": rotation, + "copy": True, + "operation_id": op, + } + r = await call("nx_transform_bodies", **params) + again = await call("nx_transform_bodies", **params) + assert r["feature"]["id"] == again["feature"]["id"] + close(await volume(), 2000) + r = await call( + "nx_transform_bodies", + feature=r["feature"]["id"], + translation=[40, 30, 40], + rotation_matrix=rotation, + ) + bounds = await call("nx_get_bounding_box", body=r["body"]["id"]) + assert bounds["min"] == [30, 30, 40], bounds + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=prefix + "/motion.prt") + close(await volume(), 2000) + return bounds + + await group("associative_motion_persistence_retry", motion) + + async def materials(): + f = await box("material") + await call( + "nx_set_material", + bodies=[f["body"]["id"]], + name="ValidationAluminum", + density=2700, + ) + r = await call("nx_mass_properties") + close(r["mass_kg"], 0.0027) + for x in r["center_of_gravity_m"]: + close(x, 0.005) + for i in range(3): + close(r["inertia_tensor_centroid_kg_m2"][i][i], 4.5e-8) + await call("nx_save_part") + await new("material_subassembly") + for translation in [[0, 0, 0], [20, 0, 0]]: + await call( + "nx_add_component", + part_path=prefix + "/material.prt", + translation=translation, + ) + await call("nx_save_part") + await new("material_assembly") + await call( + "nx_add_component", + part_path=prefix + "/material_subassembly.prt", + translation=[10, 20, 30], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + nested = await call("nx_mass_properties", scope="assembly") + close(nested["mass_kg"], 0.0054) + for actual, expected in zip( + nested["center_of_gravity_m"], [0.005, 0.035, 0.035], strict=True + ): + close(actual, expected) + return {"part": r, "nested_assembly": nested} + + await group("physical_material_mass_inertia", materials) + + async def arrays(): + a, _ = await assembly("array") + r = await call( + "nx_component_array", + component=a, + pattern_type="rectangular", + count=3, + spacing=20, + direction=[1, 0, 0], + count_y=2, + spacing_y=30, + direction_y=[0, 1, 0], + ) + r = await call( + "nx_edit_component_pattern", + pattern=r["object"]["id"], + count=4, + count_y=3, + spacing_y=40, + ) + assert r["total_instances"] == 12 + poses = {tuple(p["translation"]) for p in r["instances"]} + assert poses == {(20 * x, 40 * y, 0) for x in range(4) for y in range(3)}, poses + a, _ = await assembly("circular") + r = await call( + "nx_component_array", + component=a, + pattern_type="circular", + count=4, + center=[0, 0, 0], + axis=[0, 0, 1], + angle=90, + ) + r = await call( + "nx_edit_component_pattern", pattern=r["object"]["id"], count=5, angle=60 + ) + assert r["total_instances"] == 5 + return {"rectangular_positions": sorted(poses), "circular_count": 5} + + await group("component_arrays_and_edits", arrays) + + async def constraints(): + a, source = await assembly("constraints") + b = ( + await call( + "nx_add_component", part_path=source, name="moving", translation=[30, 0, 0] + ) + )["object"]["id"] + await call("nx_assembly_constraint", constraint_type="fix", component=a) + fa = await face(a, [1, 0, 0]) + fb = await face(b, [-1, 0, 0], "lowest") + r = await call( + "nx_assembly_constraint", + constraint_type="distance", + component=b, + geometry=fb, + target_component=a, + target_geometry=fa, + value=5, + alignment="opposite", + ) + close((await call("nx_measure_distance", obj1=a, obj2=b))["distance"], 5) + await call("nx_edit_assembly_constraint", constraint=r["object"]["id"], value=12) + close((await call("nx_measure_distance", obj1=a, obj2=b))["distance"], 12) + await call( + "nx_edit_assembly_constraint", constraint=r["object"]["id"], suppressed=True + ) + await call( + "nx_edit_assembly_constraint", constraint=r["object"]["id"], suppressed=False + ) + close((await call("nx_measure_distance", obj1=a, obj2=b))["distance"], 12) + return await call("nx_list_assembly_constraints") + + await group("assembly_constraint_geometry", constraints) + + async def copy_project(): + await assembly("copy") + await call("nx_save_part") + r = await call("nx_copy_project", path=prefix + "/projects/copied", prefix="COPY_") + assert r["references_verified"] and r["dependency_count"] == 1 + return r + + await group("project_copy_dependencies", copy_project) + + async def primitives(): + results = {} + for name, kw, area in [ + ("circle", {"radius": 2}, 4 * math.pi), + ("slot", {"width": 10, "height": 4}, 24 + 4 * math.pi), + ("rounded_rectangle", {"width": 10, "height": 6, "radius": 1}, 56 + math.pi), + ]: + await new(name) + s = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_primitive", sketch_id=s, primitive=name, center=[0, 0], **kw + ) + await call("nx_finish_sketch", sketch_id=s) + await call("nx_extrude", sketch_id=s, distance=3) + close(await volume(), area * 3) + results[name] = await volume() + return results + + await group("sketch_primitives", primitives) + + async def recovery(): + await box("recovery") + sketch = (await call("nx_create_sketch"))["object"]["id"] + points = [[0, 0], [10, 10], [0, 10], [10, 0], [0, 0]] + for start, end in zip(points, points[1:], strict=False): + await call( + "nx_sketch_line", + sketch_id=sketch, + start=dict(zip(["x", "y"], start, strict=True)), + end=dict(zip(["x", "y"], end, strict=True)), + ) + await call("nx_finish_sketch", sketch_id=sketch) + checkpoint = await call("nx_checkpoint", label="engineering recovery") + failed = await client.call_tool( + "nx_extrude", + { + "sketch_id": sketch, + "distance": 5, + "operation_id": "bad-section-" + uuid.uuid4().hex, + }, + ) + assert failed.isError, ( + "Self-intersecting section unexpectedly succeeded", + failed.structuredContent, + ) + assert failed.structuredContent["details"]["mutation_outcome"] == "rolled_back" + close(await volume(), 1000) + # Rollback invalidates object IDs; reacquire the surviving body. + body = (await call("nx_list_bodies"))["objects"][0]["id"] + await call( + "nx_transform_bodies", + bodies=[body], + translation=[20, 0, 0], + rotation_matrix=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + copy=True, + ) + close(await volume(), 2000) + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + close(await volume(), 1000) + return {"failed_operation": failed.structuredContent, "restored_volume": 1000} + + await group("failed_mutation_and_checkpoint_rollback", recovery) + + async def rendering(): + await box("render") + await call("nx_fit_view") + return await call( + "nx_render_view", + width=800, + height=600, + lighting=2, + background="color", + color=[0.2, 0.3, 0.4], + ) + + await group("native_render_inline_artifact", rendering) + + async def line(sketch, start, end): + return ( + await call( + "nx_sketch_line", + sketch_id=sketch, + start=dict(zip(["x", "y"], start, strict=True)), + end=dict(zip(["x", "y"], end, strict=True)), + ) + )["object"]["id"] + + async def sketch_edits(): + results = {} + await new("angle") + s = (await call("nx_create_sketch"))["object"]["id"] + a = await line(s, [0, 0], [10, 0]) + b = await line(s, [0, 0], [5, 5]) + results["angle"] = await call( + "nx_sketch_angle", sketch_id=s, line1=a, line2=b, value=60, origin=[4, 2] + ) + await call("nx_finish_sketch", sketch_id=s) + await new("tangent") + s = (await call("nx_create_sketch"))["object"]["id"] + a = await line(s, [-10, 0], [10, 0]) + b = ( + await call( + "nx_sketch_primitive", + sketch_id=s, + primitive="circle", + center=[0, 3], + radius=2, + ) + )["curves"][0]["id"] + results["tangent"] = await call( + "nx_sketch_tangent", sketch_id=s, curve1=a, curve2=b + ) + close(results["tangent"]["residual"], 0) + assert results["tangent"]["constraints"] + await call("nx_finish_sketch", sketch_id=s) + await new("symmetry") + s = (await call("nx_create_sketch"))["object"]["id"] + a = await line(s, [-5, 0], [-5, 10]) + b = await line(s, [4, 1], [4, 9]) + axis = await line(s, [0, -5], [0, 15]) + results["symmetry"] = await call( + "nx_sketch_symmetry", sketch_id=s, curve1=a, curve2=b, centerline=axis + ) + close(results["symmetry"]["residual"], 0) + assert results["symmetry"]["constraints"] + await call("nx_finish_sketch", sketch_id=s) + for action, end, pick in [("trim", [5, 0], [-3, 0]), ("extend", [-2, 0], [-2, 0])]: + await new(action) + s = (await call("nx_create_sketch"))["object"]["id"] + a = await line(s, [-5, 0], end) + b = await line(s, [0, -5], [0, 5]) + results[action] = await call( + "nx_sketch_trim_extend", + sketch_id=s, + curve=a, + boundaries=[b], + pick=pick, + action=action, + ) + await call("nx_finish_sketch", sketch_id=s) + return results + + await group("sketch_relations_and_local_edits", sketch_edits) + + async def legacy_modeling(): + results = {} + for method, param, expected in [ + ("nx_blend", "radius", 1000 - 10 * (1 - math.pi / 4)), + ("nx_chamfer", "offset", 995), + ]: + f = await box(method) + edges = await call( + "nx_find_geometry", + owner=f["body"]["id"], + kind="edge", + geometry_type="line", + order="highest", + ) + await call(method, edges=[edges["items"][0]["object"]["id"]], **{param: 1}) + close(await volume(), expected) + results[method] = await volume() + await box("hole") + await call("nx_hole", diameter=2, depth=5, x=5, y=5, z=0) + close(await volume(), 1000 - 5 * math.pi) + results["hole"] = await volume() + f = await box("mirror") + mirrored = await call("nx_mirror_body", body=f["body"]["id"], plane="YZ") + close(await volume(), 2000) + bounds = await call("nx_get_bounding_box", body=mirrored["body"]["id"]) + close(bounds["min"][0], -10) + close(bounds["max"][0], 0) + await new("sweep") + section = await profile(2, 2) + guide = (await call("nx_create_sketch", plane="XZ"))["object"]["id"] + await line(guide, [0, 0], [0, 10]) + await call("nx_finish_sketch", sketch_id=guide) + await call("nx_sweep", section=section, guide=guide) + close(await volume(), 40) + results["sweep"] = await volume() + return results + + await group("repaired_native_modeling", legacy_modeling) + + async def drawing_pdf(): + f = await box("drawing") + sheet = await call("nx_create_drawing", name="Sheet1", size="A3", scale=1) + view = await call( + "nx_add_base_view", + drawing=sheet["object"]["id"], + body=f["body"]["id"], + view="top", + ) + edges = await call( + "nx_find_geometry", + owner=f["body"]["id"], + kind="edge", + geometry_type="line", + order="highest", + ) + edge = next(e for e in edges["items"] if e["bounds"][3] - e["bounds"][0] > 9) + dimension = await call( + "nx_add_dimension", + view=view["object"]["id"], + object1=edge["object"]["id"], + dim_type="horizontal", + origin=[100, 80], + ) + close(dimension["measured_value"], 10) + projected = await call( + "nx_add_projection_view", base_view=view["object"]["id"], direction="right" + ) + pdf = await call("nx_export_drawing_pdf", path=prefix + "/drawing.pdf") + artifact = await call("nx_download_file", path=pdf["path"]) + data = base64.b64decode(artifact["data_base64"]) + assert artifact["eof"] and data.startswith(b"%PDF-") + assert hashlib.sha256(data).hexdigest() == pdf["sha256"] + (output / "drawing.pdf").write_bytes(data) + return { + "sheet": sheet, + "view": view, + "dimension": dimension, + "projected": projected, + "pdf": pdf, + } + + await group("native_drafting_pdf", drawing_pdf) + + finally: + for p in (await call("nx_list_open_parts"))["parts"]: + if prefix in p["path"]: + await call("nx_close_part", part=p["part"]["id"], save=False) + if display: + await call("nx_open_part", path=display["path"]) + if work: + await call( + "nx_activate_part", part=work["part"]["id"], work=True, display=work == display + ) + after = await call("nx_list_open_parts") + + def norm(p): + return str(PureWindowsPath(p["path"])).casefold() + + assert sorted(map(norm, before["parts"])) == sorted(map(norm, after["parts"])) + assert not any(p["modified"] for p in after["parts"]) + result["restored_parts"] = len(after["parts"]) + result["passed"] = sum(g["passed"] for g in result["groups"]) + (output / "engineering-validation.json").write_text(json.dumps(result, indent=2)) + if not all(g["passed"] for g in result["groups"]): + raise SystemExit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_exploded_views.py b/examples/validate_exploded_views.py new file mode 100644 index 0000000..bac28d4 --- /dev/null +++ b/examples/validate_exploded_views.py @@ -0,0 +1,249 @@ +"""Public MCP native explosion acceptance; preserves the initially saved session. + +Set NX_MCP_URL and optionally NX_VALIDATION_OUTPUT. Tests use isolated parts. +""" + +import asyncio +import base64 +import hashlib +import json +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "explosion-results")) + output.mkdir(parents=True, exist_ok=True) + prefix = "explosion-validation-" + uuid.uuid4().hex[:8] + receipt = {"fixture": prefix, "groups": []} + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(method, **params): + response = await client.call_tool(method, params) + assert not response.isError, (method, response.structuredContent) + return response.structuredContent + + async def rejected(method, **params): + response = await client.call_tool(method, params) + assert response.isError, (method, response.structuredContent) + return response.structuredContent + + async def new(name): + await call("nx_create_part", path=f"{prefix}/{name}.prt", units="mm") + + async def work_path(): + return next(p["path"] for p in (await call("nx_list_open_parts"))["parts"] if p["work"]) + + async def poses(ex): + info = await call("nx_explosion_info", explosion=ex, limit=200) + return {i["occurrence_path"][-1]: i for i in info["items"]} + + async def artifact(meta, name): + data = bytearray() + while True: + chunk = await call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (output / name).write_bytes(data) + + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before), "Save original parts before acceptance" + work = next((p for p in before if p["work"]), None) + display = next((p for p in before if p["display"]), None) + try: + await new("proto") + s = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=s, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=s) + await call("nx_extrude", sketch_id=s, distance=10) + await call("nx_save_part") + source = await work_path() + await new("sub") + await call("nx_add_component", part_path=source, name="leaf", translation=[20, 0, 0]) + await call("nx_save_part") + sub = await work_path() + await new("assembly") + await call("nx_add_component", part_path=source, name="base") + parent = ( + await call( + "nx_add_component", + part_path=sub, + name="parent", + translation=[10, 20, 30], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + )["object"]["id"] + await call("nx_add_component", part_path=source, name="cap", translation=[40, 0, 0]) + assembled = (await call("nx_list_components"))["components"] + ex = (await call("nx_create_explosion", name="Service"))["object"]["id"] + items = await poses(ex) + leaf, cap = [items[n]["component"]["id"] for n in ("LEAF", "CAP")] + targets = [ + { + "component": leaf, + "translation": [70, 60, 50], + "rotation_matrix": [[-1, 0, 0], [0, -1, 0], [0, 0, 1]], + }, + {"component": parent, "translation": [50, 20, 30]}, + {"component": cap, "translation": [100, 0, 50]}, + ] + token = "explosion-" + uuid.uuid4().hex + first = await call( + "nx_edit_explosion", explosion=ex, placements=targets, operation_id=token + ) + repeat = await call( + "nx_edit_explosion", explosion=ex, placements=targets, operation_id=token + ) + assert repeat["operation_id"] == first["operation_id"] + assert (await call("nx_edit_explosion", explosion=ex, placements=targets))[ + "affected_component_count" + ] == 0 + items = await poses(ex) + assert items["LEAF"]["translation"] == [70, 60, 50] + assert items["PARENT"]["translation"] == [50, 20, 30] + await call("nx_edit_explosion", explosion=ex, reset_components=[leaf]) + assert (await poses(ex))["LEAF"]["translation"] == [50, 40, 30] + await call("nx_edit_explosion", explosion=ex, placements=[targets[0]]) + receipt["groups"].append({"name": "absolute_nested_poses_reset_retry", "passed": True}) + + await call("nx_show_explosion", explosion=ex) + png = await call("nx_screenshot", path=prefix + "/exploded.png") + await artifact(png, "exploded.png") + sheet = (await call("nx_create_drawing", name="ServiceSheet", size="A3", scale=1))[ + "object" + ]["id"] + await call( + "nx_add_base_view", + drawing=sheet, + scope="assembly", + view="isometric", + position=[70, 100], + ) + view = await call( + "nx_add_base_view", + drawing=sheet, + scope="assembly", + view="isometric", + position=[200, 100], + explosion=ex, + ) + await call( + "nx_add_projection_view", + base_view=view["object"]["id"], + direction="right", + spacing=80, + ) + edit = await call( + "nx_edit_explosion", + explosion=ex, + placements=[{"component": cap, "translation": [120, 0, 50]}], + ) + assert len(edit["updated_drawing_views"]) == 2 + pdf = await call("nx_export_drawing_pdf", path=prefix + "/exploded.pdf") + await artifact(pdf, "exploded.pdf") + await call("nx_show_explosion", explosion=ex) + await call("nx_save_part") + assert not next( + p["modified"] for p in (await call("nx_list_open_parts"))["parts"] if p["work"] + ) + await call("nx_save_as", path=prefix + "/assembly_saved_as.prt") + saved_path = await work_path() + await call("nx_close_part", save=True) + await call("nx_open_part", path=saved_path) + await rejected("nx_explosion_info", explosion=ex) + ex = (await call("nx_list_explosions"))["explosions"][0]["object"]["id"] + items = await poses(ex) + assert items["LEAF"]["translation"] == [70, 60, 50] + assert items["CAP"]["translation"] == [120, 0, 50] + fields = ("name", "part_path", "translation", "rotation_matrix") + after = (await call("nx_list_components"))["components"] + assert [{k: c[k] for k in fields} for c in assembled] == [ + {k: c[k] for k in fields} for c in after + ] + receipt["groups"].append( + {"name": "views_pdf_save_as_reopen", "passed": True, "png": png, "pdf": pdf} + ) + + await rejected("nx_delete_explosion", explosion=ex) + ex = (await call("nx_list_explosions"))["explosions"][0]["object"]["id"] + initial = await poses(ex) + cap = initial["CAP"]["component"]["id"] + await rejected( + "nx_edit_explosion", + explosion=ex, + placements=[ + {"component": cap, "translation": [300, 0, 0]}, + {"component": cap, "translation": [400, 0, 0]}, + ], + ) + ex = (await call("nx_list_explosions"))["explosions"][0]["object"]["id"] + assert (await poses(ex))["CAP"]["translation"] == [120, 0, 50] + checkpoint = await call("nx_checkpoint", label="explosion before edit") + cap = (await poses(ex))["CAP"]["component"]["id"] + await call( + "nx_edit_explosion", + explosion=ex, + placements=[{"component": cap, "translation": [300, 0, 0]}], + ) + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + ex = (await call("nx_list_explosions"))["explosions"][0]["object"]["id"] + assert (await poses(ex))["CAP"]["translation"] == [120, 0, 50] + views = (await call("nx_explosion_info", explosion=ex))["views"] + for v in views: + await call( + "nx_show_explosion", + **{ + "drawing_view" if v["kind"] == "drawing" else "model_view": v["object"][ + "id" + ] + }, + ) + await call("nx_delete_explosion", explosion=ex) + await rejected("nx_explosion_info", explosion=ex) + assert not (await call("nx_list_explosions"))["explosions"] + receipt["groups"].append( + {"name": "preflight_rollback_view_guard_delete_stale", "passed": True} + ) + except Exception: + receipt["groups"].append( + {"name": "failure", "passed": False, "error": traceback.format_exc()} + ) + finally: + for p in (await call("nx_list_open_parts"))["parts"]: + if prefix in p["path"]: + await call("nx_close_part", part=p["part"]["id"], save=False) + if display: + await call("nx_open_part", path=display["path"]) + if work: + await call( + "nx_activate_part", part=work["part"]["id"], work=True, display=work == display + ) + after = (await call("nx_list_open_parts"))["parts"] + assert sorted(p["path"].casefold() for p in before) == sorted( + p["path"].casefold() for p in after + ) + assert not any(p["modified"] for p in after) + receipt["restored_parts"] = len(after) + (output / "explosion-validation.json").write_text(json.dumps(receipt, indent=2)) + assert all(g["passed"] for g in receipt["groups"]), receipt + print("PASS", len(receipt["groups"]), "groups; restored", len(after), "parts") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py new file mode 100644 index 0000000..eb6dc36 --- /dev/null +++ b/examples/validate_freeform_manufacturing.py @@ -0,0 +1,405 @@ +"""Public MCP acceptance for freeform, face editing and assembly documentation. + +Runs in disposable workspace parts, preserves previously saved session parts, +and downloads checksummed native artifacts. Requires interactive NX v2606. +""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "freeform-manufacturing-results")) + output.mkdir(parents=True, exist_ok=True) + prefix = "freeform-validation-" + uuid.uuid4().hex[:8] + receipt = {"fixture": prefix, "checks": [], "artifacts": [], "operations": []} + + def save(): + (output / "freeform-manufacturing-validation.json").write_text( + json.dumps(receipt, indent=2) + ) + + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(tool_name, **params): + response = await client.call_tool(tool_name, params) + receipt["operations"].append( + { + "tool": tool_name, + "error": response.isError, + "operation_id": response.structuredContent.get("operation_id"), + } + ) + save() + assert not response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def reject(tool_name, **params): + response = await client.call_tool(tool_name, params) + assert response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def artifact(meta, name): + data = bytearray() + while True: + chunk = await call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (output / name).write_bytes(data) + receipt["artifacts"].append({"file": name, "sha256": meta["sha256"], "size": len(data)}) + + async def new(name): + return await call("nx_create_part", path=prefix + "/" + name + ".prt", units="mm") + + async def nearest(owner, kind, coords, geometry_type="any"): + result = await call( + "nx_find_geometry", owner=owner, kind=kind, near=coords, geometry_type=geometry_type + ) + assert result["items"] + return result["items"][0]["object"]["id"] + + async def volume(body): + return (await call("nx_measure_volume", body=body))["volume_mm3"] + + async def block(height=5, circle=False): + sk = (await call("nx_create_sketch"))["object"]["id"] + if circle: + await call( + "nx_sketch_arc", + sketch_id=sk, + cx=0, + cy=0, + radius=5, + start_angle=0, + end_angle=360, + ) + else: + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 20, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sk) + return (await call("nx_extrude", sketch_id=sk, distance=height))["body"]["id"] + + async def spline(points, degree=1, method="through_points"): + return (await call("nx_spline", points=points, degree=degree, method=method))["curve"][ + "id" + ] + + async def mesh(x0=0, x1=20, curved=False): + primary = [] + for y in [0, 10]: + pts = ( + [[x0, y, 0], [x1, y, 0]] + if not curved + else [[x0, y, 0], [(x0 + x1) / 2, y, 0], [x1, y, 5]] + ) + primary.append( + [ + await spline( + pts, + degree=2 if curved else 1, + method="poles" if curved else "through_points", + ) + ] + ) + cross = [ + [await spline([[x0, 0, 0], [x0, 10, 0]])], + [await spline([[x1, 0, 5 if curved else 0], [x1, 10, 5 if curved else 0]])], + ] + return (await call("nx_surface_mesh", primary=primary, cross=cross))["body"]["id"] + + async def checked(name): + assert (await call("nx_model_health"))["healthy"] + receipt["checks"].append(name) + save() + + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before), "Save existing parts first" + original = next(p for p in before if p["work"]) + original_display = next(p for p in before if p["display"]) + try: + assert len((await client.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") + ) + await new("spline") + token = "spline-" + uuid.uuid4().hex + params = { + "points": [[0, 0, 0], [10, 5, 3], [20, -5, 6], [30, 0, 10]], + "operation_id": token, + } + first = await call("nx_spline", **params) + repeated = await call("nx_spline", **params) + assert first["feature"]["id"] == repeated["feature"]["id"] + edited = await call( + "nx_spline", + feature=first["feature"]["id"], + points=[[0, 0, 0], [10, 10, 3], [20, -10, 6], [30, 0, 15]], + ) + analysis = await call("nx_curve_analysis", curve=edited["curve"]["id"], samples=5) + assert math.dist(analysis["samples"][-1]["point"], [30, 0, 15]) < 1e-7 + await checked("associative_3d_spline_edit_and_idempotent_retry") + + await new("surfaces") + a, b = await mesh(), await mesh(20, 40) + edges = [await nearest(a, "edge", [20, 5, 0]), await nearest(b, "edge", [20, 5, 0])] + continuity = await call( + "nx_surface_continuity", first=edges[0], second=edges[1], samples=5 + ) + assert continuity["checks"] == {"G0": True, "G1": True, "G2": True} + sewn = await call("nx_sew", target=a, tools=[b]) + body = sewn["body"]["id"] + faces = (await call("nx_find_geometry", owner=body, kind="face", near=[10, 5, 0]))[ + "items" + ] + thick = await call( + "nx_thicken", faces=[x["object"]["id"] for x in faces], first_offset=2 + ) + assert math.isclose(await volume(thick["body"]["id"]), 800, rel_tol=1e-7) + await checked("native_mesh_sew_thicken_and_matching_surface_continuity") + + await new("trim") + body = await mesh() + boundary = await spline([[10, -1, 0], [10, 11, 0]]) + trimmed = await call( + "nx_trim_sheet", body=body, boundaries=[boundary], region_point=[5, 5, 0] + ) + bounds = await call("nx_get_bounding_box", body=trimmed["body"]["id"]) + receipt["trim_bounds"] = bounds + assert all( + math.isclose(v, expected, abs_tol=1e-6) + for v, expected in zip(bounds["dimensions"], [10, 10, 0], strict=True) + ) + await checked("native_sheet_trim") + + await new("bridge") + a, b = await mesh(), await mesh(30, 50) + first_edge, second_edge = ( + await nearest(a, "edge", [20, 5, 0]), + await nearest(b, "edge", [30, 5, 0]), + ) + gap = await call( + "nx_surface_continuity", first=first_edge, second=second_edge, samples=5 + ) + assert not gap["checks"]["G0"] and math.isclose(gap["maximum_gap"], 10, abs_tol=1e-7) + bridged = await call("nx_bridge_surface", first=first_edge, second=second_edge) + assert bridged["body_count"] == 1 + await checked("native_bridge_and_deliberate_gap_detection") + + await new("curvature") + a, b = await mesh(0, 20), await mesh(20, 40, curved=True) + result = await call( + "nx_surface_continuity", + first=await nearest(a, "edge", [20, 5, 0]), + second=await nearest(b, "edge", [20, 5, 0]), + samples=5, + curvature_tolerance=0.001, + ) + receipt["curvature_continuity"] = result + assert result["checks"] == {"G0": True, "G1": True, "G2": False} + await checked("tangent_but_curvature_discontinuous_surface_pair") + + await new("direct") + body = await block() + top = await nearest(body, "face", [10, 5, 5]) + await call("nx_edit_faces", faces=[top], action="move", direction=[0, 0, 1], distance=2) + assert math.isclose(await volume(body), 1400, rel_tol=1e-7) + await call( + "nx_edit_faces", + faces=[await nearest(body, "face", [10, 5, 7])], + action="offset", + distance=1, + ) + assert math.isclose(await volume(body), 1600, rel_tol=1e-7) + other = await block(height=10) + await call( + "nx_edit_faces", + faces=[await nearest(body, "face", [10, 5, 8])], + action="replace", + replacement=await nearest(other, "face", [10, 5, 10]), + ) + assert math.isclose(await volume(body), 2000, rel_tol=1e-7) + await checked("move_offset_replace_analytic_volumes") + + await new("heal") + body = await block() + await call( + "nx_hole", diameter=2, depth=5, x=10, y=5, z=5, body=body, direction=[0, 0, -1] + ) + cylinder = await nearest(body, "face", [11, 5, 2.5], "cylinder") + await call("nx_edit_faces", faces=[cylinder], action="heal") + assert math.isclose(await volume(body), 1000, rel_tol=1e-7) + face = await nearest(body, "face", [10, 5, 5]) + walls = await call("nx_wall_thickness", body=body, faces=[face]) + assert walls["measured_count"] == 9 + assert math.isclose(walls["minimum_sampled_thickness"], 5, abs_tol=1e-7) + draft = await call("nx_face_analysis", faces=[face], pull_direction=[0, 0, 1]) + assert all( + math.isclose(x["signed_draft_degrees"], 90, abs_tol=1e-7) for x in draft["samples"] + ) + datum = await call("nx_pmi_datum", faces=[face], letter="A", position=[25, 10, 5]) + fcf = await call( + "nx_pmi_fcf", + faces=[await nearest(body, "face", [10, 5, 5])], + characteristic="Parallelism", + tolerance=0.05, + position=[25, 20, 5], + datums=[datum["annotation"]["id"]], + ) + assert fcf["geometry_associated"] + await call( + "nx_pmi_fcf", + faces=[await nearest(body, "face", [10, 5, 5])], + characteristic="Flatness", + tolerance=0.1, + position=[25, 20, 5], + annotation=fcf["annotation"]["id"], + ) + await checked("delete_heal_wall_thickness_draft_and_native_pmi") + + for detailed in [False, True]: + await new("thread-" + str(detailed)) + body = await block() + await call( + "nx_hole", diameter=2, depth=5, x=10, y=5, z=5, body=body, direction=[0, 0, -1] + ) + baseline = await volume(body) + result = await call( + "nx_thread", + face=await nearest(body, "face", [11, 5, 2.5], "cylinder"), + start_face=await nearest(body, "face", [5, 5, 5]), + pitch=0.4, + major_diameter=2.4, + minor_diameter=1.9, + length=4, + detailed=detailed, + ) + assert result["internal"] and math.isclose(result["pitch"], 0.4, abs_tol=1e-8) + final = await volume(body) + assert final < baseline if detailed else math.isclose(final, baseline, rel_tol=1e-7) + await checked("native_" + ("detailed" if detailed else "symbolic") + "_thread") + + await new("prototype") + await block() + await call("nx_save_part") + await new("assembly") + for i in range(3): + await call( + "nx_add_component", + part_path=prefix + "/prototype.prt", + name="Cube" + str(i), + translation=[25 * i, 0, 0], + ) + assembled = sorted( + (await call("nx_list_components"))["components"], key=lambda c: c["translation"][0] + ) + explosion = (await call("nx_create_explosion", name="Service"))["object"]["id"] + await call( + "nx_edit_explosion", + explosion=explosion, + placements=[ + {"component": c["object"]["id"], "translation": [40 * i, 0, 0]} + for i, c in enumerate(assembled) + ], + ) + await call("nx_show_explosion", explosion=explosion) + starts = [ + await nearest(c["object"]["id"], "edge", [20 if i == 0 else 25, 5, 5]) + for i, c in enumerate(assembled[:2]) + ] + trace = await call( + "nx_explosion_trace", + explosion=explosion, + start_edge=starts[0], + end_edge=starts[1], + start_direction=[1, 0, 0], + end_direction=[-1, 0, 0], + ) + assert trace["traceline"]["kind"] == "traceline" + poses_before = (await call("nx_explosion_info", explosion=explosion))["items"] + animation = await call( + "nx_export_explosion_animation", + explosion=explosion, + path=prefix + "/animation.html", + frames=3, + ) + await artifact(animation, "animation.html") + assert len({f["sha256"] for f in animation["frames"]}) == 3 + assert (await call("nx_explosion_info", explosion=explosion))["items"] == poses_before + sheet = (await call("nx_create_drawing", name="Service", size="A3"))["object"]["id"] + bom = await call("nx_create_parts_list", drawing=sheet, position=[25, 250]) + assert bom["rows"] == [["1", "PROTOTYPE", "3"]] + assert (await call("nx_parts_list_info", parts_list=bom["parts_list"]["id"]))[ + "rows" + ] == bom["rows"] + await call( + "nx_add_component", + part_path=prefix + "/prototype.prt", + name="Cube3", + translation=[120, 0, 0], + ) + assert (await call("nx_update_parts_list", parts_list=bom["parts_list"]["id"]))[ + "rows" + ] == [["1", "PROTOTYPE", "4"]] + view = ( + await call( + "nx_add_base_view", + drawing=sheet, + scope="assembly", + explosion=explosion, + position=[170, 120], + ) + )["object"]["id"] + balloons = await call( + "nx_parts_list_balloons", parts_list=bom["parts_list"]["id"], view=view + ) + assert balloons["balloon_count"] >= 1 + pdf = await call("nx_export_drawing_pdf", path=prefix + "/service.pdf") + await artifact(pdf, "service.pdf") + await checked("native_bom_quantity_update_balloons_trace_animation_and_pdf") + receipt["passed"] = True + except Exception: + receipt["passed"] = False + receipt["error"] = traceback.format_exc() + raise + finally: + try: + await call("nx_open_part", path=original["path"], work=True, display=True) + fixtures = [ + p for p in (await call("nx_list_open_parts"))["parts"] if prefix in p["path"] + ] + for part in reversed(fixtures): + await call("nx_close_part", part=part["part"]["id"], save=True) + if original_display["path"] != original["path"]: + await call( + "nx_open_part", path=original_display["path"], work=False, display=True + ) + after = (await call("nx_list_open_parts"))["parts"] + assert {p["path"] for p in after} == {p["path"] for p in before} + assert not any(p["modified"] for p in after) + receipt["session_restored"] = True + finally: + save() + print(json.dumps({"passed": receipt["passed"], "checks": receipt["checks"]})) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_hard_geometry.py b/examples/validate_hard_geometry.py new file mode 100644 index 0000000..25f3ea2 --- /dev/null +++ b/examples/validate_hard_geometry.py @@ -0,0 +1,276 @@ +"""Bounded native acceptance for damaged STEP, topology edits and nested units. + +Set NX_MCP_URL, NX_VALIDATION_OUTPUT and NX_VENDOR_STEP (local public fixture). +Receipts describe tested cases; this is not general sheet-metal certification. +""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def run_suite(call, reject, artifact, upload, output): + prefix = "hard-geometry-" + uuid.uuid4().hex[:8] + receipt = {"fixture": prefix, "checks": []} + + def save(): + (output / "hard-geometry-validation.json").write_text(json.dumps(receipt, indent=2)) + + async def new(name, units="mm"): + await call("nx_create_part", path=prefix + "/" + name + ".prt", units=units) + + async def sketch(x, y): + s = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", sketch_id=s, corner1={"x": 0, "y": 0}, corner2={"x": x, "y": y} + ) + await call("nx_finish_sketch", sketch_id=s) + return s + + async def block(x, y, z): + return await call("nx_extrude", sketch_id=await sketch(x, y), distance=z) + + async def near(kind, point, **params): + return (await call("nx_find_geometry", kind=kind, near=point, **params))["items"][0][ + "object" + ]["id"] + + async def checked(name): + assert (await call("nx_model_health", scope="assembly"))["healthy"] + receipt["checks"].append(name) + save() + + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before) + original = next(p for p in before if p["work"]) + display = next(p for p in before if p["display"]) + try: + await new("damaged-step") + await block(10, 10, 10) + prior = await call("nx_measure_volume") + source = Path(os.environ["NX_VENDOR_STEP"]).read_bytes() + damaged = output / "damaged.step" + damaged.write_bytes(source[: len(source) // 3]) + await upload(damaged, prefix + "/damaged.step") + receipt["damaged_import"] = await reject( + "nx_import_geometry", path=prefix + "/damaged.step", flatten=True + ) + assert receipt["damaged_import"]["details"]["mutation_outcome"] == "rolled_back" + assert math.isclose( + (await call("nx_measure_volume"))["volume_mm3"], prior["volume_mm3"], rel_tol=1e-8 + ) + assert (await call("nx_get_bounding_box"))["body_count"] == 1 + await checked("truncated_public_vendor_step_rejected_without_geometry_change") + await new("heal") + solid = await block(20, 20, 5) + body = solid["body"]["id"] + await call("nx_hole", body=body, diameter=4, depth=5, x=10, y=10, z=5, direction=[0, 0, -1]) + hole = await near("face", [12, 10, 2.5], geometry_type="cylinder") + anchor = (await call("nx_geometry_anchor", object=hole))["anchor"] + receipt["heal"] = await call("nx_edit_faces", faces=[hole], action="heal") + assert math.isclose((await call("nx_measure_volume"))["volume_mm3"], 2000, rel_tol=1e-7) + receipt["removed_anchor"] = await reject("nx_resolve_geometry_anchor", anchor=anchor) + await checked("hole_face_healed_analytic_volume_deleted_anchor_rejected") + await new("inch", units="inch") + await block(1, 0.5, 0.25) + await call("nx_save_part") + await new("sub") + await call( + "nx_add_component", + part_path=prefix + "/inch.prt", + name="INCH", + translation=[10, 20, 0], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + await call("nx_save_part") + await new("top") + await call( + "nx_add_component", + part_path=prefix + "/sub.prt", + name="SUB", + translation=[100, 0, 0], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + bounds = await call("nx_get_bounding_box", scope="assembly", precision="exact") + for k, expected in [("min", [54.6, -2.7, 0]), ("max", [80, 10, 6.35])]: + assert all( + math.isclose(a, b, abs_tol=1e-6) for a, b in zip(bounds[k], expected, strict=True) + ), ( + k, + bounds, + ) + assert math.isclose( + (await call("nx_measure_volume", scope="assembly"))["volume_mm3"], + 25.4 * 12.7 * 6.35, + rel_tol=1e-7, + ) + # A second rotated nested occurrence has 5 mm exact clearance in Z. + await call( + "nx_add_component", + part_path=prefix + "/sub.prt", + name="SUB2", + translation=[100, 0, 11.35], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + roots = [ + c + for c in (await call("nx_list_components"))["components"] + if c["name"] in ["SUB", "SUB2"] + ] + roots.sort(key=lambda component: component["name"]) + pair = {"obj1": roots[0]["object"]["id"], "obj2": roots[1]["object"]["id"]} + receipt["nested"] = { + "bounds": bounds, + "clearance": await call("nx_measure_distance", **pair), + "separate": await call("nx_check_interference", **pair), + } + assert math.isclose(receipt["nested"]["clearance"]["distance"], 5, abs_tol=1e-6) + await call( + "nx_set_component_transform", + component=roots[1]["object"]["id"], + translation=[100, 0, 5.35], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + receipt["nested"]["overlap"] = await call("nx_check_interference", **pair) + assert receipt["nested"]["separate"]["counts"]["clear"] == 1 + overlap = receipt["nested"]["overlap"]["pairs"][0] + assert overlap["classification"] == "penetration" + assert math.isclose(overlap["interference_volume_mm3"], 25.4 * 12.7, rel_tol=1e-7) + await checked("two_level_rotated_mixed_unit_bounds_volume_clearance_interference") + await new("three-wall") + await call("nx_sheet_metal_context") + await call("nx_set_sheet_metal_defaults", thickness=2, bend_radius=3, neutral_factor=0.33) + tab = await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": await sketch(100, 80), "thickness": 2}, + ) + body = tab["body"]["id"] + edges = [ + await near("edge", point, owner=body) for point in [[50, 0, 0], [0, 40, 0], [50, 80, 0]] + ] + await call( + "nx_sheet_metal_feature", + operation="flange", + parameters={ + "flanges": [ + { + "edges": [edge], + "length": 20, + "angle": 90, + "length_reference": "Inside", + "miter": True, + } + for edge in edges + ] + }, + ) + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + assert info["bend_count"] == 3 + flat = await call( + "nx_sheet_metal_feature", + operation="flat_pattern", + parameters={ + "upward_face": await near("face", [50, 40, 0], owner=body), + "x_axis_edge": await near("edge", [100, 40, 0], owner=body), + "associative": True, + }, + ) + receipt["flat"] = await artifact( + await call( + "nx_export_flat_pattern", + flat_pattern=flat["feature"]["id"], + path=prefix + "/three-wall.dxf", + ), + "three-wall.dxf", + ) + await call("nx_set_view", orientation="isometric") + await call("nx_fit_view") + receipt["render"] = await artifact( + await call("nx_render_view", path=prefix + "/three-wall.png"), "three-wall.png" + ) + await checked("three_adjacent_mitered_walls_native_health_and_flat_export") + receipt["passed"] = True + except Exception: + receipt["passed"] = False + receipt["error"] = traceback.format_exc() + raise + finally: + try: + await call("nx_open_part", path=original["path"]) + while True: + parts = [ + p for p in (await call("nx_list_open_parts"))["parts"] if prefix in p["path"] + ] + if not parts: + break + part = next((p for p in parts if p["path"].endswith("top.prt")), parts[0]) + await call("nx_close_part", part=part["part"]["id"], save=True) + if display["path"] != original["path"]: + await call("nx_open_part", path=display["path"], work=False, display=True) + after = (await call("nx_list_open_parts"))["parts"] + assert {p["path"] for p in before} == {p["path"] for p in after} + assert not any(p["modified"] for p in after) + receipt["session_restored"] = True + finally: + save() + return receipt + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "hard-geometry-results")) + output.mkdir(parents=True, exist_ok=True) + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(tool_name, **params): + response = await client.call_tool(tool_name, params) + assert not response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def reject(tool_name, **params): + response = await client.call_tool(tool_name, params) + assert response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def artifact(meta, name): + data = bytearray() + while True: + chunk = await call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (output / name).write_bytes(data) + return {"file": name, "size": len(data), "sha256": meta["sha256"]} + + async def upload(source, path): + data = source.read_bytes() + for offset in range(0, len(data), 256 * 1024): + await call( + "nx_upload_file", + path=path, + data_base64=base64.b64encode(data[offset : offset + 256 * 1024]).decode(), + offset=offset, + total_size=len(data), + sha256=hashlib.sha256(data).hexdigest(), + ) + + result = await run_suite(call, reject, artifact, upload, output) + print(json.dumps({"passed": result["passed"], "checks": result["checks"]})) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py new file mode 100644 index 0000000..7fad5f4 --- /dev/null +++ b/examples/validate_project_folders.py @@ -0,0 +1,155 @@ +"""Live folder-path acceptance on disposable NX parts; preserves the loaded session.""" + +import asyncio +import base64 +import hashlib +import json +import os +import uuid +from pathlib import Path, PureWindowsPath + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +out = Path(os.environ.get("NX_VALIDATION_OUTPUT", "folder-validation-output")) + + +async def main(): + out.mkdir(parents=True, exist_ok=True) + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (r, w, _), + ClientSession(r, w) as c, + ): + await c.initialize() + + async def call(name, **p): + response = await c.call_tool(name, p) + if response.isError: + raise RuntimeError((name, response.structuredContent)) + return response.structuredContent + + async def rejected(name, **p): + response = await c.call_tool(name, p) + assert response.isError, (name, response) + return response.structuredContent + + before = await call("nx_list_open_parts") + assert not any(p["modified"] for p in before["parts"]), ( + "Save the current session before this acceptance run" + ) + active = next(p for p in before["parts"] if p["work"]) + checks = [] + prefix = "folder-validation-" + uuid.uuid4().hex[:10] + try: + assert len((await c.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") + ) + info = await call("nx_workspace_info") + root = PureWindowsPath(info["root"]) + + def absolute(rel): + return str(root / rel) + + checks.append({"name": "workspace_discovery", "passed": True}) + directory = prefix + "/exports" + assert (await call("nx_create_directory", path=absolute(directory)))["created"] + assert not (await call("nx_create_directory", path=directory))["created"] + checks.append({"name": "directory_creation_absolute_relative_retry", "passed": True}) + source = prefix + "/parts/folder_base.prt" + revision = prefix + "/revisions/r02/folder_base_r02.prt" + await call("nx_create_part", path=source, units="mm") + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sk) + await call("nx_extrude", sketch_id=sk, distance=10) + volume = await call("nx_measure_volume") + assert abs(volume["volume_mm3"] - 1000) < 1e-5, volume + await call("nx_save_part") + original = (await call("nx_workspace_list", path=prefix + "/parts"))["entries"][0] + checks.append({"name": "nested_part_creation_save_1000mm3", "passed": True}) + saved = await call("nx_save_as", path=absolute(revision)) + assert PureWindowsPath(saved["path"]) == root / revision + assert abs((await call("nx_measure_volume"))["volume_mm3"] - 1000) < 1e-5 + checks.append( + { + "name": "absolute_save_as_creates_missing_parents_preserves_volume", + "passed": True, + } + ) + await rejected("nx_save_as", path=source) + assert (await call("nx_workspace_list", path=prefix + "/parts"))["entries"][0][ + "sha256" + ] == original["sha256"] + checks.append({"name": "save_as_no_overwrite", "passed": True}) + await call("nx_close_part", save=False) + reopened = await call("nx_open_part", path=absolute(revision)) + assert not reopened["already_loaded"] + assert (await call("nx_open_part", path=revision))["already_loaded"] + assert abs((await call("nx_measure_volume"))["volume_mm3"] - 1000) < 1e-5 + await call("nx_open_part", path=absolute(source)) + assert (await call("nx_open_part", path=absolute(revision)))["already_loaded"] + checks.append({"name": "absolute_reopen_relative_reuse_and_activation", "passed": True}) + await call("nx_export_step", path=absolute(directory + "/folder_base.step")) + listing = await call("nx_workspace_list", path=directory) + assert any( + PureWindowsPath(e["path"]).name == "folder_base.step" for e in listing["entries"] + ) + artifact = await call( + "nx_download_file", path=absolute(directory + "/folder_base.step") + ) + data = base64.b64decode(artifact["data_base64"]) + assert artifact["eof"] and hashlib.sha256(data).hexdigest() == artifact["sha256"] + copied = await call( + "nx_upload_file", + path=absolute(prefix + "/vendor/copy.step"), + data_base64=artifact["data_base64"], + sha256=artifact["sha256"], + total_size=len(data), + ) + assert copied["committed"] + checks.append( + { + "name": "nested_absolute_step_export_download_upload_integrity", + "passed": True, + } + ) + for path in [ + "../outside.prt", + str(root.parent / "outside.prt"), + str(root / ".NX-MCP" / "state.json"), + ]: + await rejected("nx_open_part", path=path) + checks.append({"name": "outside_and_reserved_paths_rejected", "passed": True}) + finally: + parts = (await call("nx_list_open_parts"))["parts"] + for p in parts: + if prefix in p["path"]: + await call("nx_close_part", part=p["part"]["id"], save=False) + await call("nx_open_part", path=active["path"]) + after = await call("nx_list_open_parts") + + def norm(p): + return str(PureWindowsPath(p["path"])).casefold() + + assert sorted(map(norm, before["parts"])) == sorted(map(norm, after["parts"])) + assert not any(p["modified"] for p in after["parts"]) + (out / "path-validation.json").write_text( + json.dumps( + { + "checks": checks, + "passed": len(checks), + "fixture": prefix, + "restored_parts": len(after["parts"]), + }, + indent=2, + ) + ) + print(json.dumps({"passed": len(checks), "restored_parts": len(after["parts"])})) + + +asyncio.run(main()) diff --git a/examples/validate_release_engineering.py b/examples/validate_release_engineering.py new file mode 100644 index 0000000..4cf69c1 --- /dev/null +++ b/examples/validate_release_engineering.py @@ -0,0 +1,745 @@ +"""Native release acceptance: drawings, propagation, vendor repair and mixed units. + +NX_VENDOR_STEP selects a user-owned local STEP fixture, never redistributed. +All created parts are isolated; durable receipts record failure and restoration. +""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +def dxf_extents(path): + """Read LINE entity bounds for this rectangular ASCII DXF fixture.""" + lines = path.read_text(errors="strict").splitlines() + pairs = [(int(lines[i]), lines[i + 1].strip()) for i in range(0, len(lines) - 1, 2)] + points, record, entity, section = [], {}, None, None + for code, value in pairs + [(0, "EOF")]: + if code == 2 and entity == "SECTION": + section = value + if code == 0: + if entity == "LINE" and section == "ENTITIES": + points.extend( + [(float(record[10]), float(record[20])), (float(record[11]), float(record[21]))] + ) + if value == "ENDSEC": + section = None + entity, record = value, {} + else: + record[code] = value + assert points, "No LINE geometry in the native rectangular DXF" + return sorted(max(p[i] for p in points) - min(p[i] for p in points) for i in (0, 1)) + + +async def run_suite(call, reject, artifact, upload, output): + prefix = "release-engineering-" + uuid.uuid4().hex[:8] + receipt = {"fixture": prefix, "checks": [], "artifacts": []} + + def save(): + (output / "release-engineering-validation.json").write_text(json.dumps(receipt, indent=2)) + + async def new(name, units="mm"): + return await call("nx_create_part", path=prefix + "/" + name + ".prt", units=units) + + async def near(kind, point, **params): + return (await call("nx_find_geometry", kind=kind, near=point, **params))["items"][0][ + "object" + ]["id"] + + async def block(x=50, y=30, z=5, radius=None): + sk = (await call("nx_create_sketch"))["object"]["id"] + if radius is None: + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": x, "y": y}, + ) + else: + await call( + "nx_sketch_arc", + sketch_id=sk, + cx=0, + cy=0, + radius=radius, + start_angle=0, + end_angle=360, + ) + await call("nx_finish_sketch", sketch_id=sk) + return await call("nx_extrude", sketch_id=sk, distance=z) + + async def checked(name): + assert (await call("nx_model_health"))["healthy"] + receipt["checks"].append(name) + save() + + async def download(meta, name): + receipt["artifacts"].append(await artifact(meta, name)) + save() + + before = (await call("nx_list_open_parts"))["parts"] + assert before and not any(p["modified"] for p in before), "Save existing parts first" + original = next(p for p in before if p["work"]) + display = next(p for p in before if p["display"]) + try: + await new("drawing") + solid = await block() + body = solid["body"]["id"] + await call("nx_hole", body=body, diameter=8, depth=5, x=25, y=15, z=5, direction=[0, 0, -1]) + await call("nx_save_part") + face = await near("face", [10, 10, 5], geometry_type="plane") + anchor = (await call("nx_geometry_anchor", object=face))["anchor"] + sheet = (await call("nx_create_drawing", name="Service", scale=2))["object"]["id"] + view = ( + await call( + "nx_add_base_view", drawing=sheet, body=body, view="top", position=[100, 180] + ) + )["object"]["id"] + assert (await call("nx_drawing_view_info", view=view))["scale"] == 2 + edited = await call( + "nx_edit_drawing_view", + view=view, + scale=1.5, + position=[105, 180], + style={ + "hidden_lines": True, + "hidden_font": 2, + "visible_font": 1, + "construction_geometry": False, + }, + ) + assert edited["scale"] == 1.5 and edited["position"] == [105, 180] + revision_args = { + "drawing": sheet, + "kind": "revision", + "rows": [["REV", "DESCRIPTION"], ["A", "Initial"]], + "widths": [20, 70], + "position": [20, 275], + } + rev = await call("nx_drawing_table", **revision_args) + revision_args["rows"].append(["B", "Documentation verified"]) + updated = await call("nx_drawing_table", **revision_args, table=rev["table"]["id"]) + assert updated["rows"] == revision_args["rows"] + title = await call( + "nx_drawing_table", + drawing=sheet, + kind="title_block", + rows=[["TITLE", "Service fixture"], ["PART", "DEV13"], ["SHEET", "1 / 1"]], + widths=[25, 80], + position=[395, 20], + ) + assert title["native_title_block"] == "TitleBlock" + edited_title = await call( + "nx_drawing_table", + drawing=sheet, + kind="title_block", + rows=[["TITLE", "Service fixture"], ["PART", "DEV13-R1"], ["SHEET", "1 / 1"]], + widths=[25, 80], + position=[395, 20], + table=title["table"]["id"], + ) + assert edited_title["rows"][1][1] == "DEV13-R1" + detail = await call( + "nx_add_detail_drawing_view", + parent_view=view, + center=[25, 15, 5], + radius=8, + position=[230, 180], + scale=3, + ) + assert detail["scale"] == 3 and detail["inside_sheet"] + edge = await near("edge", [25, 0, 5]) + edge = await near("edge", [29, 15, 5], geometry_type="circle") + section = await call( + "nx_add_section_drawing_view", + parent_view=view, + cut_object=edge, + position=[105, 90], + cut_association="arc_center", + step_direction=[1, 0, 0], + arrow_direction=[0, 1, 0], + scale=1, + ) + assert section["native_type"] == "SectionView" and section["scale"] == 1 + edge = await near("edge", [25, 0, 5]) + dim = await call( + "nx_add_dimension", view=view, object1=edge, dim_type="horizontal", origin=[105, 220] + ) + assert math.isclose(dim["measured_value"], 50, abs_tol=1e-6) + formatted = await call( + "nx_edit_dimension_format", + dimension=dim["object"]["id"], + decimal_places=2, + trailing_zeros=True, + units="mm", + tolerance_type="bilateral", + upper_tolerance=0.05, + lower_tolerance=-0.02, + tolerance_decimal_places=2, + ) + assert math.isclose(formatted["computed_value"], 50, abs_tol=1e-6) + assert math.isclose(formatted["upper_tolerance"], 0.05, abs_tol=1e-9) + assert math.isclose(formatted["lower_tolerance"], -0.02, abs_tol=1e-9) + await download( + await call("nx_export_planar_dxf", source=face, path=prefix + "/face.dxf"), "face.dxf" + ) + await download( + await call("nx_export_drawing_pdf", path=prefix + "/service.pdf"), "service.pdf" + ) + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=prefix + "/drawing.prt") + assert (await call("nx_resolve_geometry_anchor", anchor=anchor))["object"]["kind"] == "face" + reopened_sheet = (await call("nx_list_drawings"))["sheets"][0] + annotations = (await call("nx_list_annotations"))["items"] + reopened_title = next(a for a in annotations if a.get("table_kind") == "title_block") + revised = await call( + "nx_drawing_table", + drawing=reopened_sheet["object"]["id"], + kind="title_block", + rows=[["TITLE", "Service fixture"], ["PART", "DEV13-R2"], ["SHEET", "1 / 1"]], + widths=[25, 80], + position=[395, 20], + table=reopened_title["object"]["id"], + ) + assert revised["rows"][1][1] == "DEV13-R2" + base_info = next( + v for v in reopened_sheet["views"] if v["name"] == edited["object"]["name"] + ) + await call("nx_edit_drawing_view", view=base_info["object"]["id"], position=[110, 180]) + await call("nx_edit_drawing_view", view=base_info["object"]["id"], position=[105, 180]) + await download( + await call("nx_export_drawing_pdf", path=prefix + "/service-reopened.pdf"), + "service-reopened.pdf", + ) + await checked( + "native_view_edits_section_detail_dimension_title_revision_pdf_and_anchor_reopen" + ) + + await new("inch-part", "inch") + solid = await block(x=2, y=1, z=0.25) + bounds = await call("nx_get_bounding_box") + assert bounds["units"] == "inch" and math.isclose(bounds["max"][0], 2) + assert math.isclose( + (await call("nx_measure_volume", body=solid["body"]["id"]))["volume_mm3"], + 0.5 * 25.4**3, + rel_tol=1e-7, + ) + for units, pos in [("mm", [100, 100]), ("in", [4, 4])]: + sheet = (await call("nx_create_drawing", name=units, size="A4", units=units))["object"][ + "id" + ] + v = ( + await call( + "nx_add_base_view", + drawing=sheet, + body=solid["body"]["id"], + view="top", + position=pos, + ) + )["object"]["id"] + projected = await call( + "nx_add_projection_view", + base_view=v, + direction="right", + spacing=80 if units == "mm" else 3, + ) + projected_info = await call("nx_drawing_view_info", view=projected["object"]["id"]) + assert math.isclose( + projected_info["position"][0], pos[0] + (80 if units == "mm" else 3), abs_tol=1e-6 + ) + assert projected_info["inside_sheet"] + info = await call("nx_drawing_view_info", view=v) + assert info["units"] == units and info["inside_sheet"] + assert (await call("nx_list_drawings"))["sheets"][-1]["units"] == units + await download( + await call("nx_export_drawing_pdf", path=prefix + "/mixed-units.pdf"), "mixed-units.pdf" + ) + await call("nx_save_part") + await checked("inch_volume_and_metric_inch_sheets_in_inch_part") + + # Explicit standards from the installed NX table, with both hands. + receipt["threads"] = [] + for standard, size, units, diameter, length in [ + ("Metric Fine", "M6 x 0.75", "mm", 6, 8), + ("Inch UNC", "1/4-20", "inch", 0.25, 0.4), + ]: + rows = (await call("nx_thread_catalog", standard=standard, size=size))["items"] + row = next(r for r in rows if r["Method"] == "CUT") + for left in [False, True]: + await new("thread-" + str(len(receipt["threads"])), units) + await block(z=length * 1.5, radius=diameter / 2) + cylinder = await near( + "face", [diameter / 2, 0, length / 2], geometry_type="cylinder" + ) + start = await near("face", [0, 0, length * 1.5], geometry_type="plane") + thread = await call( + "nx_standard_thread", + face=cylinder, + start_face=start, + standard=standard, + size=size, + length=length, + method=row["Method"], + radial_engage=row["RadialEngage"], + left_hand=left, + detailed=True, + ) + assert thread["standard"] == standard and thread["size"] == size + assert math.isclose(thread["pitch"], 0.75 if units == "mm" else 0.05, rel_tol=1e-7) + assert math.isclose(thread["major_diameter"], diameter, rel_tol=1e-6) + receipt["threads"].append( + { + k: thread[k] + for k in [ + "standard", + "size", + "pitch", + "major_diameter", + "minor_diameter", + "representation", + ] + } + | {"left_hand": left, "part_units": units} + ) + await checked("thread_" + standard + ("_left" if left else "_right")) + + await new("mixed-assembly") + await call("nx_add_component", part_path=prefix + "/inch-part.prt", name="Imperial") + mixed_bounds = await call("nx_get_bounding_box", scope="assembly", precision="exact") + assert all( + math.isclose(a, b, abs_tol=1e-6) + for a, b in zip(mixed_bounds["dimensions"], [50.8, 25.4, 6.35], strict=True) + ) + assert math.isclose( + (await call("nx_measure_volume", scope="assembly"))["volume_mm3"], + 8193.532, + rel_tol=1e-7, + ) + await call( + "nx_add_component", + part_path=prefix + "/drawing.prt", + name="Metric", + translation=[0, 0, 20], + ) + mixed_components = (await call("nx_list_components"))["components"] + distance = await call( + "nx_measure_distance", + obj1=mixed_components[0]["object"]["id"], + obj2=mixed_components[1]["object"]["id"], + ) + assert math.isclose(distance["distance"], 13.65, abs_tol=1e-6) + receipt["mixed_assembly"] = {"bounds": mixed_bounds, "clearance": distance} + await checked("mixed_unit_component_bounds_volume_and_clearance") + + await new("prototype") + await block() + await call("nx_save_part") + await new("replacement") + await block(z=10) + await call("nx_save_part") + await new("assembly") + for name, z in [("Base", 0), ("Lid", 20)]: + await call( + "nx_add_component", + part_path=prefix + "/prototype.prt", + name=name, + translation=[0, 0, z], + ) + components = sorted( + (await call("nx_list_components"))["components"], key=lambda c: c["translation"][2] + ) + for c in components: + await call("nx_assembly_constraint", constraint_type="fix", component=c["object"]["id"]) + ex = (await call("nx_create_explosion", name="Service", scale=2))["object"]["id"] + await call( + "nx_edit_explosion", + explosion=ex, + placements=[{"component": components[1]["object"]["id"], "translation": [0, 0, 60]}], + ) + edges = [ + await near("edge", [25, 0, z + 5], owner=c["object"]["id"]) + for c, z in zip(components, [0, 20], strict=True) + ] + await call("nx_explosion_trace", explosion=ex, start_edge=edges[0], end_edge=edges[1]) + sheet = (await call("nx_create_drawing", name="Assembly"))["object"]["id"] + bom = await call("nx_create_parts_list", drawing=sheet, position=[20, 270]) + view = ( + await call( + "nx_add_base_view", + drawing=sheet, + scope="assembly", + view="front", + position=[180, 150], + explosion=ex, + ) + )["object"]["id"] + balloons = await call( + "nx_parts_list_balloons", parts_list=bom["parts_list"]["id"], view=view + ) + edge = await near("edge", [0, 0, 2.5], owner=components[0]["object"]["id"]) + dimension = await call( + "nx_add_dimension", view=view, object1=edge, dim_type="vertical", origin=[120, 150] + ) + assert math.isclose(dimension["measured_value"], 5, abs_tol=1e-6) + initial = await call("nx_update_assembly_documentation") + assert initial["parts_lists"][0]["row_count"] == 1 + + await call("nx_save_part") + await call("nx_open_part", path=prefix + "/prototype.prt") + feature = next( + f + for f in (await call("nx_list_features"))["objects"] + if f["journal_id"].startswith("EXTRUDE") + ) + await call("nx_edit_feature", name=feature["id"], params={"distance": 8}) + await call("nx_save_part") + await call("nx_open_part", path=prefix + "/assembly.prt") + resized = await call("nx_update_assembly_documentation") + assert all(c["solver_status"] == "Solved" for c in resized["constraints"]) + receipt["assembly_probe"] = { + "initial": initial, + "resized": resized, + "bounds": await call("nx_get_bounding_box", scope="assembly"), + } + save() + dimensions = (await call("nx_list_dimensions"))["dimensions"] + assert len(dimensions) == 1 and math.isclose( + dimensions[0]["computed_value"], 8, abs_tol=1e-6 + ) + assert math.isclose( + (await call("nx_get_bounding_box", scope="assembly"))["max"][2], 28, abs_tol=1e-6 + ) + components = sorted( + (await call("nx_list_components"))["components"], key=lambda c: c["translation"][2] + ) + gap = await call( + "nx_measure_distance", + obj1=components[0]["object"]["id"], + obj2=components[1]["object"]["id"], + ) + assert math.isclose(gap["distance"], 12, abs_tol=1e-6) + annotations = (await call("nx_list_annotations"))["items"] + line = next(a for a in annotations if a["native_type"] == "AutomaticTraceline") + receipt["trace_after_resize"] = line + save() + assert math.isclose(line["start"][2], 8, abs_tol=1e-6) and math.isclose( + line["end"][2], 68, abs_tol=1e-6 + ) + # Replace after explicitly removing old prototype-edge trace anchors. + await call("nx_edit_annotation", annotation=line["object"]["id"], delete=True) + base = next(c for c in components if c["translation"][2] == 0) + await call( + "nx_component_action", + component=base["object"]["id"], + action="replace", + part_path=prefix + "/replacement.prt", + ) + replaced = await call("nx_update_assembly_documentation") + assert replaced["parts_lists"][0]["row_count"] == 2 + retained = (await call("nx_list_dimensions"))["dimensions"][0] + assert retained["retained"] and not replaced["documentation_complete"] + assert replaced["warnings"] + components = sorted( + (await call("nx_list_components"))["components"], key=lambda c: c["translation"][2] + ) + # Replacement invalidates original occurrence callouts; recreate explicitly. + for balloon in balloons["balloons"]: + await call("nx_edit_annotation", annotation=balloon["id"], delete=True) + await call("nx_parts_list_balloons", parts_list=bom["parts_list"]["id"], view=view) + replacement_edge = await near("edge", [0, 0, 5], owner=components[0]["object"]["id"]) + rebound = await call( + "nx_add_dimension", + view=view, + object1=replacement_edge, + dim_type="vertical", + origin=[120, 150], + dimension=retained["object"]["id"], + ) + assert rebound["edited"] and math.isclose(rebound["measured_value"], 10, abs_tol=1e-6) + assert not (await call("nx_list_dimensions"))["dimensions"][0]["retained"] + gap = await call( + "nx_measure_distance", + obj1=components[0]["object"]["id"], + obj2=components[1]["object"]["id"], + ) + assert math.isclose(gap["distance"], 10, abs_tol=1e-6) + assert sorted(c["translation"][2] for c in components) == [0, 20] + edges = [ + await near( + "edge", + [25, 0, c["translation"][2] + (10 if c["translation"][2] == 0 else 8)], + owner=c["object"]["id"], + ) + for c in components + ] + ex = (await call("nx_list_explosions"))["explosions"][0]["object"]["id"] + await call("nx_explosion_trace", explosion=ex, start_edge=edges[0], end_edge=edges[1]) + final = await call("nx_update_assembly_documentation") + assert final["health"]["healthy"] and final["documentation_complete"] + await download( + await call("nx_export_drawing_pdf", path=prefix + "/assembly.pdf"), "assembly.pdf" + ) + receipt["assembly"] = { + "initial": initial, + "resized": resized, + "replaced": replaced, + "final": final, + "minimum_clearance": gap, + } + await checked( + "prototype_resize_replace_mates_clearance_explosion_trace_BOM_and_drawing_propagation" + ) + + # Two opposite partial-width bends exercise square and round reliefs. + await new("sheet-channel") + await call("nx_sheet_metal_context") + await call("nx_set_sheet_metal_defaults", thickness=2, bend_radius=3, neutral_factor=0.33) + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 100, "y": 80}, + ) + await call("nx_finish_sketch", sketch_id=sk) + tab = await call( + "nx_sheet_metal_feature", operation="tab", parameters={"section": sk, "thickness": 2} + ) + body = tab["body"]["id"] + flange_edges = [await near("edge", [50, y, 0], owner=body) for y in [0, 80]] + flanges = [] + for edge, length, relief in zip(flange_edges, [20, 25], ["Square", "Round"], strict=True): + flanges.append( + { + "edges": [edge], + "length": length, + "length_reference": "Inside", + "angle": 90, + "width_option": "AtCenter", + "width": 60, + "bend_options": { + "bend_relief_type": relief, + "use_global_relief_width": False, + "bend_relief_width": 1, + "use_global_relief_depth": False, + "bend_relief_depth": 3, + }, + } + ) + await call("nx_sheet_metal_feature", operation="flange", parameters={"flanges": flanges}) + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + assert info["bend_count"] == 2 and math.isclose(info["thickness"], 2) + assert all( + math.isclose(b["inner_radius"], 3) and math.isclose(b["angle_degrees"], 90) + for b in info["bends"] + ) + flat = await call( + "nx_sheet_metal_feature", + operation="flat_pattern", + parameters={ + "upward_face": await near("face", [50, 40, 0], owner=body), + "x_axis_edge": await near("edge", [0, 40, 0], owner=body), + "associative": True, + }, + ) + await download( + await call( + "nx_export_flat_pattern", + flat_pattern=flat["feature"]["id"], + path=prefix + "/channel.dxf", + ), + "channel.dxf", + ) + actual = dxf_extents(output / "channel.dxf") + expected = sorted([100, 80 + 20 + 25 + 4 - 4 * 5 + math.pi * (3 + 0.33 * 2)]) + assert all( + math.isclose(a, b, abs_tol=1e-4) for a, b in zip(actual, expected, strict=True) + ), (actual, expected) + receipt["sheet_channel"] = { + "bend_count": info["bend_count"], + "developed_dimensions": actual, + "analytic_dimensions": expected, + "reliefs": ["Square", "Round"], + } + await checked("two_bend_partial_width_channel_square_round_reliefs_analytic_flat_pattern") + await new("sheet-corner") + await call("nx_sheet_metal_context") + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 100, "y": 80}, + ) + await call("nx_finish_sketch", sketch_id=sk) + body = ( + await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": sk, "thickness": 2}, + ) + )["body"]["id"] + edges = [await near("edge", p, owner=body) for p in [[50, 0, 0], [0, 40, 0]]] + await call( + "nx_sheet_metal_feature", + operation="flange", + parameters={ + "flanges": [ + { + "edges": [edge], + "length": 20, + "angle": 90, + "length_reference": "Inside", + "miter": True, + } + for edge in edges + ] + }, + ) + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + assert info["bend_count"] == 2 + flat = await call( + "nx_sheet_metal_feature", + operation="flat_pattern", + parameters={ + "upward_face": await near("face", [50, 40, 0], owner=body), + "x_axis_edge": await near("edge", [50, 80, 0], owner=body), + "associative": True, + }, + ) + await download( + await call( + "nx_export_flat_pattern", + flat_pattern=flat["feature"]["id"], + path=prefix + "/corner.dxf", + ), + "corner.dxf", + ) + await call("nx_set_view", orientation="isometric") + await call("nx_fit_view") + await download( + await call("nx_render_view", path=prefix + "/corner.png", style="shaded_with_edges"), + "corner.png", + ) + await checked("adjacent_mitered_flanges_native_validity_and_flat_export") + + # Imported vendor topology is user-provided and stays out of the repository. + vendor = Path(os.environ["NX_VENDOR_STEP"]) + await new("vendor") + vendor_path = prefix + "/vendor.step" + await upload(vendor, vendor_path) + imported = await call("nx_import_geometry", path=vendor_path, flatten=True) + receipt["vendor"] = { + "sha256": hashlib.sha256(vendor.read_bytes()).hexdigest(), + "import": imported, + } + assert (await call("nx_model_health"))["healthy"] + await call("nx_save_part") + face = await near("face", [0, 0, 0], geometry_type="plane") + anchor = (await call("nx_geometry_anchor", object=face))["anchor"] + checkpoint = (await call("nx_checkpoint", label="vendor repair"))["checkpoint_id"] + prior = await call("nx_get_bounding_box") + # Large offsets may be invalid for this topology: require a clear outcome, + # then rollback any accepted edit and compare geometry independently. + repaired = await call("nx_edit_faces", faces=[face], action="offset", distance=0.01) + assert (await call("nx_model_health"))["healthy"] + receipt["vendor"]["repair"] = repaired + await call("nx_rollback", checkpoint_id=checkpoint) + after = await call("nx_get_bounding_box") + assert all( + math.isclose(a, b, abs_tol=1e-6) + for k in ["min", "max"] + for a, b in zip(prior[k], after[k], strict=True) + ) + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=prefix + "/vendor.prt") + assert (await call("nx_resolve_geometry_anchor", anchor=anchor))["object"]["kind"] == "face" + await call("nx_fit_view") + await download(await call("nx_render_view", path=prefix + "/vendor.png"), "vendor.png") + await checked("vendor_step_native_offset_repair_rollback_and_persistent_face_reopen") + receipt["passed"] = True + except Exception: + receipt["passed"] = False + receipt["error"] = traceback.format_exc() + raise + finally: + try: + await call("nx_open_part", path=original["path"]) + while True: + parts = [ + p for p in (await call("nx_list_open_parts"))["parts"] if prefix in p["path"] + ] + if not parts: + break + p = next((p for p in parts if p["path"].endswith("assembly.prt")), parts[0]) + await call("nx_close_part", part=p["part"]["id"], save=True) + if display["path"] != original["path"]: + await call("nx_open_part", path=display["path"], work=False, display=True) + after = (await call("nx_list_open_parts"))["parts"] + assert {p["path"] for p in before} == {p["path"] for p in after} + assert not any(p["modified"] for p in after) + receipt["session_restored"] = True + finally: + save() + return receipt + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "release-engineering-results")) + output.mkdir(parents=True, exist_ok=True) + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(tool_name, **params): + response = await client.call_tool(tool_name, params) + assert not response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def reject(tool_name, **params): + response = await client.call_tool(tool_name, params) + assert response.isError, (tool_name, response.structuredContent) + return response.structuredContent + + async def artifact(meta, name): + data = bytearray() + while True: + chunk = await call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (output / name).write_bytes(data) + return {"file": name, "size": len(data), "sha256": meta["sha256"]} + + async def upload(source, path): + data = source.read_bytes() + for offset in range(0, len(data), 256 * 1024): + await call( + "nx_upload_file", + path=path, + data_base64=base64.b64encode(data[offset : offset + 256 * 1024]).decode(), + offset=offset, + total_size=len(data), + sha256=hashlib.sha256(data).hexdigest(), + ) + + result = await run_suite(call, reject, artifact, upload, output) + print(json.dumps({"passed": result["passed"], "checks": result["checks"]})) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_remaining_tools.py b/examples/validate_remaining_tools.py new file mode 100644 index 0000000..574721a --- /dev/null +++ b/examples/validate_remaining_tools.py @@ -0,0 +1,162 @@ +"""NX 2606 legacy-tool acceptance on disposable fixtures. + +Set NX_MCP_URL and NX_VALIDATION_OUTPUT; defaults target loopback and ./native-results. +Original parts must be saved. Records every response, including failed attempts. +""" + +import asyncio +import base64 +import hashlib +import io +import json +import math +import os +import uuid +import zipfile +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def main(): + prefix = "validation/remaining-" + uuid.uuid4().hex[:8] + log = [] + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "native-results")) + output.mkdir(parents=True, exist_ok=True) + async with ( + streamablehttp_client(os.environ.get("NX_MCP_URL", "http://127.0.0.1:8765/mcp")) as ( + r, + w, + _, + ), + ClientSession(r, w) as c, + ): + await c.initialize() + + async def call(n, **p): + v = await c.call_tool(n, p) + d = v.structuredContent + if d is None: + d = json.loads(v.content[0].text) + log.append({"tool": n, "arguments": p, "error": bool(v.isError), "result": d}) + (output / "remaining-tools.json").write_text(json.dumps(log, indent=2)) + if v.isError and n != "nx_cancel_operation": + raise AssertionError((n, d)) + return d + + before = (await call("nx_list_open_parts", limit=100))["parts"] + assert not any(x["modified"] for x in before) + original = next(x for x in before if x["work"]) + created = [] + try: + status = await call("nx_status") + assert ( + status["connected"] + and status["ui"]["main_thread_id"] == status["ui"]["callback_thread_id"] + ) + capabilities = await call("nx_capabilities", tool="nx_status") + assert set(capabilities["tools"]) == {"nx_status"} + d = await call("nx_create_part", path=prefix + "/probe.prt", units="mm") + created.append(d["part"]["id"]) + s = (await call("nx_create_sketch"))["object"]["id"] + a = await call( + "nx_sketch_line", sketch_id=s, start={"x": 0, "y": 0}, end={"x": 10, "y": 0} + ) + b = await call( + "nx_sketch_line", sketch_id=s, start={"x": 0, "y": 0}, end={"x": 0, "y": 10} + ) + info = await call("nx_sketch_info", sketch_id=s) + assert info["frame"]["normal"] == [0, 0, 1] and len(info["curves"]) == 2 + angle = await call("nx_measure_angle", obj1=a["object"]["id"], obj2=b["object"]["id"]) + assert math.isclose(angle["angle_deg"], 90, abs_tol=1e-8) and angle["units"] == "deg" + constraint = await call( + "nx_sketch_constraint", constraint_type="horizontal", targets=[a["object"]["id"]] + ) + assert constraint["edit_count"] == 1 + await call("nx_finish_sketch", sketch_id=s) + s2 = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=s2, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=s2) + e = await call("nx_extrude", sketch_id=s2, distance=10) + bodies = await call("nx_list_bodies") + assert len(bodies["objects"]) == 1 + fs = await call("nx_list_features") + assert len(fs["objects"]) >= 3 + sketches = await call("nx_list_sketches") + assert len(sketches["objects"]) == 2 + feature = e["feature"]["id"] + await call("nx_rename_object", object_id=feature, name="ProbeExtrusion") + for v in ["Top", "Back", "Isometric"]: + view = await call("nx_set_view", orientation=v) + assert view["orientation"] == v.lower() and view["viewport_available"] + await call("nx_fit_view") + await call("nx_save_part") + await call("nx_save_as", path=prefix + "/nested/copy.prt") + page = await call("nx_workspace_list", path=prefix, limit=1) + assert page["count"] == 1 and page["next_offset"] == 1 and page["total_count"] == 2 + feature = next( + f["id"] + for f in (await call("nx_list_features"))["objects"] + if f["name"] == "ProbeExtrusion" + ) + deleted = await call("nx_delete_feature", name=feature) + assert deleted["deleted"] + assert not (await call("nx_list_bodies"))["objects"] + await call("nx_save_part") + # Use original saved cube as a component prototype. + d = await call("nx_create_part", path=prefix + "/assembly.prt", units="mm") + created.append(d["part"]["id"]) + comp = (await call("nx_add_component", part_path=prefix + "/probe.prt", name="Cube"))[ + "object" + ]["id"] + oid = "remaining-relative-" + uuid.uuid4().hex + await call("nx_reposition_component", component=comp, dx=20, rz=90, operation_id=oid) + retry = await call( + "nx_reposition_component", component=comp, dx=20, rz=90, operation_id=oid + ) + assert retry["replayed"] + pose = (await call("nx_list_components"))["components"][0] + assert pose["translation"] == [20, 0, 0] + expected_rotation = [[0, -1, 0], [1, 0, 0], [0, 0, 1]] + assert all( + math.isclose(a, b, abs_tol=1e-8) + for actual, expected in zip(pose["rotation_matrix"], expected_rotation, strict=True) + for a, b in zip(actual, expected, strict=True) + ) + ex = (await call("nx_create_explosion", name="Disposable"))["object"]["id"] + await call("nx_delete_explosion", explosion=ex) + assert not (await call("nx_list_explosions"))["explosions"] + await call("nx_save_part") + await call("nx_package_assembly", path=prefix + "/assembly.zip") + chunk = await call("nx_download_file", path=prefix + "/assembly.zip") + assert chunk["eof"] + data = base64.b64decode(chunk["data_base64"]) + assert hashlib.sha256(data).hexdigest() == chunk["sha256"] + with zipfile.ZipFile(io.BytesIO(data)) as z: + assert ( + len([n for n in z.namelist() if n.endswith(".prt")]) == 2 + and z.testzip() is None + ) + cancel = await call("nx_cancel_operation", operation_id="remaining-not-running") + assert cancel["code"] == "NX_NOT_CANCELLABLE" + finally: + now = (await call("nx_list_open_parts", limit=100))["parts"] + for x in reversed(now): + if "/" + prefix + "/" in x["path"].replace("\\", "/"): + await call("nx_close_part", part=x["part"]["id"], save=False) + await call("nx_activate_part", part=original["part"]["id"], work=True, display=True) + after = (await call("nx_list_open_parts", limit=100))["parts"] + assert len(after) == len(before) and not any(x["modified"] for x in after) + print( + "Completed; original session restored; expected rejection:", + [x["tool"] for x in log if x["error"]], + ) + + +asyncio.run(main()) diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py new file mode 100644 index 0000000..b35c41e --- /dev/null +++ b/examples/validate_sheet_metal.py @@ -0,0 +1,321 @@ +"""Native public-MCP sheet-metal workflow acceptance in disposable parts. + +NX_MCP_URL selects the server. NX_VALIDATION_OUTPUT receives durable receipts and +checksum-verified PNG/PDF/DXF/GEO artifacts. Existing parts must be saved first. +This checks a bracket workflow, not every sheet-metal option or manufacturability. +""" + +import asyncio +import base64 +import hashlib +import json +import math +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +def fixture_dxf_extents(path): + """Read LINE entity bounds for this rectangular ASCII DXF fixture.""" + lines = path.read_text(errors="strict").splitlines() + pairs = [(int(lines[i]), lines[i + 1].strip()) for i in range(0, len(lines) - 1, 2)] + points, record, entity, section = [], {}, None, None + for code, value in pairs + [(0, "EOF")]: + if code == 2 and entity == "SECTION": + section = value + if code == 0: + if entity == "LINE" and section == "ENTITIES": + points.extend( + [(float(record[10]), float(record[20])), (float(record[11]), float(record[21]))] + ) + if value == "ENDSEC": + section = None + entity, record = value, {} + else: + record[code] = value + assert points, "No LINE geometry in the native rectangular DXF" + return sorted(max(p[i] for p in points) - min(p[i] for p in points) for i in (0, 1)) + + +async def main(): + output = Path(os.environ.get("NX_VALIDATION_OUTPUT", "sheet-metal-results")) + output.mkdir(parents=True, exist_ok=True) + prefix = "sheet-metal-validation-" + uuid.uuid4().hex[:8] + receipt = {"fixture": prefix, "checks": []} + + def save(): + (output / "sheet-metal-validation.json").write_text(json.dumps(receipt, indent=2)) + + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(method, **params): + response = await client.call_tool(method, params) + assert not response.isError, (method, response.structuredContent) + return response.structuredContent + + async def reject(method, **params): + response = await client.call_tool(method, params) + assert response.isError, (method, response.structuredContent) + return response.structuredContent + + async def artifact(meta, name): + data = bytearray() + while True: + chunk = await call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(chunk["data_base64"])) + if chunk["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (output / name).write_bytes(data) + return {"file": name, "sha256": meta["sha256"], "size": len(data)} + + async def nearest(body, kind, point): + result = await call("nx_find_geometry", owner=body, kind=kind, near=point) + assert result["items"] + return result["items"][0]["object"]["id"] + + async def volume(body): + return (await call("nx_measure_volume", body=body))["volume_mm3"] + + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before), "Save existing parts before acceptance" + original = next(p for p in before if p["work"]) + original_display = next(p for p in before if p["display"]) + try: + assert len((await client.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") + ) + catalog = await call("nx_sheet_metal_schema") + assert len(catalog["operations"]) == 34 + await call("nx_create_part", path=prefix + "/bracket.prt", units="mm") + await call("nx_sheet_metal_context") + defaults = await call( + "nx_set_sheet_metal_defaults", thickness=2, bend_radius=3, neutral_factor=0.33 + ) + assert defaults["parameters"]["thickness"]["value"] == 2 + sketch = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sketch, + corner1={"x": 0, "y": 0}, + corner2={"x": 100, "y": 80}, + ) + await call("nx_finish_sketch", sketch_id=sketch) + tab = await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": sketch, "thickness": 2}, + ) + body = tab["body"]["id"] + assert math.isclose(await volume(body), 16000, rel_tol=1e-8) + edge = await nearest(body, "edge", [50, 0, 0]) + token = "sheet-metal-flange-" + uuid.uuid4().hex + params = { + "operation": "flange", + "parameters": { + "flanges": [ + {"edges": [edge], "length": 20, "length_reference": "Inside", "angle": 90} + ] + }, + "operation_id": token, + } + flange = await call("nx_sheet_metal_feature", **params) + again = await call("nx_sheet_metal_feature", **params) + assert again["feature"]["id"] == flange["feature"]["id"] + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + assert info["thickness"] == 2 and info["bend_count"] == 1 + bend = info["bends"][0] + assert math.isclose(bend["angle_degrees"], 90) and math.isclose(bend["inner_radius"], 3) + note = await call( + "nx_sheet_metal_annotation", + kind="bend", + body=body, + faces=[bend["face"]["id"]], + position=[50, -25, 20], + ) + assert "90.000 deg" in note["text"][0][1] + assert any(r["kind"] == "annotation" for r in note["changes"]["created"]) + receipt["checks"].append("tab_analytic_volume_flange_bend_info_retry_pmi") + save() + + await call("nx_set_view", orientation="isometric") + await call("nx_fit_view") + render = await call( + "nx_render_view", path=prefix + "/bracket.png", style="shaded_with_edges" + ) + receipt["render"] = await artifact(render, "bracket.png") + face = await nearest(body, "face", [50, 40, 0]) + xedge = await nearest(body, "edge", [50, 80, 0]) + flat = await call( + "nx_sheet_metal_feature", + operation="flat_pattern", + parameters={"upward_face": face, "x_axis_edge": xedge, "associative": True}, + ) + bodies_before = (await call("nx_get_bounding_box"))["body_count"] + receipt["exports"] = [] + for fmt in ("dxf", "geo"): + exported = await call( + "nx_export_flat_pattern", + flat_pattern=flat["feature"]["id"], + path=prefix + "/bracket." + fmt, + format=fmt, + ) + receipt["exports"].append(await artifact(exported, "bracket." + fmt)) + assert (await call("nx_get_bounding_box"))["body_count"] == bodies_before + length_expression = [e for e in flange["expressions"] if e["value"] == 20] + assert len(length_expression) == 1, "Fixture must identify length unambiguously" + await call( + "nx_set_expression", expression=length_expression[0]["object"]["id"], formula="25" + ) + updated = await call( + "nx_export_flat_pattern", + flat_pattern=flat["feature"]["id"], + path=prefix + "/bracket-edited.dxf", + ) + receipt["edited_dxf"] = await artifact(updated, "bracket-edited.dxf") + assert receipt["edited_dxf"]["sha256"] != receipt["exports"][0]["sha256"] + # Inside height + thickness = outside height. Subtract two outer + # setbacks and add the 90-degree neutral-axis bend allowance. + for filename, inside_height in [("bracket.dxf", 20), ("bracket-edited.dxf", 25)]: + expected = sorted( + [100, 80 + inside_height + 2 - 2 * (3 + 2) + math.pi / 2 * (3 + 0.33 * 2)] + ) + actual = fixture_dxf_extents(output / filename) + assert all( + math.isclose(a, b, abs_tol=1e-4) for a, b in zip(actual, expected, strict=True) + ), (actual, expected) + receipt.setdefault("developed_dimensions", {})[filename] = actual + drawing = await call("nx_create_drawing", name="Flat", size="A4") + view = await call( + "nx_add_flat_pattern_view", + drawing=drawing["object"]["id"], + flat_pattern=flat["feature"]["id"], + position=[148.5, 105], + ) + receipt["flat_view"] = view + pdf = await call("nx_export_drawing_pdf", path=prefix + "/bracket.pdf") + receipt["pdf"] = await artifact(pdf, "bracket.pdf") + await call("nx_save_part") + await call("nx_close_part") + await call("nx_open_part", path=prefix + "/bracket.prt") + stale = await reject("nx_sheet_metal_info", body=body) + assert stale["code"] == "NX_OBJECT_STALE" + assert (await call("nx_model_health"))["healthy"] + receipt["checks"].append("flat_pattern_dxf_geo_drawing_pdf_reopen_stale") + save() + + # A fresh part isolates rollback from the flat-pattern derived bodies. + await call("nx_create_part", path=prefix + "/rollback.prt", units="mm") + await call("nx_sheet_metal_context") + checkpoint = await call("nx_checkpoint", label="before_tab") + sketch = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sketch, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sketch) + tab = await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": sketch, "thickness": 2}, + ) + before_volume = await volume(tab["body"]["id"]) + await reject( + "nx_sheet_metal_feature", + operation="tab", + feature=tab["feature"]["id"], + parameters={"unsupported": 42}, + ) + # A failed mutation invalidates references even when it rolls back. + fresh = (await call("nx_sheet_metal_info"))["items"][0]["body"]["id"] + assert await volume(fresh) == before_volume + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + assert not (await call("nx_sheet_metal_info"))["items"] + receipt["checks"].append("unsupported_edit_unchanged_checkpoint_rollback") + await call("nx_create_part", path=prefix + "/attached.prt", units="mm") + await call("nx_sheet_metal_context") + sketch = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sketch, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sketch) + tab = await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": sketch, "thickness": 2}, + ) + body = tab["body"]["id"] + edge = await nearest(body, "edge", [5, 0, 0]) + face = await nearest(body, "face", [5, 5, 0]) + path_sketch = await call( + "nx_create_path_sketch", + edges=[edge], + help_point=[5, 0, 0], + percent=0, + orienting_face=face, + ) + frame = path_sketch["frame"] + assert math.isclose(abs(frame["normal"][0]), 1) + delta = [0, -5, 0] + end = { + k: sum(a * b for a, b in zip(delta, frame[axis], strict=True)) + for k, axis in [("x", "x_axis"), ("y", "y_axis")] + } + sketch = path_sketch["object"]["id"] + await call("nx_sketch_line", sketch_id=sketch, start={"x": 0, "y": 0}, end=end) + await call("nx_finish_sketch", sketch_id=sketch) + # Re-resolve topology after sketch mutations. + edge = await nearest(body, "edge", [5, 0, 0]) + attached = await call( + "nx_sheet_metal_feature", + operation="contour_flange", + parameters={ + "section": sketch, + "edge_chain": {"edges": [edge], "help_point": [5, 0, 0]}, + "is_secondary": True, + "thickness": 2, + "sweep_distance": 10, + }, + ) + assert math.isclose(await volume(attached["body"]["id"]), 300, rel_tol=1e-7) + assert (await call("nx_model_health"))["healthy"] + receipt["checks"].append("path_sketch_secondary_contour_analytic_volume") + receipt["passed"] = True + except Exception: + receipt["passed"] = False + receipt["error"] = traceback.format_exc() + raise + finally: + try: + await call("nx_open_part", path=original["path"], work=True, display=True) + for part in (await call("nx_list_open_parts"))["parts"]: + if prefix in part["path"]: + await call("nx_close_part", part=part["part"]["id"], save=True) + if original_display["path"] != original["path"]: + await call( + "nx_open_part", path=original_display["path"], work=False, display=True + ) + after = (await call("nx_list_open_parts"))["parts"] + assert {p["path"] for p in after} == {p["path"] for p in before} + assert not any(p["modified"] for p in after) + receipt["session_restored"] = True + finally: + save() + print(json.dumps({"passed": receipt["passed"], "checks": receipt["checks"]})) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_transport_recovery.py b/examples/validate_transport_recovery.py new file mode 100644 index 0000000..b5172d3 --- /dev/null +++ b/examples/validate_transport_recovery.py @@ -0,0 +1,131 @@ +"""Native Windows bridge interruption, idempotency and manual-handoff acceptance. + +Run beside NX with NX_BRIDGE_DESCRIPTOR and NX_VALIDATION_OUTPUT set. The token +is read locally and never included in receipts. Only disposable parts mutate. +A committed receipt is retained for verification after a real bridge restart. +""" + +import asyncio +import json +import os +import socket +import traceback +import uuid +from pathlib import Path + +from nx_mcp.bridge import DescriptorBridgeClient +from nx_mcp.runtime import NXToolError + + +async def main(): + descriptor = Path(os.environ["NX_BRIDGE_DESCRIPTOR"]) + output = Path(os.environ["NX_VALIDATION_OUTPUT"]) + output.mkdir(parents=True, exist_ok=True) + client = DescriptorBridgeClient(descriptor) + receipt = {"checks": []} + prefix = str(Path(os.environ["NX_WORKSPACE"]) / ("transport-recovery-" + uuid.uuid4().hex[:8])) + + def save(): + (output / "transport-recovery.json").write_text(json.dumps(receipt, indent=2)) + + async def call(method, **params): + return await client.call(method, params) + + async def reject(method, **params): + try: + await call(method, **params) + except NXToolError as error: + return error.code + raise AssertionError(f"{method} unexpectedly succeeded") + + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before), "Save existing parts first" + original = next(p for p in before if p["work"]) + display = next(p for p in before if p["display"]) + try: + await call("nx_create_part", path=prefix + "/seed.prt") + sketch = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sketch, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sketch) + await call("nx_extrude", sketch_id=sketch, distance=10) + await call("nx_save_part") + await call("nx_create_part", path=prefix + "/assembly.prt") + await call("nx_add_component", part_path=prefix + "/seed.prt", name="SEED") + await call("nx_save_part") + component = (await call("nx_list_components"))["components"][0]["object"]["id"] + operation_id = "disconnect_" + uuid.uuid4().hex + params = {"component": component, "dx": 7, "operation_id": operation_id} + d = json.loads(descriptor.read_text()) + request = { + "jsonrpc": "2.0", + "protocol_version": 1, + "id": uuid.uuid4().hex, + "token": d["token"], + "method": "nx_reposition_component", + "params": params, + } + with socket.create_connection((d["host"], d["port"]), timeout=10) as connection: + connection.sendall(json.dumps(request).encode() + b"\n") + # Deliberately abandon the response. No mutation is retried until its + # durable receipt demonstrates whether it committed. + state = await call("nx_operation_status", operation_id=operation_id) + assert state["state"] == "committed", state + replay = await call("nx_reposition_component", **params) + assert replay["replayed"] + pose = (await call("nx_list_components"))["components"][0]["translation"] + assert pose == [7, 0, 0], pose + conflict = await reject("nx_reposition_component", **{**params, "dx": 8}) + assert conflict == "NX_IDEMPOTENCY_CONFLICT", conflict + receipt.update( + operation_id=operation_id, fixture=prefix, committed=state, replayed=True, pose=pose + ) + receipt["checks"].append("disconnect_committed_receipt_exact_retry_no_double_move") + save() + await call("nx_save_part") + checkpoint = await call("nx_checkpoint", label="placement rollback") + await call("nx_reposition_component", component=component, dx=3) + await call("nx_get_bounding_box", scope="assembly") + await call("nx_rollback", checkpoint_id=checkpoint["checkpoint_id"]) + fresh = (await call("nx_list_components"))["components"][0] + assert fresh["translation"] == [7, 0, 0] + receipt["checks"].append("readonly_keeps_checkpoint_rollback_restores_pose") + await call("nx_save_part") + stale = fresh["object"]["id"] + await call("nx_ui_control", mode="manual") + assert await reject("nx_reposition_component", component=stale, dx=1) == "NX_UI_PAUSED" + await call("nx_ui_control", mode="agent") + error = await reject("nx_reposition_component", component=stale, dx=1) + assert "STALE" in error, error + fresh = (await call("nx_list_components"))["components"][0] + assert fresh["translation"] == [7, 0, 0] + receipt["checks"].append("manual_blocks_mutation_resumption_rejects_stale_reference") + receipt["passed"] = True + except Exception: + receipt["error"] = traceback.format_exc() + raise + finally: + await call("nx_ui_control", mode="agent") + parts = (await call("nx_list_open_parts"))["parts"] + copies = [p for p in parts if prefix in p["path"]] + copies.sort(key=lambda p: "assembly.prt" not in p["path"]) + for part in copies: + live = (await call("nx_list_open_parts"))["parts"] + current = next((p for p in live if p["path"] == part["path"]), None) + if current: + await call("nx_close_part", part=current["part"]["id"], save=True) + await call("nx_open_part", path=display["path"], work=False, display=True) + await call("nx_open_part", path=original["path"], work=True, display=False) + after = (await call("nx_list_open_parts"))["parts"] + receipt["session_restored"] = {p["path"] for p in before} == {p["path"] for p in after} + assert receipt["session_restored"] and not any(p["modified"] for p in after) + save() + print(json.dumps({"passed": receipt["passed"], "checks": receipt["checks"]})) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py new file mode 100644 index 0000000..ddcc84c --- /dev/null +++ b/examples/validate_visual_tools.py @@ -0,0 +1,300 @@ +"""Public MCP visualization regressions; uses a unique disposable workspace folder.""" + +import asyncio +import base64 +import hashlib +import json +import os +import traceback +import uuid +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +outdir = Path(os.environ.get("NX_VISUAL_RESULTS", "outputs/nx-mcp-visual-tools")) +outdir.mkdir(parents=True, exist_ok=True) +root = "visual-validation-" + uuid.uuid4().hex[:8] +report = {"tests": [], "workspace": root} + + +async def run(client): + async def call(method, **args): + response = await client.call_tool(method, args) + if response.isError: + raise RuntimeError(response.structuredContent or response.content) + data = response.structuredContent + if method == "nx_screenshot": + items = [i for i in response.content if i.type == "image"] + assert len(items) == 1 + data_bytes = base64.b64decode(items[0].data) + assert hashlib.sha256(data_bytes).hexdigest() == data["sha256"] + file = outdir / (Path(data["artifact_path"].replace("\\", "/")).name) + file.write_bytes(data_bytes) + data["local_image"] = str(file.resolve()) + return data + + async def test(name, fn): + try: + report["tests"].append({"name": name, "status": "passed", "result": await fn()}) + except Exception: + report["tests"].append( + {"name": name, "status": "failed", "error": traceback.format_exc()} + ) + (outdir / "public-visual-validation.json").write_text(json.dumps(report, indent=2)) + print(name, report["tests"][-1]["status"], flush=True) + + async def capture(name): + return await call("nx_screenshot", path=root + "/" + name + ".png", style="current") + + async def cube(path): + await call("nx_create_part", path=path) + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", + sketch_id=sk, + corner1={"x": 0, "y": 0}, + corner2={"x": 10, "y": 10}, + ) + await call("nx_finish_sketch", sketch_id=sk) + body = (await call("nx_extrude", sketch_id=sk, distance=10))["bodies"][0]["id"] + return sk, body + + async def schema(): + names = {x.name for x in (await client.list_tools()).tools} + assert len(names) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")), len(names) + return await call("nx_status") + + await test("schemas_and_visible_ui", schema) + proto = root + "/cube.prt" + sub = root + "/sub.prt" + top = root + "/assembly.prt" + sk, body = await cube(proto) + + async def diagnostics(): + await call("nx_save_part") + before = await call("nx_list_open_parts") + checkpoint = await call("nx_checkpoint", label="Before diagnostics") + result = await call("nx_sketch_diagnostics", sketch_id=sk) + assert ( + result["solver_status"] == "UnderConstrained" + and result["remaining_degrees_of_freedom"] > 0 + ), result + after = await call("nx_list_open_parts") + assert [p["modified"] for p in before["parts"]] == [p["modified"] for p in after["parts"]] + assert (await call("nx_checkpoint_state"))["checkpoints"], checkpoint + return result + + await test("underconstrained_diagnostics_preserve_state", diagnostics) + + async def appearance(): + before = await call("nx_display_info", objects=[body]) + result = await call("nx_set_display", objects=[body], color="red", transparency=55) + faces = [r for r in result["objects"] if r["object"]["kind"] == "face"] + assert all(r["transparency"] == 55 for r in faces), result + await call("nx_set_view", orientation="isometric") + await call("nx_fit_view") + image = await capture("appearance") + await call("nx_restore_display", restore_id=result["restore_id"]) + after = await call("nx_display_info", objects=[body]) + assert before["objects"] == after["objects"], after + return {"changed": result, "image": image} + + await test("color_transparency_and_face_restore", appearance) + + async def visibility(): + before = await call("nx_display_info", objects=[body]) + color = await call("nx_set_display", objects=[body], color="blue") + hide = await call("nx_set_visibility", objects=[body], mode="hide") + assert hide["objects"][0]["blanked"] + try: + await call("nx_restore_display", restore_id=color["restore_id"]) + except RuntimeError as ex: + assert "NX_RESTORE_ORDER" in str(ex), ex + else: + raise AssertionError("Out-of-order restore accepted") + await call("nx_restore_display", restore_id=hide["restore_id"]) + await call("nx_restore_display", restore_id=color["restore_id"]) + assert before["objects"] == (await call("nx_display_info", objects=[body]))["objects"] + return {"hide": hide, "out_of_order_rejected": True} + + await test("visibility_restore_and_order_preflight", visibility) + + async def sections(): + before = (await call("nx_measure_volume", body=body))["volume_mm3"] + plane = await call("nx_section_view", origin=[0, 0, 5], normal=[0, 0, 2], name="Z midplane") + ref = plane["object"]["id"] + assert plane["normal"] == [0, 0, 1] + assert plane["retained_side"] == "negative_normal" + await call("nx_save_part") + saved = await call("nx_list_open_parts") + listing = await call("nx_list_sections") + assert [p["modified"] for p in saved["parts"]] == [ + p["modified"] for p in (await call("nx_list_open_parts"))["parts"] + ] + assert listing["count"] == 1 and listing["view_sectioning_enabled"], listing + image = await capture("section-z") + moved = await call( + "nx_section_view", + section=ref, + origin=[5, 0, 0], + normal=[1, 1, 0], + name="Diagonal section", + ) + diagonal = await capture("section-diagonal") + await call("nx_section_control", section=ref, action="disable") + assert not (await call("nx_list_sections"))["view_sectioning_enabled"] + await call("nx_section_control", section=ref, action="enable") + await call("nx_section_control", section=ref, action="delete") + assert (await call("nx_list_sections"))["count"] == 0 + assert abs((await call("nx_measure_volume", body=body))["volume_mm3"] - before) < 1e-7 + return { + "plane": plane, + "listing": listing, + "image": image, + "edited": moved, + "diagonal": diagonal, + } + + await test("native_section_lifecycle_and_saved_state", sections) + await call("nx_save_part") + # Shared prototype under a rotated subassembly and direct instances. + await call("nx_create_part", path=sub) + await call("nx_add_component", part_path=proto, name="NESTED_CUBE", translation=[3, 0, 0]) + await call("nx_save_part") + await call("nx_create_part", path=top) + await call("nx_add_component", part_path=proto, name="DIRECT_CUBE") + await call( + "nx_add_component", + part_path=sub, + name="ROTATED_SUB", + translation=[15, -3, 0], + rotation_matrix=[[0, -1, 0], [1, 0, 0], [0, 0, 1]], + ) + await call("nx_add_component", part_path=proto, name="CLEAR_CUBE", translation=[25, 0, 0]) + await call("nx_save_part") + await call("nx_set_view", orientation="isometric") + await call("nx_fit_view") + + async def highlights(): + before = await call("nx_display_info", objects=["DIRECT_CUBE", "ROTATED_SUB", "CLEAR_CUBE"]) + result = await call("nx_highlight_collisions", obj1="DIRECT_CUBE", obj2="ROTATED_SUB") + assert ( + result["highlighted_count"] == 2 + and abs(result["pairs"][0]["interference_volume_mm3"] - 500) < 1e-7 + ), result + image = await capture("collision-highlight") + clear = await call("nx_clear_highlights") + assert clear["cleared_count"] == 2, clear + assert ( + before["objects"] + == ( + await call("nx_display_info", objects=["DIRECT_CUBE", "ROTATED_SUB", "CLEAR_CUBE"]) + )["objects"] + ) + empty = await call("nx_highlight_collisions", obj1="DIRECT_CUBE", obj2="CLEAR_CUBE") + assert empty["highlighted_count"] == 0 + return {"collision": result, "image": image, "clear_pair": empty} + + await test("nested_collision_highlighting", highlights) + + async def isolate(): + state = await call("nx_display_info", objects=["DIRECT_CUBE", "ROTATED_SUB", "CLEAR_CUBE"]) + hidden = await call("nx_set_visibility", objects=["CLEAR_CUBE"], mode="hide") + isolated = await call("nx_set_visibility", objects=["ROTATED_SUB"], mode="isolate") + image = await capture("isolated-nested-component") + await call("nx_restore_display", restore_id=isolated["restore_id"]) + # Existing hidden geometry stays hidden after ending isolation. + listed = await call("nx_set_visibility", objects=["CLEAR_CUBE"], mode="show") + await call("nx_restore_display", restore_id=listed["restore_id"]) + await call("nx_restore_display", restore_id=hidden["restore_id"]) + assert ( + state["objects"] + == ( + await call("nx_display_info", objects=["DIRECT_CUBE", "ROTATED_SUB", "CLEAR_CUBE"]) + )["objects"] + ) + return {"isolated": isolated, "image": image} + + await test("nested_isolation_and_previous_visibility", isolate) + + async def occurrence_color(): + before = await call("nx_display_info", objects=["DIRECT_CUBE", "CLEAR_CUBE"]) + changed = await call( + "nx_set_display", objects=["ROTATED_SUB"], color="green", transparency=30 + ) + assert ( + before["objects"] + == (await call("nx_display_info", objects=["DIRECT_CUBE", "CLEAR_CUBE"]))["objects"] + ) + image = await capture("occurrence-color") + parts = await call("nx_list_open_parts") + prototypes = [p for p in parts["parts"] if p["path"].endswith("cube.prt")] + assert all(not p["modified"] for p in prototypes), prototypes + await call("nx_restore_display", restore_id=changed["restore_id"]) + return {"change": changed, "image": image, "prototype_modified": False} + + await test("occurrence_appearance_does_not_recolor_prototype", occurrence_color) + + async def assembly_section(): + before = await call("nx_get_bounding_box", scope="assembly") + plane = await call( + "nx_section_view", origin=[0, 0, 5], normal=[0, 0, 1], name="Assembly slice" + ) + image = await capture("assembly-section") + after = await call("nx_get_bounding_box", scope="assembly") + assert before["min"] == after["min"] and before["max"] == after["max"] + await call("nx_section_control", section=plane["object"]["id"], action="delete") + return {"plane": plane, "image": image} + + await test("assembly_section_preserves_geometry", assembly_section) + + async def fixed_fixture(): + path = os.environ.get("NX_FIXED_SKETCH_FIXTURE") + assert path, "NX_FIXED_SKETCH_FIXTURE is required" + await call("nx_open_part", path=path) + sketches = await call("nx_list_sketches") + ref = sketches["objects"][0]["id"] + result = await call("nx_sketch_diagnostics", sketch_id=ref) + assert ( + result["solver_status"] == "WellConstrained" + and result["remaining_degrees_of_freedom"] == 0 + ), result + assert result["constraint_count"] > 0 and any( + x["constraints"] for x in result["geometry"] + ), result + return result + + await test("fully_constrained_fixture_diagnostics", fixed_fixture) + + async def handoff(): + await call("nx_open_part", path=top) + await call("nx_highlight_collisions", obj1="DIRECT_CUBE", obj2="ROTATED_SUB") + manual = await call("nx_ui_control", mode="manual") + assert not manual["actual_ui_lock"] and manual["nx_window_input_enabled"], manual + agent = await call("nx_ui_control", mode="agent") + assert agent["actual_ui_lock"] and not agent["nx_window_input_enabled"], agent + cleared = await call("nx_clear_highlights") + assert cleared["cleared_count"] == 0, cleared + return {"manual": manual, "agent": agent, "highlight_cleanup": cleared} + + await test("manual_handoff_clears_highlights", handoff) + report["passed"] = sum(t["status"] == "passed" for t in report["tests"]) + report["total"] = len(report["tests"]) + (outdir / "public-visual-validation.json").write_text(json.dumps(report, indent=2)) + print(json.dumps({"passed": report["passed"], "total": report["total"]})) + if report["passed"] != report["total"]: + raise RuntimeError("See public-visual-validation.json") + + +async def main(): + async with ( + streamablehttp_client(os.environ["NX_MCP_TEST_ENDPOINT"]) as (r, w, _), + ClientSession(r, w) as client, + ): + await client.initialize() + await run(client) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index e6b0b19..d989001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev0" +version = "0.2.0.dev23" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" @@ -15,6 +15,7 @@ dependencies = [ ] [project.optional-dependencies] +benchmark = ["tiktoken>=0.12,<1"] dev = [ "pytest>=7.0", "pytest-asyncio>=0.21", @@ -83,3 +84,6 @@ exclude = [ # NXOpen is injected at runtime inside NX; treat it as untyped rather than erroring. module = "NXOpen.*" ignore_missing_imports = true + +[tool.setuptools.package-data] +nx_mcp = ["capability_manifest.json", "sheet_metal_catalog.json"] diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..40398b8 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,5 @@ +# Build in Python 3.12. Runtime wheels are hash-locked separately. +build==1.6.0 +setuptools==82.0.1 +wheel==0.46.3 +pip==26.2.1 diff --git a/requirements-windows.lock b/requirements-windows.lock new file mode 100644 index 0000000..ff9700c --- /dev/null +++ b/requirements-windows.lock @@ -0,0 +1,548 @@ +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via + # httpx + # mcp + # sse-starlette + # starlette +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.1 \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via uvicorn +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via pyjwt +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via mcp +httpx-sse==0.4.3 \ + --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ + --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d + # via mcp +idna==3.19 \ + --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 + # via + # anyio + # httpx +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via mcp +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +mcp==1.29.1 \ + --hash=sha256:1967ba4c315f7a375146209949f45950d18b0efd2f913d7cf3400bc723ee5f04 \ + --hash=sha256:b6310eeb59153300c4ab8b9aec4c52f4819a2d6a8e429eb43d908bed7c783648 + # via + # -c constraints-windows.txt + # nx-mcp (pyproject.toml) +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \ + --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08 + # via + # -c constraints-windows.txt + # nx-mcp (pyproject.toml) + # mcp + # pydantic-settings +pydantic-core==2.46.5 \ + --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ + --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ + --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ + --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \ + --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ + --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ + --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \ + --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \ + --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ + --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ + --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ + --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \ + --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \ + --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \ + --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \ + --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ + --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \ + --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ + --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ + --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \ + --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ + --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \ + --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ + --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \ + --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ + --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ + --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \ + --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \ + --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \ + --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \ + --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \ + --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ + --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \ + --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \ + --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \ + --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \ + --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ + --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ + --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ + --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \ + --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ + --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ + --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ + --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ + --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ + --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ + --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \ + --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \ + --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \ + --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ + --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \ + --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \ + --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ + --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ + --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \ + --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \ + --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ + --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ + --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ + --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \ + --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ + --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \ + --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \ + --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \ + --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ + --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \ + --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \ + --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ + --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \ + --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ + --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ + --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \ + --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ + --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \ + --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \ + --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \ + --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ + --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ + --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \ + --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ + --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \ + --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \ + --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ + --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \ + --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ + --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \ + --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ + --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ + --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ + --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ + --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \ + --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ + --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \ + --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \ + --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ + --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ + --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \ + --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ + --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \ + --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \ + --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ + --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ + --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \ + --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ + --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ + --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ + --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ + --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ + --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ + --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \ + --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \ + --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \ + --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ + --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \ + --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ + --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ + --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ + --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \ + --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \ + --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 + # via pydantic +pydantic-settings==2.15.0 \ + --hash=sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42 \ + --hash=sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117 + # via mcp +pyjwt==2.13.0 \ + --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + # via mcp +python-dotenv==1.2.3 \ + --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \ + --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35 + # via pydantic-settings +python-multipart==0.0.32 \ + --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ + --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 + # via mcp +pywin32==311 \ + --hash=sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b \ + --hash=sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151 \ + --hash=sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87 \ + --hash=sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503 \ + --hash=sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d \ + --hash=sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c \ + --hash=sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d \ + --hash=sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31 \ + --hash=sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b \ + --hash=sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a \ + --hash=sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42 \ + --hash=sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2 \ + --hash=sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b \ + --hash=sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee \ + --hash=sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067 \ + --hash=sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd \ + --hash=sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3 \ + --hash=sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91 \ + --hash=sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852 \ + --hash=sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d + # via + # -c constraints-windows.txt + # mcp +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # jsonschema + # jsonschema-specifications +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +sse-starlette==3.4.11 \ + --hash=sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade \ + --hash=sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453 + # via mcp +starlette==1.6.0 \ + --hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \ + --hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b + # via + # mcp + # sse-starlette +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # mcp + # pydantic + # pydantic-core + # referencing + # starlette + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via + # mcp + # pydantic + # pydantic-settings +uvicorn==0.52.4 \ + --hash=sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86 \ + --hash=sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1 + # via mcp diff --git a/scripts/accept_release.py b/scripts/accept_release.py new file mode 100644 index 0000000..d94567b --- /dev/null +++ b/scripts/accept_release.py @@ -0,0 +1,356 @@ +"""Verify an installed offline release and run serial native acceptance on its NX host. + +Never installs, stops, restarts, saves or restores user parts itself. Native suites +create isolated test parts and must restore the original saved session. +""" + +import argparse +import asyncio +import hashlib +import importlib.metadata +import importlib.util +import io +import json +import os +import re +import runpy +import subprocess +import sys +import zipfile +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from urllib.parse import urlparse + + +def now(): + return datetime.now(timezone.utc).isoformat() + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def absolute_path(path): + """Normalize host paths without querying unsupported shared-drive reparse APIs. + + These are operator-selected paths, not a containment/security boundary. File + existence and trusted-package byte checks happen separately. In particular, + Path.resolve() can fail with WinError 1005 on VirtIO shares, even for a valid + path or a new output directory whose parents exist. + """ + return Path(os.path.abspath(os.fspath(path))) + + +def write_receipt(path, report): + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + + +def safe_name(name): + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts or "\\" in name or ":" in name: + raise RuntimeError(f"Unsafe package path: {name}") + return path + + +def verify_package(archive, expected_hash, expected_commit, install_root, runtime_root): + """Compare trusted ZIP bytes with installed source and importable module files.""" + raw = archive.read_bytes() + if digest(raw) != expected_hash.lower(): + raise RuntimeError("Release ZIP SHA-256 differs from the expected value") + with zipfile.ZipFile(io.BytesIO(raw)) as bundle: + names = [i.filename for i in bundle.infolist() if not i.is_dir()] + if len(names) != len(set(names)): + raise RuntimeError("Duplicate ZIP members") + for name in names: + safe_name(name) + manifest = json.loads(bundle.read("manifest.json")) + if set(names) != set(manifest) | {"manifest.json"}: + raise RuntimeError("Package manifest does not cover exactly the ZIP files") + for name, expected in manifest.items(): + safe_name(name) + if digest(bundle.read(name)) != expected: + raise RuntimeError(f"Package checksum mismatch: {name}") + release = json.loads(bundle.read("release.json")) + if release["commit"] != expected_commit: + raise RuntimeError("Package commit differs from the expected full commit") + installed = json.loads((install_root / "release.json").read_text(encoding="utf-8-sig")) + if installed != release: + raise RuntimeError("Installed release metadata differs from the package") + if (install_root / "validation-release.json").exists(): + raise RuntimeError("Unexpected validation overlay; use the consolidated package") + checked_source = 0 + for name, expected in manifest.items(): + if name.startswith("source/"): + actual = install_root.joinpath(*safe_name(name).parts) + if not actual.is_file() or digest(actual.read_bytes()) != expected: + raise RuntimeError(f"Installed source mismatch: {name}") + checked_source += 1 + wheel_name = f"wheels/nx_mcp-{release['version']}-py3-none-any.whl" + with zipfile.ZipFile(io.BytesIO(bundle.read(wheel_name))) as wheel: + runtime_files = { + name.removeprefix("nx_mcp/"): wheel.read(name) + for name in wheel.namelist() + if name.startswith("nx_mcp/") and not name.endswith("/") + } + if not checked_source or not runtime_files: + raise RuntimeError("Package lacks source or runtime module files") + for name, expected in runtime_files.items(): + actual = runtime_root.joinpath(*safe_name(name).parts) + if not actual.is_file() or actual.read_bytes() != expected: + raise RuntimeError(f"Importable runtime differs from wheel: {name}") + # Extra executable/configuration files can shadow the verified release. + for root, expected_names in ( + ( + install_root / "source", + {n.removeprefix("source/") for n in manifest if n.startswith("source/")}, + ), + (runtime_root, set(runtime_files)), + ): + extras = [ + p.relative_to(root).as_posix() + for p in root.rglob("*") + if p.is_file() + and p.suffix in {".py", ".json", ".pyd"} + and "__pycache__" not in p.parts + and p.relative_to(root).as_posix() not in expected_names + ] + if extras: + raise RuntimeError(f"Unpackaged source/runtime files: {extras}") + return { + "release": release, + "source_files_verified": checked_source, + "runtime_files_verified": len(runtime_files), + "runtime_path": str(runtime_root), + "loaded_process_bytes_attested": False, + } + + +def prepare_report(path, identity, resume): + if path.exists(): + if not resume: + raise RuntimeError("Acceptance receipt exists; use --resume or a new output directory") + report = json.loads(path.read_text()) + if report["identity"] != identity: + raise RuntimeError("Resume inputs differ from the original acceptance run") + # A child could have mutated NX before it died. Never infer failure from + # a missing receipt, or automatically repeat failed native operations. + native = report["phases"].get("native", {}) + if native.get("state") in {"running", "failed"}: + raise RuntimeError( + "Native phase incomplete/failed: inspect receipts and NX session; no automatic replay" + ) + if native.get("state") == "passed" and report.get("state") != "passed": + raise RuntimeError( + "Native phase finished but acceptance incomplete; reconcile session evidence before a new run" + ) + return report + if resume: + raise RuntimeError("No acceptance receipt to resume") + return {"identity": identity, "started": now(), "state": "pending", "phases": {}} + + +def phase(report, receipt, name, action): + report["phases"][name] = {"state": "running", "started": now()} + write_receipt(receipt, report) + try: + result = action() + except BaseException as error: + report["phases"][name].update(state="failed", error=str(error), finished=now()) + report.update(state="failed", finished=now()) + write_receipt(receipt, report) + raise + report["phases"][name].update(state="passed", result=result, finished=now()) + write_receipt(receipt, report) + return result + + +def run_native(source, output, tool_count): + log = output / "native.log" + with log.open("w", encoding="utf-8") as stream: + result = subprocess.run( + [ + sys.executable, + str(source / "scripts/validate_native_release.py"), + "--output", + str(output / "native"), + "--expected-tool-count", + str(tool_count), + ], + stdout=stream, + stderr=subprocess.STDOUT, + check=False, + ) + path = output / "native/release-validation.json" + if result.returncode: + raise RuntimeError(f"Native runner exit {result.returncode}; inspect {log} and {path}") + native = json.loads(path.read_text()) + if not native.get("passed") or not native.get("session_restored"): + raise RuntimeError("Native runner lacks passed/session_restored evidence") + return { + "receipt": str(path), + "sha256": digest(path.read_bytes()), + "log": str(log), + "suites": native["suites"], + "session_restored": True, + } + + +def verify_dependencies(lock, version=importlib.metadata.version): + versions = {} + for line in lock.read_text().splitlines(): + if not line or line[0].isspace() or line.startswith("#"): + continue + match = re.fullmatch(r"([\w.-]+)==([^\s;]+)\s*(?:\\)?", line) + if not match: + raise RuntimeError(f"Unsupported dependency lock entry: {line}") + name, expected = match.groups() + actual = version(name) + if actual != expected: + raise RuntimeError( + f"Installed dependency mismatch: {name} expected {expected}, got {actual}" + ) + versions[name] = actual + if not versions: + raise RuntimeError("Dependency lock contains no pinned versions") + return versions + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-zip", type=Path, required=True) + parser.add_argument("--sha256", required=True) + parser.add_argument("--expected-commit", required=True) + parser.add_argument("--install-root", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--expected-tool-count", type=int, default=189) + parser.add_argument("--expected-nx-version", default="v2606") + parser.add_argument("--verify-only", action="store_true") + parser.add_argument("--resume", action="store_true") + args = parser.parse_args() + url = os.environ.get("NX_MCP_URL", "") + parsed = urlparse(url) + if ( + parsed.hostname not in {"localhost", "127.0.0.1", "::1"} + or parsed.username + or parsed.password + ): + parser.error( + "NX_MCP_URL must address this Windows NX host over loopback, without credentials" + ) + if sys.platform != "win32" or sys.version_info[:2] != (3, 12): + parser.error( + "Run on the NX Windows host with its installed Python 3.12 virtual environment" + ) + args.output = absolute_path(args.output) + args.install_root = absolute_path(args.install_root) + args.release_zip = absolute_path(args.release_zip) + fixture = absolute_path(os.environ.get("NX_VENDOR_STEP", "")) + if not fixture.is_file(): + parser.error("NX_VENDOR_STEP must identify an authorized local STEP fixture") + identity = { + "release_zip": str(args.release_zip), + "sha256": args.sha256.lower(), + "expected_commit": args.expected_commit, + "install_root": str(args.install_root), + "endpoint": url, + "python": sys.executable, + "vendor_step": str(fixture), + "vendor_step_sha256": digest(fixture.read_bytes()), + "expected_tool_count": args.expected_tool_count, + "expected_nx_version": args.expected_nx_version, + } + receipt = args.output / "acceptance.json" + if args.output.exists() and not receipt.exists(): + parser.error("Use a new output directory; existing artifacts have no acceptance receipt") + lock = args.install_root / "acceptance.lock" + try: + descriptor = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as error: + raise RuntimeError( + "Acceptance lock exists; inspect the prior process/receipt before removing it" + ) from error + try: + with os.fdopen(descriptor, "w") as stream: + json.dump({"pid": os.getpid(), "receipt": str(receipt), "started": now()}, stream) + report = prepare_report(receipt, identity, args.resume) + if report["state"] == "passed": + print( + json.dumps({"state": "passed", "historical_receipt": str(receipt), "rerun": False}) + ) + return + args.output.mkdir(parents=True, exist_ok=True) + write_receipt(receipt, report) + execute(args, receipt, report) + finally: + lock.unlink() + + +def execute(args, receipt, report): + def package(): + spec = importlib.util.find_spec("nx_mcp") + runtime = absolute_path(Path(spec.origin).parent) + # A source checkout in PYTHONPATH is not the installed wheel environment. + if not runtime.is_relative_to(args.install_root / "venv"): + raise RuntimeError("nx_mcp must import from the selected installation's venv") + return verify_package( + args.release_zip, args.sha256, args.expected_commit, args.install_root, runtime + ) + + phase(report, receipt, "package_and_installed_files", package) + + def dependencies(): + versions = verify_dependencies(args.install_root / "source/requirements-windows.lock") + result = subprocess.run( + [sys.executable, "-m", "pip", "check"], capture_output=True, text=True + ) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + return { + "python_version": sys.version, + "pip_check": result.stdout.strip(), + "locked_versions": versions, + } + + phase(report, receipt, "dependencies", dependencies) + source = args.install_root / "source" + runner = runpy.run_path(str(source / "scripts/validate_native_release.py")) + + def live_preflight(): + before = asyncio.run(runner["snapshot"]()) + if ( + before["tool_count"] != args.expected_tool_count + or before["nx_version"] != args.expected_nx_version + ): + raise RuntimeError("Live NX version/tool count differs from expected runtime") + return before + + before = phase(report, receipt, "live_saved_session", live_preflight) + if args.verify_only: + report.update(state="verified", finished=now()) + write_receipt(receipt, report) + print(json.dumps({"state": "verified", "native_run": False, "receipt": str(receipt)})) + return + try: + phase( + report, + receipt, + "native", + lambda: run_native(source, args.output, args.expected_tool_count), + ) + finally: + + def preservation(): + after = asyncio.run(runner["snapshot"]()) + runner["verify_session"](before, after) + return {"session_restored": True, "after": after} + + phase(report, receipt, "session_preservation", preservation) + report.update(state="passed", finished=now()) + write_receipt(receipt, report) + print(json.dumps({"state": "passed", "receipt": str(receipt)})) + + +if __name__ == "__main__": + main() diff --git a/scripts/agent_benchmark_client.py b/scripts/agent_benchmark_client.py new file mode 100644 index 0000000..40ab148 --- /dev/null +++ b/scripts/agent_benchmark_client.py @@ -0,0 +1,98 @@ +"""Transparent MCP CLI for isolated agent trials; records every explicit request/response.""" + +import argparse +import asyncio +import json +import time +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def run(args): + url = args.url.rstrip("/") + ("/agent/mcp" if args.profile == "agent" else "/mcp") + async with streamablehttp_client(url) as (r, w, _), ClientSession(r, w) as client: + await client.initialize() + + def log(entry): + args.log.parent.mkdir(parents=True, exist_ok=True) + with args.log.open("a") as stream: + stream.write(json.dumps(entry) + "\n") + + async def call(name, params): + started = time.monotonic() + log({"event": "request", "tool": name, "arguments": params}) + result = await client.call_tool(name, params) + value = result.structuredContent + log( + { + "event": "response", + "tool": name, + "result": value, + "error": bool(result.isError), + "seconds": time.monotonic() - started, + "non_text_content": [c.type for c in result.content if c.type != "text"], + } + ) + return value + + if args.action == "list": + catalog = (await client.list_tools()).tools + value = {"tools": [t.model_dump(exclude_none=True) for t in catalog]} + log({"event": "catalog", "profile": args.profile, "presented": value}) + elif args.action == "discover": + if args.profile == "agent": + value = await call( + "nx_discover_tools", {"query": args.tool, "include_schema": True} + ) + else: + started = time.monotonic() + catalog = (await client.list_tools()).tools + selected = [ + t + for t in catalog + if args.tool.casefold() in (t.name + " " + t.description).casefold() + ] + exact = [t for t in selected if t.name == args.tool] + value = { + "tools": [t.model_dump(exclude_none=True) for t in (exact or selected)[:10]], + "matching_count": len(exact or selected), + } + log( + { + "event": "discovery", + "profile": "full", + "catalog_count": len(catalog), + "query": args.tool, + "presented": value, + "seconds": time.monotonic() - started, + } + ) + else: + params = json.loads(args.arguments) + if args.profile == "agent" and args.tool not in { + "nx_inspect", + "nx_result", + "nx_result_cleanup", + "nx_discover_tools", + }: + value = await call("nx_invoke", {"tool": args.tool, "arguments": params}) + else: + value = await call(args.tool, params) + print(json.dumps(value, ensure_ascii=False)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", choices=["full", "agent"], required=True) + parser.add_argument("--url", default="http://192.168.52.10:8765") + parser.add_argument("--log", type=Path, required=True) + parser.add_argument("action", choices=["discover", "call", "list"]) + parser.add_argument("tool", nargs="?", default="") + parser.add_argument("arguments", nargs="?", default="{}") + asyncio.run(run(parser.parse_args())) + + +if __name__ == "__main__": + main() diff --git a/scripts/benchmark_agent_surface.py b/scripts/benchmark_agent_surface.py new file mode 100644 index 0000000..92c7689 --- /dev/null +++ b/scripts/benchmark_agent_surface.py @@ -0,0 +1,74 @@ +"""Tokenize paired NX response transcripts; never equate tokenizer counts with billed usage.""" + +import argparse +import hashlib +import json +from pathlib import Path + +from nx_mcp.agent_surface import compact_payload + + +def benchmark(records, encoding): + def tokens(value): + return len( + encoding.encode( + json.dumps(value, ensure_ascii=False, separators=(",", ":")), disallowed_special=() + ) + ) + + full = reduced = calls = failures = binary_calls = 0 + for row in records: + if row.get("state") != "response": + continue + calls += 1 + failures += bool(row.get("error")) + value = row["result"] + full += tokens(value) + result_id = "result_" + hashlib.sha256(str(calls).encode()).hexdigest()[:32] + payload = compact_payload(value, result_id) + reduced += tokens(payload) + binary_calls += "data_base64" in value + return { + "scope": "paired recorded response projection; same completed native tasks, not an autonomous-agent A/B trial", + "tokenizer": encoding.name, + "serialization": "compact JSON structuredContent counted once; excludes protocol, duplicate text, image tokens and initial discovery", + "full_response_tokens": full, + "compact_response_tokens": reduced, + "response_token_reduction_percent": round(100 * (1 - reduced / full), 2) if full else 0, + "recorded_calls": calls, + "recorded_errors": failures, + "binary_transfer_calls": binary_calls, + "model_calls": 0, + "provider_input_tokens": "unavailable", + "provider_cached_input_tokens": "unavailable", + "provider_output_tokens": "unavailable", + "autonomous_task_success": "not measured; use usage observations from actual agent runs", + "extra_discovery_and_expansion_calls": "not measured by replay", + } + + +def main(): + import tiktoken + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("transcript", type=Path) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--encoding", default="o200k_base") + parser.add_argument( + "--usage", + type=Path, + help="Optional observed provider usage JSON; retained separately, never inferred", + ) + args = parser.parse_args() + result = benchmark( + (json.loads(line) for line in args.transcript.read_text().splitlines()), + tiktoken.get_encoding(args.encoding), + ) + if args.usage: + result["observed_agent_usage"] = json.loads(args.usage.read_text()) + args.output.write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..bd77544 --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,122 @@ +"""Build an offline Windows/Python 3.12 release from a clean committed checkout.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path + + +def command(*args, **kwargs): + return subprocess.check_output(args, text=True, **kwargs).strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + repo = Path(__file__).resolve().parents[1] + if command("git", "status", "--porcelain", cwd=repo): + raise SystemExit("Commit or isolate changes before building a release") + commit = command("git", "rev-parse", "HEAD", cwd=repo) + epoch = command("git", "show", "-s", "--format=%ct", "HEAD", cwd=repo) + destination = args.output.resolve() + destination.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="nx-release-") as temporary: + root = Path(temporary) + archive = root / "source.zip" + subprocess.run( + ["git", "archive", "--format=zip", "-o", str(archive), commit], cwd=repo, check=True + ) + source = root / "source" + with zipfile.ZipFile(archive) as z: + z.extractall(source) + # The package version is a simple literal; avoids a tomllib dependency on Python 3.10. + namespace = {} + exec((source / "src/nx_mcp/__init__.py").read_text(), namespace) + version = namespace["__version__"] + bundle = root / f"nx-mcp-{version}-windows-py312" + bundle.mkdir() + shutil.copytree(source, bundle / "source") + wheels = bundle / "wheels" + wheels.mkdir() + env = dict(os.environ, SOURCE_DATE_EPOCH=epoch) + subprocess.run( + [ + sys.executable, + "-m", + "build", + "--wheel", + "--no-isolation", + "--outdir", + str(wheels), + str(source), + ], + env=env, + check=True, + ) + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "download", + "--require-hashes", + "--only-binary=:all:", + "--platform", + "win_amd64", + "--python-version", + "312", + "--implementation", + "cp", + "--dest", + str(wheels), + "-r", + str(source / "requirements-windows.lock"), + ], + check=True, + ) + shutil.copy2(source / "requirements-windows.lock", bundle) + for name in ("install_release.ps1", "restore_release.ps1"): + shutil.copy2(source / "scripts" / name, bundle) + metadata = { + "version": version, + "commit": commit, + "source_date_epoch": int(epoch), + "python": "3.12", + "platform": "win_amd64", + "upstream": "https://github.com/DreamEnding/NX_MCP", + "fork": "https://github.com/xuio/NX_MCP", + } + (bundle / "release.json").write_text(json.dumps(metadata, indent=2) + "\n") + files = { + p.relative_to(bundle).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(bundle.rglob("*")) + if p.is_file() + } + (bundle / "manifest.json").write_text(json.dumps(files, indent=2) + "\n") + output = destination / (bundle.name + ".zip") + # Fixed ZIP timestamps and sorted entries make archive metadata reproducible. + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as z: + for p in sorted(bundle.rglob("*")): + if p.is_file(): + info = zipfile.ZipInfo(p.relative_to(bundle).as_posix(), (2020, 1, 1, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + z.writestr(info, p.read_bytes()) + digest = hashlib.sha256(output.read_bytes()).hexdigest() + output.with_suffix(".zip.sha256").write_text(f"{digest} {output.name}\n") + print( + json.dumps({**metadata, "archive": str(output), "sha256": digest, "files": len(files)}) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/download_artifact.py b/scripts/download_artifact.py new file mode 100644 index 0000000..24577b2 --- /dev/null +++ b/scripts/download_artifact.py @@ -0,0 +1,67 @@ +"""Download NX artifact bytes directly to disk, outside an agent's textual context.""" + +import argparse +import asyncio +import base64 +import hashlib +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def download(url, remote, output): + async with streamablehttp_client(url) as (r, w, _), ClientSession(r, w) as client: + await client.initialize() + names = {t.name for t in (await client.list_tools()).tools} + + async def call(arguments): + result = ( + await client.call_tool( + "nx_invoke", + {"tool": "nx_download_file", "arguments": arguments, "detail": "full"}, + ) + if "nx_invoke" in names + else await client.call_tool("nx_download_file", arguments) + ) + if result.isError: + raise RuntimeError(result.structuredContent) + return result.structuredContent + + meta = await call({"path": remote, "delivery": "metadata"}) + temporary = output.with_suffix(output.suffix + ".partial") + digest = hashlib.sha256() + offset = 0 + # Exclusive temporary file prevents accidental overwrite or concurrent downloader races. + if output.exists(): + raise FileExistsError(output) + with temporary.open("xb") as stream: + while True: + chunk = await call({"path": remote, "delivery": "base64", "offset": offset}) + if chunk["sha256"] != meta["sha256"]: + raise RuntimeError("Artifact changed during transfer") + data = base64.b64decode(chunk["data_base64"], validate=True) + stream.write(data) + digest.update(data) + offset += len(data) + if chunk["eof"]: + break + if not data: + raise RuntimeError("Download made no progress") + if offset != meta["size"] or digest.hexdigest() != meta["sha256"]: + raise RuntimeError("Artifact checksum/size mismatch") + temporary.rename(output) + print(f"Downloaded {offset} bytes to {output}; SHA-256 {digest.hexdigest()}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path") + parser.add_argument("output", type=Path) + parser.add_argument("--url", default="http://127.0.0.1:8765/agent/mcp") + args = parser.parse_args() + asyncio.run(download(args.url, args.path, args.output)) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_capability_matrix.py b/scripts/generate_capability_matrix.py new file mode 100644 index 0000000..359ad08 --- /dev/null +++ b/scripts/generate_capability_matrix.py @@ -0,0 +1,127 @@ +"""Render evidence-scoped capability documentation without importing NX or the server.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GROUPS = ("Native-tested", "Contract/sidecar-tested", "Experimental", "Unavailable") + + +def classification(entry: dict) -> str: + """Fail closed: a tested status alone never establishes native evidence.""" + status = entry.get("status") + evidence = entry.get("evidence_type", "") + if status == "unavailable": + return "Unavailable" + if status != "tested": + return "Experimental" + if isinstance(evidence, str) and evidence.startswith("real_NX_"): + return "Native-tested" + if evidence in { + "local_contract_test", + "local_contract_tests", + "live_sidecar_and_contract_tests", + }: + return "Contract/sidecar-tested" + return "Experimental" + + +def cell(value: object) -> str: + return ( + str(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("|", "|") + .replace("\r\n", "
") + .replace("\n", "
") + ) + + +def render(manifest: dict) -> str: + canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + tools = manifest["tools"] + counts = Counter(classification(entry) for entry in tools.values()) + lines = [ + "# Capability evidence matrix", + "", + "Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by hand.", + "Run `python scripts/generate_capability_matrix.py` to regenerate, or add `--check` to detect drift.", + "", + f"Manifest revision: **{cell(manifest['revision'])}**. NX: **{cell(manifest['nx_version'])}**. " + f"Bridge protocol: **{cell(manifest['bridge_protocol'])}**.", + f"Canonical manifest SHA-256: `{hashlib.sha256(canonical).hexdigest()}`.", + "", + "These labels report manifest evidence, not certification or independent verification of its claims. " + "Native-tested means status `tested` with an evidence type beginning `real_NX_`; " + "only the stated scope and NX version are covered. Contract/sidecar-tested does not establish " + "native CAD correctness. Experimental includes untested entries and tested entries without a " + "recognized evidence type. Unavailable capabilities are explicitly recorded by the manifest; " + "absence from this matrix is not proof of availability or unavailability.", + "", + "| Tool classification | Count |", + "| --- | ---: |", + ] + lines.extend(f"| {group} | {counts[group]} |" for group in GROUPS) + lines.extend( + [ + "", + "## Tools", + "", + "| Tool | Classification | Manifest status | Evidence type | Tested scope / caveat |", + "| --- | --- | --- | --- | --- |", + ] + ) + for name, entry in sorted(tools.items()): + values = [ + name, + classification(entry), + entry.get("status", "missing"), + entry.get("evidence_type", "not recorded"), + entry.get("scope", "not recorded"), + ] + lines.append("| " + " | ".join(cell(value) for value in values) + " |") + lines.extend( + [ + "", + "## Explicitly unavailable capabilities", + "", + "These are capability names, separate from the tool counts above.", + "", + ] + ) + lines.extend(f"- {cell(name)}" for name in sorted(manifest.get("unavailable", []))) + if not manifest.get("unavailable"): + lines.append("None recorded.") + lines.extend(["", "## Manifest limitations", ""]) + lines.extend(f"- {cell(value)}" for value in manifest.get("limitations", [])) + return "\n".join(lines) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", type=Path, default=ROOT / "src/nx_mcp/capability_manifest.json" + ) + parser.add_argument("--output", type=Path, default=ROOT / "docs/capability-matrix.md") + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + result = render(json.loads(args.manifest.read_text(encoding="utf-8"))) + if args.check: + if not args.output.exists() or args.output.read_text(encoding="utf-8") != result: + print(f"Capability matrix is stale: {args.output}; regenerate with this script.") + return 1 + print("Capability matrix matches manifest.") + return 0 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(result, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/install_release.ps1 b/scripts/install_release.ps1 new file mode 100644 index 0000000..08ac5e4 --- /dev/null +++ b/scripts/install_release.ps1 @@ -0,0 +1,53 @@ +# Run after the NX bridge and its sidecar have stopped. Never stops unrelated NX sessions. +param( + [Parameter(Mandatory=$true)][string]$InstallRoot, + [Parameter(Mandatory=$true)][string]$BridgeDescriptor, + [string]$ReleaseRoot = $PSScriptRoot +) +$ErrorActionPreference = 'Stop' +if (Test-Path -LiteralPath $BridgeDescriptor) { throw 'Stop the NX bridge before replacing its runtime.' } +$release = Get-Content (Join-Path $ReleaseRoot 'release.json') -Raw | ConvertFrom-Json +$manifest = Get-Content (Join-Path $ReleaseRoot 'manifest.json') -Raw | ConvertFrom-Json +$prefix = [IO.Path]::GetFullPath($ReleaseRoot).TrimEnd('\') + '\' +foreach ($entry in $manifest.PSObject.Properties) { + $path = [IO.Path]::GetFullPath((Join-Path $ReleaseRoot $entry.Name)) + if (-not $path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Invalid manifest path' } + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry.Value) { throw "Checksum mismatch: $($entry.Name)" } +} +$python = Join-Path $InstallRoot 'venv\Scripts\python.exe' +& $python -c 'import sys; assert sys.version_info[:2] == (3,12), sys.version' +if ($LASTEXITCODE -ne 0) { throw 'This package requires Windows Python 3.12.' } +$backup = Join-Path $InstallRoot ('backups\release-' + (Get-Date -Format 'yyyyMMdd-HHmmss-fff')) +New-Item -ItemType Directory -Path $backup | Out-Null +foreach ($item in @('source', 'venv', 'release.json', 'install.json', 'validation-release.json')) { + $path = Join-Path $InstallRoot $item + if (Test-Path -LiteralPath $path) { Copy-Item -LiteralPath $path -Destination (Join-Path $backup $item) -Recurse } +} +Copy-Item (Join-Path $ReleaseRoot 'restore_release.ps1') $backup +try { + $staged = Join-Path $InstallRoot 'source-release-staging' + if (Test-Path $staged) { throw 'Resolve previous source staging before installing.' } + Copy-Item (Join-Path $ReleaseRoot 'source') $staged -Recurse + & $python -m pip install --no-index --find-links (Join-Path $ReleaseRoot 'wheels') --require-hashes -r (Join-Path $ReleaseRoot 'requirements-windows.lock') + if ($LASTEXITCODE -ne 0) { throw 'Locked dependency installation failed.' } + $wheel = Join-Path $ReleaseRoot ('wheels\nx_mcp-' + $release.version + '-py3-none-any.whl') + & $python -m pip install --no-index --no-deps --force-reinstall $wheel + if ($LASTEXITCODE -ne 0) { throw 'NX MCP wheel installation failed.' } + Remove-Item (Join-Path $InstallRoot 'source') -Recurse -Force + Move-Item $staged (Join-Path $InstallRoot 'source') + & $python -m pip check + if ($LASTEXITCODE -ne 0) { throw 'Installed dependency validation failed.' } + Copy-Item (Join-Path $ReleaseRoot 'release.json') (Join-Path $InstallRoot 'release.json') -Force + # A consolidated package supersedes any earlier validation overlay. + Remove-Item (Join-Path $InstallRoot 'validation-release.json') -Force -ErrorAction SilentlyContinue + $installMetadata = Join-Path $InstallRoot 'install.json' + if (Test-Path $installMetadata) { + $metadata = Get-Content $installMetadata -Raw | ConvertFrom-Json + $metadata.PSObject.Properties.Remove('ValidationCommit') + $metadata | ConvertTo-Json -Depth 10 | Set-Content $installMetadata -Encoding UTF8 + } + @{version=$release.version;commit=$release.commit;backup=$backup;installed_at=[DateTime]::UtcNow.ToString('o')} | ConvertTo-Json +} catch { + & (Join-Path $backup 'restore_release.ps1') -InstallRoot $InstallRoot -BackupRoot $backup -BridgeDescriptor $BridgeDescriptor + throw +} diff --git a/scripts/restore_release.ps1 b/scripts/restore_release.ps1 new file mode 100644 index 0000000..04a5409 --- /dev/null +++ b/scripts/restore_release.ps1 @@ -0,0 +1,21 @@ +param( + [Parameter(Mandatory=$true)][string]$InstallRoot, + [Parameter(Mandatory=$true)][string]$BackupRoot, + [Parameter(Mandatory=$true)][string]$BridgeDescriptor +) +$ErrorActionPreference = 'Stop' +if (Test-Path -LiteralPath $BridgeDescriptor) { throw 'Stop the NX bridge before restoring its runtime.' } +foreach ($required in @('source\src\nx_mcp\__init__.py','venv\Scripts\python.exe')) { + if (-not (Test-Path (Join-Path $BackupRoot $required))) { throw "Incomplete rollback package: $required" } +} +foreach ($item in @('source','venv','release.json','install.json','validation-release.json')) { + $source = Join-Path $BackupRoot $item + $destination = Join-Path $InstallRoot $item + if (Test-Path $source) { + if (Test-Path $destination) { Remove-Item -LiteralPath $destination -Recurse -Force } + Copy-Item -LiteralPath $source -Destination $destination -Recurse + } elseif ($item -in @('release.json','install.json','validation-release.json') -and (Test-Path $destination)) { + Remove-Item -LiteralPath $destination -Force + } +} +Write-Output "Restored runtime from $BackupRoot. Restart the sidecar and restore the saved part manifest." diff --git a/scripts/validate_agent_ux.py b/scripts/validate_agent_ux.py new file mode 100644 index 0000000..6dd60d0 --- /dev/null +++ b/scripts/validate_agent_ux.py @@ -0,0 +1,407 @@ +"""Run serial agent-UX fixtures against NX; retain artifacts and preserve loaded parts.""" + +import argparse +import asyncio +import base64 +import hashlib +import json +import math +import time +import uuid +from pathlib import Path +from types import SimpleNamespace + +from jsonschema import Draft202012Validator +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +class Client: + def __init__(self, session, output): + self.c = session + self.out = output + self.out.mkdir(parents=True, exist_ok=False) + self.schemas = {} + self.tools = {} + self.agent = False + + async def call(self, tool_name, **params): + if "operation_id" in self.schemas[tool_name].get("properties", {}): + params.setdefault("operation_id", "ux_" + uuid.uuid4().hex) + with (self.out / "operations.jsonl").open("a") as f: + f.write(json.dumps({"state": "submitted", "tool": tool_name, "params": params}) + "\n") + result = ( + await self.c.call_tool( + "nx_invoke", {"tool": tool_name, "arguments": params, "detail": "full"} + ) + if self.agent + else await self.c.call_tool(tool_name, params) + ) + with (self.out / "operations.jsonl").open("a") as f: + f.write( + json.dumps( + { + "state": "response", + "tool": tool_name, + "result": result.structuredContent, + "error": result.isError, + } + ) + + "\n" + ) + if result.isError: + raise RuntimeError((tool_name, result.structuredContent)) + return result.structuredContent + + async def artifact(self, meta, name): + data = bytearray() + while True: + result = await self.call("nx_download_file", path=meta["path"], offset=len(data)) + data.extend(base64.b64decode(result["data_base64"])) + if result["eof"]: + break + assert hashlib.sha256(data).hexdigest() == meta["sha256"] + (self.out / name).write_bytes(data) + + +async def run(url, output): + async with streamablehttp_client(url) as (r, w, _), ClientSession(r, w) as session: + await session.initialize() + client = Client(session, output) + catalog = (await session.list_tools()).tools + client.agent = any(t.name == "nx_invoke" for t in catalog) + if client.agent: + catalog = [] + offset = 0 + while True: + result = await session.call_tool( + "nx_discover_tools", {"include_schema": True, "offset": offset, "limit": 20} + ) + if result.isError: + raise RuntimeError(result.structuredContent) + catalog.extend(SimpleNamespace(**t) for t in result.structuredContent["tools"]) + offset = result.structuredContent["next_offset"] + if offset is None: + break + client.tools = {t.name: t for t in catalog} + client.schemas = {t.name: t.inputSchema for t in catalog} + await main(client) + + +async def main(c): + tools = c.tools + original_call = c.call + records = [] + phase = "inventory" + + async def call(n, **p): + start = time.monotonic() + result = await original_call(n, **p) + Draft202012Validator(tools[n].outputSchema).validate(result) + records.append( + { + "phase": phase, + "tool": n, + "seconds": time.monotonic() - start, + "json_characters": len(json.dumps(result)), + } + ) + (c.out / "metrics.json").write_text(json.dumps(records, indent=2)) + return result + + c.call = call + before = (await call("nx_list_open_parts"))["parts"] + assert not any(p["modified"] for p in before), "Native UX fixtures require saved original parts" + original = next(p for p in before if p["work"]) + assert original["display"], "Activate the original assembly as both work and display part" + full = await call("nx_list_components") + small = await call("nx_list_components", compact=True, include_transforms=False) + assert [x["object"]["id"] for x in full["components"]] == [ + x["object"]["id"] for x in small["components"] + ] + assert all("translation" not in x for x in small["components"]) + compact = await call("nx_list_open_parts", compact=True, active_only=True) + assert compact["count"] == 1 and compact["parts"][0]["part"]["id"] == original["part"]["id"] + page = await call("nx_list_components", compact=True, offset=1, limit=2) + assert page["count"] == len(full["components"][1:3]) and page["total_count"] == len( + full["components"] + ) + prefix = "validation/dev16-ux-" + uuid.uuid4().hex[:8] + report = {"prefix": prefix, "checks": []} + + async def close_fixtures(): + parts = (await call("nx_list_open_parts"))["parts"] + # Close assemblies before their prototypes; NX may unload unused dependencies. + for p in reversed(parts): + if prefix in p["path"].replace("\\", "/"): + live = (await call("nx_list_open_parts"))["parts"] + match = next((x for x in live if x["path"] == p["path"]), None) + if match: + await call("nx_close_part", part=match["part"]["id"], save=True) + + async def rectangle(x, y): + sk = (await call("nx_create_sketch"))["object"]["id"] + await call( + "nx_sketch_rectangle", sketch_id=sk, corner1={"x": 0, "y": 0}, corner2={"x": x, "y": y} + ) + await call("nx_sketch_diagnostics", sketch_id=sk) + await call("nx_finish_sketch", sketch_id=sk) + return sk + + try: + phase = "exploded" + await call("nx_create_part", path=prefix + "/block.prt", units="mm") + sk = await rectangle(20, 10) + body = (await call("nx_extrude", sketch_id=sk, distance=4))["bodies"][0]["id"] + datums = await call("nx_list_datums") + assert datums["count"] > 0 + hidden = await call("nx_set_datum_visibility", visible=False) + assert all(x["blanked"] for x in hidden["objects"]) + await call("nx_restore_display", restore_id=hidden["restore_id"]) + rs = await call("nx_create_reference_set", name="SOLIDS", objects=[body]) + assert rs["member_count"] == 1 + sets = await call("nx_list_reference_sets") + assert any(x["name"] == "SOLIDS" for x in sets["reference_sets"]) + await call("nx_save_part") + await call("nx_create_part", path=prefix + "/assembly.prt", units="mm") + await call("nx_set_datum_visibility", visible=False) + comps = [] + for i in range(3): + comps.append( + ( + await call( + "nx_add_component", + part_path=prefix + "/block.prt", + name="block" + str(i), + translation=[30 * i, 0, 0], + ) + )["object"]["id"] + ) + before_poses = (await call("nx_list_components"))["components"] + changed = await call("nx_set_component_reference_set", components=comps, name="SOLIDS") + assert changed["count"] == 3 + after_poses = (await call("nx_list_components"))["components"] + assert [x["translation"] for x in before_poses] == [x["translation"] for x in after_poses] + assert all(x["reference_set"] == "SOLIDS" for x in after_poses) + explosion = (await call("nx_create_explosion", name="Service"))["object"]["id"] + await call( + "nx_edit_explosion", + explosion=explosion, + placements=[ + {"component": r, "translation": [30 * i, 0, 20 * i]} for i, r in enumerate(comps) + ], + ) + sheet = (await call("nx_create_drawing", name="Service review", size="A3"))["object"]["id"] + view = ( + await call( + "nx_add_base_view", + drawing=sheet, + scope="assembly", + explosion=explosion, + view="isometric", + position=[125, 150], + ) + )["object"]["id"] + bom = ( + await call("nx_create_parts_list", drawing=sheet, position=[275, 250], scope="leaves") + )["parts_list"]["id"] + await call("nx_parts_list_balloons", parts_list=bom, view=view) + await call("nx_drawing_view_info", view=view) + await call("nx_list_drawings") + await call("nx_save_part") + pdf = await call("nx_export_drawing_pdf", path=prefix + "/review.pdf") + await c.artifact(pdf, "review.pdf") + report["checks"].append( + "body-only reference sets assigned without pose changes; exploded drawing exported" + ) + phase = "sheet_metal" + await call("nx_create_part", path=prefix + "/sheet.prt", units="mm") + await call("nx_sheet_metal_context") + await call("nx_set_sheet_metal_defaults", thickness=2, bend_radius=3, neutral_factor=0.33) + sk = await rectangle(100, 80) + body = ( + await call( + "nx_sheet_metal_feature", + operation="tab", + parameters={"section": sk, "thickness": 2}, + ) + )["body"]["id"] + entries = [] + for point, width in [ + ([50, 0, 0], 80), + ([50, 80, 0], 80), + ([0, 40, 0], 60), + ([100, 40, 0], 60), + ]: + edge = ( + await call( + "nx_find_geometry", + owner=body, + kind="edge", + geometry_type="line", + near=point, + limit=1, + ) + )["items"][0]["object"]["id"] + entries.append( + { + "edges": [edge], + "length": 20, + "length_reference": "Inside", + "angle": 90, + "width_option": "AtCenter", + "width": width, + "bend_options": { + "bend_relief_type": "Square", + "use_global_relief_width": False, + "bend_relief_width": 1, + "use_global_relief_depth": False, + "bend_relief_depth": 3, + }, + } + ) + body = ( + await call( + "nx_sheet_metal_feature", operation="flange", parameters={"flanges": entries} + ) + )["body"]["id"] + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + assert info["bend_count"] == 4 and math.isclose(info["thickness"], 2) + formed = (await call("nx_measure_volume"))["volume_mm3"] + stationary = ( + await call( + "nx_find_geometry", + owner=body, + kind="face", + geometry_type="plane", + near=[50, 40, 0], + limit=1, + ) + )["items"][0]["object"]["id"] + body = ( + await call( + "nx_sheet_metal_feature", + operation="unbend", + parameters={ + "face_collector": [info["bends"][0]["face"]["id"]], + "reference_entity": stationary, + }, + ) + )["body"]["id"] + info = (await call("nx_sheet_metal_info", body=body))["items"][0] + stationary = ( + await call( + "nx_find_geometry", + owner=body, + kind="face", + geometry_type="plane", + near=[50, 40, 0], + limit=1, + ) + )["items"][0]["object"]["id"] + body = ( + await call( + "nx_sheet_metal_feature", + operation="rebend", + parameters={ + "face_collector": [x["face"]["id"] for x in info["bends"]], + "reference_entity": stationary, + }, + ) + )["body"]["id"] + assert math.isclose((await call("nx_measure_volume"))["volume_mm3"], formed, rel_tol=1e-7) + face = ( + await call( + "nx_find_geometry", + owner=body, + kind="face", + geometry_type="plane", + near=[50, 40, 0], + limit=1, + ) + )["items"][0]["object"]["id"] + topology = await call( + "nx_list_topology", body=body, face=face, include_adjacency=True, compact=True + ) + candidates = await call("nx_flat_pattern_orientation_edges", upward_face=face) + assert candidates["count"] > 0 + # Prefer the longest straight web edge: deterministic geometric selection. + selected = max(candidates["edges"], key=lambda x: math.dist(x["start"], x["end"])) + assert selected["edge"]["id"] in topology["face_edges"][0]["edges"] + flat = await call( + "nx_sheet_metal_feature", + operation="flat_pattern", + parameters={ + "upward_face": face, + "x_axis_edge": selected["edge"]["id"], + "associative": True, + }, + ) + dxf = await call( + "nx_export_flat_pattern", flat_pattern=flat["feature"]["id"], path=prefix + "/sheet.dxf" + ) + await c.artifact(dxf, "sheet.dxf") + await call("nx_sheet_metal_info", body=body) + await call("nx_save_part") + report["checks"].append( + "flat pattern committed first attempt using discovered face-boundary edge" + ) + phase = "imported" + await call("nx_create_part", path=prefix + "/seed.prt", units="mm") + sk = await rectangle(20, 15) + await call("nx_extrude", sketch_id=sk, distance=10) + step = await call("nx_export_step", path=prefix + "/seed.step") + await call("nx_create_part", path=prefix + "/import.prt", units="mm") + imported = await call("nx_import_geometry", path=step["path"]) + (c.out / "import-result.json").write_text(json.dumps(imported, indent=2)) + body = (await call("nx_list_bodies"))["objects"][0]["id"] + face = ( + await call( + "nx_find_geometry", + owner=body, + kind="face", + geometry_type="plane", + normal=[0, 0, 1], + order="highest", + limit=1, + ) + )["items"][0]["object"]["id"] + # Use the already established native imported-face edit contract. + await call("nx_edit_faces", faces=[face], action="move", distance=2, direction=[0, 0, 1]) + assert math.isclose((await call("nx_measure_volume"))["volume_mm3"], 3600, rel_tol=1e-7) + await call("nx_save_part") + await call("nx_close_part", save=False) + await call("nx_open_part", path=prefix + "/import.prt") + assert math.isclose((await call("nx_measure_volume"))["volume_mm3"], 3600, rel_tol=1e-7) + report["checks"].append("imported-face edit 3000 to 3600 mm3 persisted through reopen") + report["passed"] = True + finally: + phase = "cleanup" + await close_fixtures() + await call("nx_activate_part", part=original["part"]["id"]) + after = (await call("nx_list_open_parts"))["parts"] + assert sorted(p["path"] for p in before) == sorted(p["path"] for p in after) and not any( + p["modified"] for p in after + ) + comps = (await call("nx_list_components"))["components"] + fields = ( + "name", + "part_path", + "translation", + "rotation_matrix", + "suppressed", + "reference_set", + ) + assert [{k: x[k] for k in fields} for x in full["components"]] == [ + {k: x[k] for k in fields} for x in comps + ] + report["session_preserved"] = {"parts": len(after), "occurrences": len(comps)} + (c.out / "result.json").write_text(json.dumps(report, indent=2)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default="http://127.0.0.1:8765/mcp") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + asyncio.run(run(args.url, args.output)) diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py new file mode 100644 index 0000000..783d4c1 --- /dev/null +++ b/scripts/validate_native_release.py @@ -0,0 +1,203 @@ +"""Run release acceptance serially against a live graphical NX session. + +Run only trusted source from a reviewed release. This never deploys or restarts NX. +Requires NX_MCP_URL and NX_VENDOR_STEP. Stops at the first failed suite and retains +all logs/receipts; no blind mutation retry, no parallel NX calls. +""" + +import argparse +import asyncio +import hashlib +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + +SUITES = [ + ("hard_geometry", "validate_hard_geometry.py", "hard-geometry-validation.json"), + ( + "release_engineering", + "validate_release_engineering.py", + "release-engineering-validation.json", + ), + ( + "documentation", + "validate_documentation_manufacturing.py", + "documentation-manufacturing-validation.json", + ), + ("documentation", "validate_annotation_recovery.py", "refresh-check.json"), + ("freeform", "validate_freeform_manufacturing.py", "freeform-manufacturing-validation.json"), + ("sheet_metal", "validate_sheet_metal.py", "sheet-metal-validation.json"), +] + + +def manifest_files(root): + return [ + { + "path": p.relative_to(root).as_posix(), + "size": p.stat().st_size, + "sha256": hashlib.sha256(p.read_bytes()).hexdigest(), + } + for p in sorted(root.rglob("*")) + if p.is_file() and p.name != "release-validation.json" + ] + + +def verify_session(before, after): + def parts(snapshot): + return sorted( + (p["path"], p["work"], p["display"], p["modified"]) for p in snapshot["parts"] + ) + + if parts(before) != parts(after): + raise RuntimeError("Original open parts, work/display state or saved state changed") + if before["components"] != after["components"]: + raise RuntimeError("Original component source paths or transforms changed") + + +async def snapshot(): + async with ( + streamablehttp_client(os.environ["NX_MCP_URL"]) as (read, write, _), + ClientSession(read, write) as client, + ): + await client.initialize() + + async def call(name): + result = await client.call_tool(name, {}) + if result.isError: + raise RuntimeError((name, result.structuredContent)) + return result.structuredContent + + status = await call("nx_status") + if status["ui"]["mode"] != "agent": + raise RuntimeError("NX must be in agent mode before native acceptance") + parts = (await call("nx_list_open_parts"))["parts"] + if not parts or any(p["modified"] for p in parts): + raise RuntimeError( + "Keep an original saved part open and save existing work before acceptance" + ) + components = await call("nx_list_components") + + # Strip session-scoped IDs, preserve all returned source paths/poses. + def stable(value): + if isinstance(value, dict): + return { + k: stable(v) + for k, v in value.items() + if k + not in { + "id", + "part_id", + "session_id", + "generation_id", + "operation_id", + "warnings", + "mutation_outcome", + "status", + } + } + if isinstance(value, list): + return [stable(v) for v in value] + return value + + return { + "parts": parts, + "components": stable(components), + "nx_version": status["nx_version"], + "tool_count": len((await client.list_tools()).tools), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--expected-tool-count", type=int, default=189) + args = parser.parse_args() + if args.output.exists(): + raise RuntimeError( + "Use a new output directory so receipts cannot be confused with an earlier run" + ) + if not Path(os.environ["NX_VENDOR_STEP"]).is_file(): + raise RuntimeError("NX_VENDOR_STEP must identify an authorized local STEP fixture") + args.output.mkdir(parents=True) + report = {"started": datetime.now(timezone.utc).isoformat(), "suites": [], "passed": False} + source = Path(__file__).resolve().parents[1] + metadata = source.parent / "release.json" + if metadata.is_file(): + report["installed_release"] = json.loads(metadata.read_text()) + report["runtime_source"] = [ + f + for f in manifest_files(source / "src" / "nx_mcp") + if Path(f["path"]).suffix in {".py", ".json"} + ] + before = None + try: + before = asyncio.run(snapshot()) + report["before"] = before + if before["tool_count"] != args.expected_tool_count: + raise RuntimeError("Deployed tool count differs from the selected release") + for directory, script, receipt_name in SUITES: + output = args.output / directory + output.mkdir(exist_ok=True) + log = args.output / (Path(script).stem + ".log") + with log.open("w") as stream: + result = subprocess.run( + [sys.executable, str(source / "examples" / script)], + env={ + **os.environ, + "NX_VALIDATION_OUTPUT": str(output.absolute()), + "NX_EXPECTED_TOOL_COUNT": str(args.expected_tool_count), + }, + stdout=stream, + stderr=subprocess.STDOUT, + check=False, + ) + item = { + "script": script, + "exit_code": result.returncode, + "receipt": (output / receipt_name).relative_to(args.output).as_posix(), + } + report["suites"].append(item) + if result.returncode: + raise RuntimeError(f"Native acceptance failed: {script}; inspect {log}") + receipt = json.loads((output / receipt_name).read_text()) + if not receipt.get("passed", receipt.get("pass", False)) or not receipt.get( + "session_restored", False + ): + raise RuntimeError(f"Missing success or session restoration evidence: {script}") + report["passed"] = True + except Exception as error: + report["error"] = str(error) + raise + finally: + try: + if before is not None: + report["after"] = asyncio.run(snapshot()) + verify_session(before, report["after"]) + report["session_restored"] = True + except Exception as error: + report["passed"] = False + report["restoration_error"] = str(error) + raise + finally: + report["artifacts"] = manifest_files(args.output) + report["finished"] = datetime.now(timezone.utc).isoformat() + (args.output / "release-validation.json").write_text(json.dumps(report, indent=2)) + print( + json.dumps( + { + "passed": report["passed"], + "suites": len(report["suites"]), + "report": str(args.output / "release-validation.json"), + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_output_contracts.py b/scripts/validate_output_contracts.py new file mode 100644 index 0000000..e57a113 --- /dev/null +++ b/scripts/validate_output_contracts.py @@ -0,0 +1,74 @@ +"""Validate fresh MCP response schemas on the saved active assembly, read-only.""" + +import argparse +import asyncio +import json +from pathlib import Path + +from jsonschema import Draft202012Validator +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client + + +async def validate(url, output): + report = {"checks": [], "passed": False} + async with streamablehttp_client(url) as (read, write, _), ClientSession(read, write) as client: + await client.initialize() + tools = {t.name: t for t in (await client.list_tools()).tools} + for tool in tools.values(): + assert tool.outputSchema, tool.name + Draft202012Validator.check_schema(tool.outputSchema) + report["tool_count"] = len(tools) + report["typed_payload_count"] = sum( + "Tool-specific payload is typed." in t.outputSchema.get("description", "") + for t in tools.values() + ) + + async def call(name, params=None, error=False): + result = await client.call_tool(name, params or {}) + assert bool(result.isError) == error, (name, result.structuredContent) + Draft202012Validator(tools[name].outputSchema).validate(result.structuredContent) + report["checks"].append(name + (":error" if error else ":success")) + return result.structuredContent + + before = await call("nx_list_open_parts") + try: + await call("nx_status") + components = (await call("nx_list_components"))["components"] + await call("nx_get_bounding_box") + await call("nx_measure_volume") + if len(components) >= 2: + pair = { + "obj1": components[0]["object"]["id"], + "obj2": components[1]["object"]["id"], + } + await call("nx_measure_distance", pair) + await call("nx_check_interference", pair) + await call("nx_checkpoint_state") + await call("nx_operation_status", {"operation_id": "contract_unknown_20260906"}) + await call("nx_workspace_list", {"limit": 2}) + await call( + "nx_workspace_list", {"path": "contract-absent-directory-20260906"}, error=True + ) + await call("nx_revolve", error=True) + report["passed"] = True + finally: + after = await call("nx_list_open_parts") + fields = ("path", "work", "display", "modified") + + def project(r): + return sorted(tuple(p[k] for k in fields) for p in r["parts"]) + + report["session_preserved"] = project(before) == project(after) + report["passed"] = report["passed"] and report["session_preserved"] + output.write_text(json.dumps(report, indent=2) + "\n") + assert report["passed"], report + print(json.dumps(report)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--url", default="http://127.0.0.1:8765/mcp") + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + asyncio.run(validate(args.url, args.output)) diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index fa6c364..1205933 100644 --- a/src/nx_mcp/__init__.py +++ b/src/nx_mcp/__init__.py @@ -1,3 +1,3 @@ """NX MCP Server - MCP tools for Siemens NX CAD operations.""" -__version__ = "0.2.0.dev0" +__version__ = "0.2.0.dev23" diff --git a/src/nx_mcp/advanced_authoring.py b/src/nx_mcp/advanced_authoring.py new file mode 100644 index 0000000..7aa6621 --- /dev/null +++ b/src/nx_mcp/advanced_authoring.py @@ -0,0 +1,773 @@ +"""NX 2606 native patterns, geometric rules and constrained parameter editing.""" + +from __future__ import annotations + +import contextlib +import math + +from nx_mcp.authoring import finite, page +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import enum_name, unit_normal + + +class AdvancedAuthoringMixin: + def _geometry_owner_locator(self, owner): + obj = self._resolve(owner, {"body", "feature", "component"}) + kind = ( + "body" + if isinstance(obj, self.nxopen.Body) + else "feature" + if isinstance(obj, self.nxopen.Features.Feature) + else "component" + ) + return self._locator(obj, kind) + + def _resolve_geometry(self, selector, tie_tolerance=0.001): + tol = finite(tie_tolerance, "tie_tolerance", True) + fields = {"kind", "geometry_type", "normal", "radius", "near", "order", "axis", "tolerance"} + if ( + not isinstance(selector, dict) + or set(selector) != {"version", "owner_part", "owner", "query"} + or selector["version"] != 1 + or not isinstance(selector["owner_part"], str) + or not isinstance(selector["query"], dict) + or set(selector["query"]) != fields + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use the complete version 1 selector from nx_find_geometry" + ) + if selector["owner_part"].casefold() != self._work_part().FullPath.casefold(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Selector belongs to another part") + owner = selector["owner"] + if owner is not None: + if ( + not isinstance(owner, dict) + or set(owner) != {"kind", "journal_id", "owner_part"} + or owner["kind"] not in {"body", "feature", "component"} + or not isinstance(owner["journal_id"], str) + or not isinstance(owner["owner_part"], str) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Invalid selector owner") + obj = self._locate(owner) + owner = self._reference(obj, owner["kind"], self._work_part(), "Owner")["id"] + result = self._find_geometry(owner=owner, **selector["query"], limit=2) + if not result["items"]: + raise NXToolError("NX_NOT_FOUND", "No geometry satisfies the saved rule") + if ( + len(result["items"]) > 1 + and abs(result["items"][0]["rank_value"] - result["items"][1]["rank_value"]) <= tol + ): + raise NXToolError( + "NX_AMBIGUOUS_REFERENCE", + "Best geometry candidates are tied; narrow the selection rule", + details={"candidates": result["items"]}, + ) + return { + "match": result["items"][0], + "selector": selector, + "resolution": "reevaluated geometric rule", + "units": self._units(), + "coordinate_frame": "work_part", + } + + def _recognize_holes(self, owner=None, offset=0, limit=50): + import NXOpen.UF + + from nx_mcp.hardened import dot + + uf = NXOpen.UF.UFSession.GetUFSession() + self._require_api(uf.Modeling, "AskFaceUvMinmax") + rows = [] + start = 0 + while True: + batch = self._find_geometry( + owner=owner, geometry_type="cylinder", order="lowest", offset=start, limit=200 + ) + for row in batch["items"]: + if row["cylindrical_role"] != "bore": + continue + face = self._resolve(row["object"]["id"], {"face"}) + uv = uf.Modeling.AskFaceUvMinmax(face.Tag) + coverage = abs(uv[1] - uv[0]) + row.update( + angular_coverage_radians=coverage, + full_circumference=abs(coverage - 2 * math.pi) <= 1e-6, + ) + rows.append(row) + if batch["next_offset"] is None: + break + start = batch["next_offset"] + groups = [] + for row in rows: + axis = unit_normal(row["axis"]) + for group in groups: + delta = [a - b for a, b in zip(row["axis_point"], group["axis_point"], strict=True)] + axial = dot(delta, group["axis"]) + radial = math.sqrt(max(0, dot(delta, delta) - axial * axial)) + if abs(dot(axis, group["axis"])) >= 1 - 1e-8 and radial <= 0.001: + break + else: + group = { + "group": len(groups), + "axis": axis, + "axis_point": row["axis_point"], + "faces": [], + } + groups.append(group) + group["faces"].append(row["object"]) + row["coaxial_group"] = group["group"] + return { + **page(rows, offset, limit), + "coaxial_groups": groups, + "units": self._units(), + "coordinate_frame": "work_part", + "recognition": "inward cylindrical BREP faces", + "limitations": [ + "No blind/through, thread or manufacturing-feature inference; partial faces are reported." + ], + } + + def _component_pattern_record(self, pattern): + import NXOpen.GeometricUtilities + + from nx_mcp.hardened import rows, xyz + + part = self._work_part() + builder = part.ComponentAssembly.CreateComponentPatternBuilder(pattern) + try: + service = builder.PatternService + linear = ( + service.PatternType + == NXOpen.GeometricUtilities.PatternDefinition.PatternEnum.Linear + ) + result = { + "object": self._reference(pattern, "component_pattern", part, "Component pattern"), + "native_type": "NXOpen.Assemblies.ComponentPattern", + "pattern_type": "linear" + if linear + else enum_name(service.PatternType, service.PatternEnum), + "associative": bool(builder.Associative), + } + if linear: + spacing = service.RectangularDefinition.XSpacing + result.update( + count_expression=self._expression_record(spacing.NCopies), + spacing_expression=self._expression_record(spacing.PitchDistance), + count_includes_seed=True, + ) + if linear: + d = service.RectangularDefinition + result["second_direction_enabled"] = d.UseYDirectionToggle + if d.UseYDirectionToggle: + result["count_y_expression"] = self._expression_record(d.YSpacing.NCopies) + result["spacing_y_expression"] = self._expression_record( + d.YSpacing.PitchDistance + ) + elif service.PatternType == service.PatternEnum.Circular: + d = service.CircularDefinition + result["count_expression"] = self._expression_record(d.AngularSpacing.NCopies) + result["angle_expression"] = self._expression_record(d.AngularSpacing.PitchAngle) + result["count_includes_seed"] = True + components = {int(c.Tag): c for c in pattern.GetComponentsToPattern()} + for member in pattern.GetAllPatternMembers(): + for c in member.GetAllComponents(): + components[int(c.Tag)] = c + result["instances"] = [] + for c in components.values(): + p, m = c.GetPosition() + result["instances"].append( + { + "object": self._reference(c, "component", part, "Component"), + "translation": xyz(p), + "rotation_matrix": rows(m), + "suppressed": bool(c.IsSuppressed), + } + ) + result["total_instances"] = len(components) + return result + finally: + builder.Destroy() + + def _component_patterns(self, part): + assembly = part.ComponentAssembly + if not assembly.RootComponent or not hasattr(assembly, "ComponentPatterns"): + return [] + self._require_api(assembly.ComponentPatterns, "GetAllComponentPatterns") + return list(assembly.ComponentPatterns.GetAllComponentPatterns()) + + def _list_component_patterns(self): + assembly = self._work_part().ComponentAssembly + self._require_api(assembly, "ComponentPatterns", "CreateComponentPatternBuilder") + patterns = [ + self._component_pattern_record(p) for p in self._component_patterns(self._work_part()) + ] + return { + "patterns": patterns, + "count": len(patterns), + "units": self._units(), + "coordinate_frame": "work_part", + } + + def _pattern_inputs(self, spacing, count): + if count is not None and (type(count) is not int or not 2 <= count <= 100): + raise NXToolError( + "NX_INVALID_ARGUMENT", "count must be integer 2–100 including the seed" + ) + if spacing is not None: + finite(spacing, "spacing", True) + + def _native_component_pattern(self, component, direction, spacing, count): + import NXOpen.GeometricUtilities + + self._pattern_inputs(spacing, count) + axis = unit_normal(direction) + part = self._work_part() + seed = self._resolve(component, {"component"}) + if seed.Parent != part.ComponentAssembly.RootComponent or seed.IsSuppressed: + raise NXToolError( + "NX_UNSUPPORTED_SCOPE", "Seed must be an unsuppressed immediate child" + ) + self._require_api(part.ComponentAssembly, "CreateComponentPatternBuilder") + b = part.ComponentAssembly.CreateComponentPatternBuilder(None) + try: + b.Associative = True + b.ComponentPatternSet.Add(seed) + b.PatternService.PatternType = ( + NXOpen.GeometricUtilities.PatternDefinition.PatternEnum.Linear + ) + d = b.PatternService.RectangularDefinition + d.XDirection = part.Directions.CreateDirection( + self.nxopen.Point3d(0.0, 0.0, 0.0), + self.nxopen.Vector3d(*axis), + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + d.XSpacing.NCopies.RightHandSide = str(count) + d.XSpacing.PitchDistance.RightHandSide = str(float(spacing)) + d.YSpacing.NCopies.RightHandSide = "1" + pattern = b.Commit() + finally: + b.Destroy() + self._update_model() + result = self._component_pattern_record(pattern) + if result["total_instances"] != count or not result["associative"]: + raise NXToolError( + "NX_PATTERN_VERIFICATION_FAILED", + "Native association or member count differs; rolling back", + ) + return result + + def _edit_component_pattern( + self, pattern, spacing=None, count=None, count_y=None, spacing_y=None, angle=None + ): + if all(v is None for v in [spacing, count, count_y, spacing_y, angle]): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply at least one parameter") + self._pattern_inputs(spacing, count) + if count_y is not None and (type(count_y) is not int or not 1 <= count_y <= 100): + raise NXToolError("NX_INVALID_ARGUMENT", "count_y must be 1–100") + if spacing_y is not None: + spacing_y = finite(spacing_y, "spacing_y", True) + if angle is not None: + angle = finite(angle, "angle", True) + obj = self._resolve(pattern, {"component_pattern"}) + b = self._work_part().ComponentAssembly.CreateComponentPatternBuilder(obj) + try: + service = b.PatternService + if not b.Associative or len(obj.GetComponentsToPattern()) != 1: + raise NXToolError( + "NX_UNSUPPORTED_EDIT", "Select an associative single-seed pattern" + ) + if service.PatternType == service.PatternEnum.Linear: + if angle is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "angle requires a circular pattern") + d = service.RectangularDefinition + nx = count if count is not None else int(d.XSpacing.NCopies.Value) + ny = ( + count_y + if count_y is not None + else int(d.YSpacing.NCopies.Value) + if d.UseYDirectionToggle + else 1 + ) + if ny > 1 and not d.UseYDirectionToggle: + raise NXToolError( + "NX_UNSUPPORTED_EDIT", + "Create a two-direction array before increasing its second-direction count", + ) + if spacing_y is not None and ny == 1: + raise NXToolError( + "NX_INVALID_ARGUMENT", "spacing_y requires a second direction" + ) + expected = nx * ny + if expected > 100: + raise NXToolError("NX_INVALID_ARGUMENT", "At most 100 total instances") + if count is not None: + d.XSpacing.NCopies.RightHandSide = str(count) + if spacing is not None: + d.XSpacing.PitchDistance.RightHandSide = str(float(spacing)) + if count_y is not None: + d.YSpacing.NCopies.RightHandSide = str(count_y) + d.UseYDirectionToggle = count_y > 1 + if spacing_y is not None: + d.YSpacing.PitchDistance.RightHandSide = str(spacing_y) + elif service.PatternType == service.PatternEnum.Circular: + if any(v is not None for v in [spacing, count_y, spacing_y]): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Circular edits accept count and angle only" + ) + d = service.CircularDefinition + expected = count if count is not None else int(d.AngularSpacing.NCopies.Value) + pitch = angle if angle is not None else d.AngularSpacing.PitchAngle.Value + if (expected - 1) * pitch >= 360: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Angular positions must not duplicate the seed" + ) + if count is not None: + d.AngularSpacing.NCopies.RightHandSide = str(count) + if angle is not None: + d.AngularSpacing.PitchAngle.RightHandSide = str(angle) + else: + raise NXToolError( + "NX_UNSUPPORTED_EDIT", "Only rectangular and circular patterns are supported" + ) + b.Commit() + finally: + b.Destroy() + self._update_model() + result = self._component_pattern_record(obj) + if result["total_instances"] != expected: + raise NXToolError( + "NX_PATTERN_VERIFICATION_FAILED", "Native member count differs; rolling back" + ) + return result + + def _feature_parameters(self, feature): + f = self._resolve(feature, {"feature"}) + return { + "feature": self._reference(f, "feature", self._work_part(), "Feature"), + "feature_type": f.FeatureType, + "parameters": [self._expression_record(e) for e in f.GetExpressions()], + } + + def _set_feature_parameters(self, feature, values): + f = self._resolve(feature, {"feature"}) + if not isinstance(values, dict) or not 1 <= len(values) <= 25: + raise NXToolError( + "NX_INVALID_ARGUMENT", "values must contain 1–25 expression/formula pairs" + ) + owned = {int(e.Tag) for e in f.GetExpressions()} + prepared = [] + seen = set() + for key, formula in values.items(): + exp = self._expression(key) + if int(exp.Tag) not in owned: + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Expression is not owned by this feature" + ) + if int(exp.Tag) in seen: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Duplicate expression specified by ID and name" + ) + seen.add(int(exp.Tag)) + if not self._expression_record(exp)["editable"] or exp.Type != "Number": + raise NXToolError( + "NX_EXPRESSION_READ_ONLY", + "Only editable local Number expressions are supported", + ) + if not isinstance(formula, str) or not formula.strip() or len(formula) > 4096: + raise NXToolError("NX_INVALID_ARGUMENT", "Each formula requires 1–4096 characters") + prepared.append((exp, formula)) + for exp, formula in prepared: + self._work_part().Expressions.EditExpression(exp, formula) + self._update_model() + return self._feature_parameters(feature) + + @contextlib.contextmanager + def _editing_sketch(self, sketch): + active = self.session.ActiveSketch + if active and active != sketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the other active sketch") + try: + if not active: + sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue) + yield + finally: + if not active and self.session.ActiveSketch == sketch: + sketch.Deactivate( + self.nxopen.Sketch.ViewReorient.FalseValue, self.nxopen.Sketch.UpdateLevel.Model + ) + + def _owned_curve(self, sketch, ref): + curve = self._resolve(ref, {"curve"}) + if int(curve.Tag) not in {int(c.Tag) for c in sketch.GetAllGeometry()}: + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Curve is not owned by this sketch") + return curve + + def _sketch_local_point(self, sketch, value): + if not isinstance(value, list) or len(value) != 2: + raise NXToolError("NX_INVALID_ARGUMENT", "origin requires local [x,y]") + x, y = [finite(v, "origin") for v in value] + frame = self._sketch_frame(sketch) + return self.nxopen.Point3d( + *[ + frame["origin"][i] + x * frame["x_axis"][i] + y * frame["y_axis"][i] + for i in range(3) + ] + ) + + def _check_sketch_result(self, sketch_id): + self._update_model() + diagnostics = self._sketch_diagnostics(sketch_id) + if diagnostics["solver_status"] in { + "OverConstrained", + "InconsistentlyConstrained", + } or self._explicit_constraint_conflicts(self._resolve(sketch_id, {"sketch"})): + raise NXToolError( + "NX_SKETCH_CONFLICT", + "Constraint edit conflicts with the sketch; rolling back", + details={"diagnostics": diagnostics}, + ) + return diagnostics + + def _sketch_dimension(self, sketch_id, curve, dimension_type, value, origin, reference=False): + sketch = self._resolve(sketch_id, {"sketch"}) + obj = self._owned_curve(sketch, curve) + val = finite(value, "value", True) + if type(reference) is not bool: + raise NXToolError("NX_INVALID_ARGUMENT", "reference must be boolean") + radial = dimension_type in {"radius", "diameter"} + if dimension_type not in { + "length", + "horizontal", + "vertical", + "radius", + "diameter", + } or not isinstance(obj, self.nxopen.Arc if radial else self.nxopen.Line): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Dimension requires a line or arc of the documented type" + ) + point = self._sketch_local_point(sketch, origin) + one = self.nxopen.Sketch.DimensionGeometry() + one.Geometry = obj + mode = ( + self.nxopen.Sketch.DimensionOption.CreateAsReference + if reference + else self.nxopen.Sketch.DimensionOption.CreateAsDriving + ) + with self._editing_sketch(sketch): + if radial: + fn = ( + sketch.CreateRadialDimension + if dimension_type == "radius" + else sketch.CreateDiameterDimension + ) + constraint = fn(one, point, None, mode) + else: + one.AssocType = self.nxopen.Sketch.AssocType.StartPoint + two = self.nxopen.Sketch.DimensionGeometry() + two.Geometry = obj + two.AssocType = self.nxopen.Sketch.AssocType.EndPoint + dtype = { + "length": "ParallelDim", + "horizontal": "HorizontalDim", + "vertical": "VerticalDim", + }[dimension_type] + constraint = sketch.CreateDimension( + getattr(self.nxopen.Sketch.ConstraintType, dtype), one, two, point, None, mode + ) + exp = constraint.AssociatedExpression + if reference: + if abs(self._expression_record(exp)["value"] - val) > 0.001: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Reference dimension value must match measured geometry", + ) + else: + self._work_part().Expressions.EditExpression(exp, str(val)) + sketch.Update() + diagnostics = self._check_sketch_result(sketch_id) + return { + "constraint": self._reference(constraint, "constraint", self._work_part(), "Dimension"), + "expression": self._expression_record(exp), + "diagnostics": diagnostics, + } + + def _sketch_relation(self, sketch_id, curve1, curve2, relation, point1=None, point2=None): + sketch = self._resolve(sketch_id, {"sketch"}) + a, b = self._owned_curve(sketch, curve1), self._owned_curve(sketch, curve2) + methods = { + "parallel": "CreateParallelConstraint", + "perpendicular": "CreatePerpendicularConstraint", + "equal_length": "CreateEqualLengthConstraint", + "equal_radius": "CreateEqualRadiusConstraint", + "concentric": "CreateConcentricConstraint", + "coincident": "CreateCoincidentConstraint", + } + if relation not in methods or int(a.Tag) == int(b.Tag): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Requires a supported relation and two different curves" + ) + if relation != "coincident" and (point1 is not None or point2 is not None): + raise NXToolError("NX_INVALID_ARGUMENT", "Point arguments apply only to coincident") + pair = [] + for curve, point in [(a, point1), (b, point2)]: + g = self.nxopen.Sketch.ConstraintGeometry() + g.Geometry = curve + if relation == "coincident": + names = {"start": "StartVertex", "end": "EndVertex", "center": "ArcCenter"} + if ( + point not in names + or not isinstance(curve, (self.nxopen.Line, self.nxopen.Arc)) + or (point == "center" and not isinstance(curve, self.nxopen.Arc)) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Coincident requires a valid point on each line/arc" + ) + self._relation_point(curve, point) + g.PointType = getattr(self.nxopen.Sketch.ConstraintPointType, names[point]) + elif not isinstance( + curve, + self.nxopen.Arc if relation in {"equal_radius", "concentric"} else self.nxopen.Line, + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Curves do not support this relation") + pair.append(g) + with self._editing_sketch(sketch): + before = { + int(c.Tag) + for c in sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + } + if getattr(sketch, "UsesLegacySolver", True): + self._require_api(sketch, methods[relation]) + getattr(sketch, methods[relation])(*pair) + else: + self._modern_relation(sketch, a, b, relation, point1, point2) + sketch.Update() + residual = self._relation_residual(a, b, relation, point1, point2) + if residual > 1e-6: + raise NXToolError( + "NX_RELATION_UNSATISFIED", + "Native relation did not satisfy geometry; rolling back", + details={"residual": residual, "relation": relation}, + ) + diagnostics = self._check_sketch_result(sketch_id) + created = [ + c + for c in sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + if int(c.Tag) not in before + ] + after_tags = { + int(c.Tag) + for c in sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + } + if not before <= after_tags: + raise NXToolError( + "NX_CONSTRAINT_REMOVED", + "Native solver removed an existing constraint; rolling back", + ) + refs = [ + self._reference(c, "constraint", self._work_part(), "Relation") for c in created + ] + return { + "constraint": refs[0] if refs else None, + "constraints": refs, + "created_count": len(refs), + "diagnostics": diagnostics, + "geometric_residual": residual, + "relation_satisfied": True, + } + + def _relation_point(self, curve, point): + if isinstance(curve, self.nxopen.Line) and point in {"start", "end"}: + return curve.StartPoint if point == "start" else curve.EndPoint + if isinstance(curve, self.nxopen.Arc) and point == "center": + return curve.CenterPoint + raise NXToolError( + "NX_INVALID_ARGUMENT", "Point selection supports line start/end or arc center" + ) + + def _relation_residual(self, a, b, relation, point1=None, point2=None): + from nx_mcp.hardened import dot, xyz + + if relation == "equal_radius": + return abs(a.Radius - b.Radius) + if relation == "concentric": + return math.dist(xyz(a.CenterPoint), xyz(b.CenterPoint)) + if relation == "coincident": + return math.dist( + xyz(self._relation_point(a, point1)), xyz(self._relation_point(b, point2)) + ) + av = [x - y for x, y in zip(xyz(a.EndPoint), xyz(a.StartPoint), strict=True)] + bv = [x - y for x, y in zip(xyz(b.EndPoint), xyz(b.StartPoint), strict=True)] + if relation == "equal_length": + return abs(math.sqrt(dot(av, av)) - math.sqrt(dot(bv, bv))) + cosine = abs(dot(unit_normal(av), unit_normal(bv))) + return abs(1 - cosine) if relation == "parallel" else cosine + + def _modern_relation(self, sketch, a, b, relation, point1, point2): + names = { + "parallel": "CreateSketchMakeParallelBuilder", + "perpendicular": "CreateSketchMakePerpendicularBuilder", + "equal_length": "CreateSketchMakeEqualBuilder", + "equal_radius": "CreateSketchMakeEqualBuilder", + "coincident": "CreateSketchMakeCoincidentBuilder", + "concentric": "CreateSketchMakeCoincidentBuilder", + } + sketches = self._work_part().Sketches + self._require_api(sketches, names[relation]) + builder = getattr(sketches, names[relation])() + try: + if relation in {"coincident", "concentric"}: + if relation == "concentric": + point1 = point2 = "center" + view = self._work_part().ModelingViews.WorkView + snaps = self.nxopen.InferSnapType.SnapType + empty = self.nxopen.Point3d(0.0, 0.0, 0.0) + builder.StationaryObject.SetValue( + getattr(snaps, point1.title()), + a, + view, + self._relation_point(a, point1), + None, + None, + empty, + ) + builder.MotionPoints.Add( + getattr(snaps, point2.title()), + b, + view, + self._relation_point(b, point2), + None, + None, + empty, + ) + else: + builder.StationaryObject.Value = a + builder.MotionObjects.Add(b) + if relation in {"equal_length", "equal_radius"}: + builder.EqualType = getattr( + self.nxopen.SketchMakeEqualBuilder.EqualTypes, + "Radius" if relation == "equal_radius" else "Length", + ) + builder.SetCreateConstraints(True) + builder.FindRelations() + builder.Commit() + finally: + builder.Destroy() + + def _explicit_constraint_conflicts(self, sketch): + # NX 2606's modern solver can report UnderConstrained for contradictory + # persistent legacy relations. Report only directly provable pairs. + from nx_mcp.hardened import xyz + + pairs = [] + for curve in sketch.GetAllGeometry(): + if ( + not isinstance(curve, self.nxopen.Line) + or math.dist(xyz(curve.StartPoint), xyz(curve.EndPoint)) <= 0.001 + ): + continue + attached = sketch.GetConstraintsForGeometry( + curve, self.nxopen.Sketch.ConstraintClass.Any + ) + horizontal = [ + c + for c in attached + if enum_name(c.ConstraintType, self.nxopen.Sketch.ConstraintType) == "Horizontal" + ] + vertical = [ + c + for c in attached + if enum_name(c.ConstraintType, self.nxopen.Sketch.ConstraintType) == "Vertical" + ] + for a in horizontal: + for b in vertical: + pairs.append( + { + "geometry": self._reference(curve, "curve", self._work_part(), "Line"), + "constraints": [ + self._reference(c, "constraint", self._work_part(), "Constraint") + for c in (a, b) + ], + "reason": "A nonzero line cannot be both horizontal and vertical", + } + ) + return pairs + + @contextlib.contextmanager + def _temporary_sketch_edit(self, sketch): + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP temporary sketch inspection" + ) + try: + with self._editing_sketch(sketch): + yield + finally: + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + except Exception as exc: + raise NXToolError( + "NX_ROLLBACK_FAILED", + "Sketch inspection cleanup failed: " + str(exc), + details={"mutation_outcome": "partial"}, + ) from exc + + def _sketch_conflicts(self, sketch_id, max_checks=20): + if type(max_checks) is not int or not 1 <= max_checks <= 50: + raise NXToolError("NX_INVALID_ARGUMENT", "max_checks requires 1–50") + sketch = self._resolve(sketch_id, {"sketch"}) + baseline = self._sketch_diagnostics(sketch_id) + bad = {"OverConstrained", "InconsistentlyConstrained"} + explicit = self._explicit_constraint_conflicts(sketch) + constraints = ( + baseline["constraints"] if baseline["solver_status"] in bad or explicit else [] + ) + trials = [] + with self._temporary_sketch_edit(sketch): + for row in constraints[:max_checks]: + obj = self._resolve(row["object"]["id"], {"constraint"}) + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP conflict trial" + ) + trial = {"constraint": row["object"], "relieves_conflict": False} + try: + self._error_list(sketch.DeleteObjects([obj])) + # Entire-sketch evaluation uses its own reversible work-region scope. + status = self._sketch_diagnostics(sketch_id)["solver_status"] + trial.update( + solver_status=status, + relieves_conflict=status in {"UnderConstrained", "WellConstrained"} + and not self._explicit_constraint_conflicts(sketch), + ) + except Exception as exc: + trial["error"] = str(exc) + finally: + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + except Exception as exc: + raise NXToolError( + "NX_ROLLBACK_FAILED", + "Conflict trial cleanup failed: " + str(exc), + details={"mutation_outcome": "partial"}, + ) from exc + trials.append(trial) + return { + "baseline_status": baseline["solver_status"], + "explicit_conflict_pairs": explicit, + "warnings": [ + "Native solver status can omit contradictory persistent legacy relations; explicit pair detection covers horizontal/vertical on a nonzero line only." + ], + "trials": trials, + "checked": len(trials), + "total_constraints": baseline["constraint_count"], + "complete": len(trials) == len(constraints) and not any("error" in r for r in trials), + "single_removal_relief": [r["constraint"] for r in trials if r["relieves_conflict"]], + "method": "bounded single-removal sensitivity; not a minimal conflicting set", + "geometry_restored": True, + } diff --git a/src/nx_mcp/agent_guidance.py b/src/nx_mcp/agent_guidance.py new file mode 100644 index 0000000..dfb2209 --- /dev/null +++ b/src/nx_mcp/agent_guidance.py @@ -0,0 +1,199 @@ +"""Conservative task guidance, with unknown effects explicit rather than guessed.""" + +import json +from pathlib import Path + +MANIFEST = json.loads(Path(__file__).with_name("capability_manifest.json").read_text()) + +# Reviewed contracts; omitted tools retain their descriptions and unknown effects. +GUIDANCE = { + "nx_create_part": ( + ["Workspace-relative .prt path; no existing file"], + [], + {"path": "project/bracket.prt"}, + ), + "nx_open_part": (["Existing workspace .prt path"], ["part"], {"path": "project/bracket.prt"}), + "nx_create_sketch": (["Active work part"], ["part"], {"plane": "XY", "name": "profile"}), + "nx_sketch_rectangle": ( + ["Active owned sketch; coordinates in sketch-local units"], + ["sketch"], + {"sketch_id": "", "corner1": {"x": 0, "y": 0}, "corner2": {"x": 20, "y": 10}}, + ), + "nx_finish_sketch": (["Existing active sketch"], ["sketch"], {"sketch_id": ""}), + "nx_extrude": ( + ["Finished sketch with suitable closed section; lengths in work-part units"], + ["sketch"], + {"sketch_id": "", "distance": 5}, + ), + "nx_list_topology": ( + ["Current body reference; reacquire topology after edits"], + ["body", "face"], + {"body": "", "include_adjacency": True}, + ), + "nx_measure_volume": ( + ["Active part or assembly with solid geometry"], + ["body", "part", "component"], + {}, + ), + "nx_add_component": ( + ["Active assembly; saved prototype path"], + ["part"], + {"part_path": "project/bracket.prt", "name": "bracket"}, + ), + "nx_set_component_transform": ( + ["Current occurrence reference; absolute transform in documented frame"], + ["component"], + None, + ), + "nx_save_part": ( + [ + "Active work part with writable path; saves only this part, never component files. Returns saved paths and verifies loaded-file hashes and unrelated modified flags" + ], + ["part"], + {}, + ), + "nx_close_part": ( + ["Current part reference; explicitly choose whether to save"], + ["part"], + {"part": "", "save": True}, + ), + "nx_export_step": ( + ["Active work part; export also saves it"], + ["part"], + {"path": "project/bracket.step"}, + ), + "nx_screenshot": ( + ["Graphical NX with active part; agent mode"], + ["part"], + {"path": "project/review.png"}, + ), + "nx_download_file": ( + ["Existing workspace artifact"], + [], + {"path": "project/review.png", "delivery": "metadata"}, + ), + "nx_sketch_diagnostics": ( + ["Current owned sketch reference"], + ["sketch"], + {"sketch_id": ""}, + ), + "nx_rollback": (["Live explicit checkpoint; no intervening manual edits"], [], None), +} +GEOMETRY = { + "nx_create_sketch", + "nx_sketch_rectangle", + "nx_finish_sketch", + "nx_extrude", + "nx_add_component", + "nx_set_component_transform", + "nx_rollback", +} +VISIBILITY = { + "nx_set_display", + "nx_set_visibility", + "nx_set_datum_visibility", + "nx_restore_display", + "nx_set_view", + "nx_set_camera", + "nx_fit_view", + "nx_show_explosion", + "nx_section_view", + "nx_section_control", + "nx_highlight_collisions", + "nx_clear_highlights", +} +SAVES = {"nx_save_part", "nx_save_as", "nx_export_step", "nx_close_part", "nx_export_drawing_pdf"} +FILES = SAVES | { + "nx_screenshot", + "nx_render_view", + "nx_upload_file", + "nx_export_drawing_pdf", + "nx_export_flat_pattern", + "nx_package_assembly", + "nx_create_directory", +} +INVALIDATES = { + "nx_rollback", + "nx_undo", + "nx_close_part", + "nx_edit_feature", + "nx_edit_faces", + "nx_rename_object", + "nx_set_expression", +} + + +def guidance(tool): + readonly = bool(tool.annotations and tool.annotations.readOnlyHint) + reviewed = GUIDANCE.get(tool.name) + + def effect(group): + return True if tool.name in group else (False if readonly else None) + + return { + "prerequisites": reviewed[0] + if reviewed + else [ + "Inspect tool description and exact schema; additional native prerequisites may apply" + ], + "supported_object_kinds": reviewed[1] if reviewed else None, + "minimal_example": reviewed[2] if reviewed else None, + "example_convention": "Angle-bracket IDs are placeholders; use live references. Add a unique operation_id for mutations.", + "native_evidence": MANIFEST["tools"].get( + tool.name, {"status": "unavailable", "scope": "No manifest entry"} + ), + "effects": { + "read_only": readonly, + "geometry_or_assembly": effect(GEOMETRY), + "visibility_or_view": effect(VISIBILITY), + "saves_part": effect(SAVES), + "writes_files": effect(FILES), + "invalidates_references": effect(INVALIDATES), + "expires_undo_checkpoints": effect( + SAVES | {"nx_import_geometry", "nx_open_part", "nx_create_part"} + ), + "recovery": "Saves and part lifecycle changes can expire native undo marks. Inspect nx_checkpoint_state and create a new checkpoint after these operations; durable operation receipts remain available.", + "unknown_semantics": "null means not reviewed; true can be conditional on arguments. Read the exact tool description.", + }, + } + + +def next_actions(method, payload): + """Only suggest actions with already returned IDs, never new mutations.""" + if method in {"nx_list_topology", "nx_download_file", "nx_sketch_diagnostics"}: + return [] + actions = [] + + def identity(value): + return value.get("id") if isinstance(value, dict) else None + + body = next((identity(x) for x in payload.get("bodies", []) if identity(x)), None) + body = body or identity(payload.get("body")) + obj = payload.get("object", {}) + if isinstance(obj, dict) and obj.get("kind") == "body": + body = body or identity(obj) + if body: + actions.append( + { + "tool": "nx_list_topology", + "arguments": {"body": body}, + "purpose": "Inspect current faces and edges before selecting geometry", + } + ) + if isinstance(obj, dict) and obj.get("kind") == "sketch": + actions.append( + { + "tool": "nx_sketch_diagnostics", + "arguments": {"sketch_id": obj["id"]}, + "purpose": "Check sketch constraints and remaining degrees of freedom", + } + ) + if payload.get("path") and payload.get("sha256"): + actions.append( + { + "tool": "nx_download_file", + "arguments": {"path": payload["path"], "delivery": "metadata"}, + "purpose": "Retrieve artifact metadata before programmatic download", + } + ) + return actions diff --git a/src/nx_mcp/agent_surface.py b/src/nx_mcp/agent_surface.py new file mode 100644 index 0000000..0080b7a --- /dev/null +++ b/src/nx_mcp/agent_surface.py @@ -0,0 +1,470 @@ +"""Opt-in bounded agent surface; the full registry remains the validation authority.""" + +from __future__ import annotations + +import json +import os +import re +import uuid +from pathlib import Path +from typing import Annotated, Any, Literal +from urllib.parse import quote, unquote + +from mcp.server.fastmcp.exceptions import ToolError +from mcp.types import CallToolResult, ResourceLink, TextContent, ToolAnnotations +from pydantic import Field + +from nx_mcp.agent_guidance import guidance, next_actions +from nx_mcp.result_retention import LOCK, maintain, settings + +CORE = { + "nx_status", + "nx_workspace_info", + "nx_operation_status", + "nx_cancel_operation", + "nx_list_open_parts", + "nx_list_components", + "nx_screenshot", + "nx_download_file", +} +DOMAINS = { + "sketch": ("sketch", "constraint"), + "assembly": ("component", "assembly", "explosion", "reference_set", "mate"), + "drawing": ("drawing", "balloon", "bom", "pmi", "annotation"), + "manufacturing": ("sheet_metal", "flat_pattern", "thread", "draft_analysis", "thickness"), + "inspection": ("measure", "bounding", "interference", "collision", "topology", "diagnostic"), + "display": ("view", "display", "visibility", "section", "render", "screenshot", "datum"), + "files": ( + "part", + "workspace", + "directory", + "upload", + "download", + "export", + "import", + "package", + ), +} +DEFAULTS: dict[str, dict[str, Any]] = { + "nx_list_open_parts": {"compact": True, "limit": 20}, + "nx_list_components": {"compact": True, "include_transforms": False, "limit": 20}, + "nx_list_topology": {"compact": True}, + "nx_download_file": {"delivery": "metadata"}, + "nx_workspace_list": {"limit": 20}, +} + + +def category(name): + return next( + (key for key, words in DOMAINS.items() if any(w in name for w in words)), "modeling" + ) + + +def compact(value, path="", omitted=None, paged_keys=()): + """Preserve scalars, coordinate vectors, identities and all warnings; bound other arrays.""" + omitted = {} if omitted is None else omitted + if isinstance(value, dict): + if "id" in value and "kind" in value: + return { + k: v + for k, v in value.items() + if k in {"id", "kind", "name", "part_id", "occurrence_path"} + } + result = {} + for key, item in value.items(): + location = f"{path}/{key}" + if key == "data_base64": + omitted[location] = "binary omitted; use resources/read or a programmatic download" + elif key in {"warnings", "retry_guidance", "recovery"}: + result[key] = item + else: + result[key] = ( + [compact(row, f"{location}/{i}", omitted) for i, row in enumerate(item)] + if key in paged_keys and isinstance(item, list) + else compact(item, location, omitted) + ) + return result + if isinstance(value, list): + if len(value) > 20: + omitted[path] = {"total_count": len(value), "returned_count": 20} + return [compact(item, f"{path}/{i}", omitted) for i, item in enumerate(value[:20])] + return value + + +def compact_payload(full, result_id, method=None): + if full.get("status") == "error": + return full + omitted: dict[str, Any] = {} + # A backend page is already bounded by its requested limit. Never truncate its + # rows while retaining the backend count/cursor: that silently skips entries. + paged_keys = ("parts", "components", "entries", "items") if "next_offset" in full else () + payload = compact(full, omitted=omitted, paged_keys=paged_keys) + payload.update(result_id=result_id, detail="compact") + if omitted: + payload["omitted"] = omitted + changes = full.get("changes") + if isinstance(changes, dict): + payload["change_counts"] = { + k: len(v) if isinstance(v, list) else None + for k, v in changes.items() + if k in {"created", "modified", "deleted"} + } + if actions := next_actions(method, full): + payload["next_actions"] = actions + return payload + + +class ResultStore: + """Durable inspection snapshots, separate from authoritative mutation receipts.""" + + def __init__(self, root): + self.root = Path(root) / ".nx-mcp" / "agent-results" + settings() # Validate configuration before any mutation can be dispatched. + + def put(self, payload): + with LOCK: + return self._put(payload) + + def _put(self, payload): + if len(json.dumps(payload).encode("utf-8")) > settings()["max_bytes"]: + raise OSError("Response exceeds snapshot storage limit") + self.root.mkdir(parents=True, exist_ok=True) + result_id = "result_" + uuid.uuid4().hex + path = self.root / (result_id + ".json") + temporary = path.with_suffix(".tmp") + with temporary.open("w", encoding="utf-8") as stream: + json.dump(payload, stream, ensure_ascii=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + maintain(self.root, apply=True, protect=result_id) + return result_id + + def get(self, result_id): + if not re.fullmatch(r"result_[a-f0-9]{32}", result_id): + raise ValueError("Invalid result_id") + return json.loads((self.root / (result_id + ".json")).read_text(encoding="utf-8")) + + +def configure(mcp, workspace): + from nx_mcp.integration_server import envelope + + registry = dict(mcp._tool_manager._tools) + original_call = mcp.call_tool + original_list = mcp.list_tools + store = ResultStore(workspace.root) + + @mcp.resource("nx-artifact://workspace/{path}", mime_type="application/octet-stream") + def artifact(path: str) -> bytes: + """Read a workspace artifact (8 MiB limit); keep binary content outside model text.""" + file = workspace.resolve(unquote(path)) + if ".nx-mcp" in {p.casefold() for p in file.relative_to(workspace.root).parts}: + raise ValueError("Internal service state is not an artifact") + if file.stat().st_size > 8 * 1024 * 1024: + raise ValueError("Resource exceeds 8 MiB; use programmatic nx_download_file chunks") + return file.read_bytes() + + def present(response, mode, method=None): + if not isinstance(response, CallToolResult) or mode == "full" or response.isError: + return response + full = response.structuredContent + if not isinstance(full, dict): + return response + # Never turn an already-committed operation into an error if caching fails. + try: + payload = compact_payload(full, store.put(full), method) + except OSError: + return response + result = envelope(payload) + result.content.extend(c for c in response.content if not isinstance(c, TextContent)) + if full.get("path") and full.get("sha256") and full.get("size", 0) <= 8 * 1024 * 1024: + file = workspace.resolve(full["path"]) + relative = file.relative_to(workspace.root).as_posix() + result.content.append( + ResourceLink( + type="resource_link", + name=file.name, + uri="nx-artifact://workspace/" + quote(relative, safe=""), + size=full.get("size"), + ) + ) + return result + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def nx_discover_tools( + query: str = "", + domain: str | None = None, + include_schema: bool = False, + include_output_schema: bool = True, + offset: Annotated[int, Field(ge=0)] = 0, + limit: Annotated[int, Field(ge=1, le=20)] = 10, + ) -> CallToolResult: + """Discover task tools by name/description or domain. Domains: modeling, sketch, assembly, drawing, manufacturing, inspection, display, files. Spaced queries match all words, with tool-name matches ranked first. Request exact-name schema before nx_invoke; include_output_schema=false omits the repeated full output contract; results are paged. Discovery does not mutate NX or the session's catalog.""" + if offset < 0 or not 1 <= limit <= 20: + raise ValueError("offset >= 0; limit 1..20") + if domain is not None and domain not in {*DOMAINS, "modeling"}: + raise ValueError("Unknown domain") + words = re.findall(r"[a-z0-9]+", query.casefold()) + + def score(tool): + name_words = re.findall(r"[a-z0-9]+", tool.name.casefold()) + haystack = " ".join(name_words) + " " + tool.description.casefold() + if not all(word in haystack for word in words): + return -1 + return sum(4 if word in name_words else 1 for word in words) + + rows = [ + t + for n, t in registry.items() + if (domain is None or category(n) == domain) and score(t) >= 0 + ] + rows.sort(key=lambda t: (-score(t), t.name)) + # Exact name wins over incidental description matches. + if query in registry and (domain is None or category(query) == domain): + rows = [registry[query]] + selected = [] + for tool in rows[offset : offset + limit]: + row = { + "name": tool.name, + "description": tool.description, + "domain": category(tool.name), + "defaults": DEFAULTS.get(tool.name, {}), + } + if include_schema: + row.update( + inputSchema=tool.parameters, + guidance=guidance(tool), + ) + if include_output_schema: + row["outputSchema"] = tool.fn_metadata.output_schema + selected.append(row) + return envelope( + { + "tools": selected, + "total_count": len(rows), + "next_offset": offset + len(selected) + if offset + len(selected) < len(rows) + else None, + } + ) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True)) + async def nx_invoke( + tool: str, arguments: dict[str, Any], detail: Literal["compact", "full"] = "compact" + ) -> CallToolResult: + """Call a discovered NX tool using its exact input schema. Mutations execute serially on the NX thread. Supply operation_id inside arguments and reuse it after transport failure; nx_operation_status is authoritative. Compact returns result_id for detail expansion. Full preserves the original response. This gateway may mutate NX; inspect the discovered tool semantics first.""" + if tool not in registry: + raise ValueError("Unknown tool; use nx_discover_tools") + params = {**DEFAULTS.get(tool, {}), **arguments} if detail == "compact" else arguments + return present(await original_call(tool, params), detail, tool) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def nx_result( + result_id: str, + field: str = "", + offset: int = 0, + limit: int = 20, + detail: Literal["compact", "full"] = "compact", + ) -> CallToolResult: + """Expand a retained response snapshot without repeating NX operations. field is a JSON Pointer (/bodies, /changes/created); arrays are paged with limit 1..100. Empty field returns compact data plus available top-level fields. detail=full returns full selected object/scalar data; arrays remain paged. Snapshots survive server restarts until workspace cleanup; references may be stale after model changes. Use nx_operation_status for authoritative mutation recovery.""" + if offset < 0 or not 1 <= limit <= 100: + raise ValueError("offset >= 0; limit 1..100") + value = store.get(result_id) + if field: + if not field.startswith("/"): + raise ValueError("field must be a JSON Pointer") + for token in field[1:].split("/"): + token = token.replace("~1", "/").replace("~0", "~") + value = value[int(token)] if isinstance(value, list) else value[token] + if isinstance(value, list): + return envelope( + { + "result_id": result_id, + "field": field, + "items": value[offset : offset + limit], + "total_count": len(value), + "next_offset": offset + limit if offset + limit < len(value) else None, + } + ) + omitted: dict[str, Any] = {} + payload = compact(value, omitted=omitted) if detail == "compact" else value + return envelope( + { + "result_id": result_id, + "field": field, + "value": payload, + "fields": list(value) if isinstance(value, dict) else [], + "omitted": omitted, + } + ) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=True)) + async def nx_result_cleanup( + dry_run: bool = True, + max_age_seconds: int | None = None, + max_bytes: int | None = None, + ) -> CallToolResult: + """Inspect or delete disposable response snapshots. Default dry_run=true previews eligible counts/bytes. Limits are positive integers; default retention is seven days/256 MiB, configurable with NX_MCP_RESULT_MAX_AGE_SECONDS and NX_MCP_RESULT_MAX_BYTES. Automatic pruning runs on snapshot writes. Mutation recovery receipts and CAD files are never included. Call with dry_run=false to apply; old result_ids may then expire.""" + return envelope( + maintain( + store.root, apply=not dry_run, max_age_seconds=max_age_seconds, max_bytes=max_bytes + ) + ) + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) + async def nx_inspect( + tool: Literal[ + "nx_list_features", "nx_list_sketches", "nx_list_topology", "nx_list_annotations" + ], + arguments: dict[str, Any] | None = None, + collection: Literal["objects", "faces", "edges", "items"] | None = None, + name_contains: str = "", + kind: str | None = None, + offset: int = 0, + limit: int = 20, + result_id: str | None = None, + ) -> CallToolResult: + """Filter and page features, sketches, topology or annotations. Filter by case-insensitive name/text and exact object kind before pagination. Topology requires arguments.body and collection=faces or edges. Returns total_count, count, next_offset, units and reported coordinate frame. Reuse result_id to page the same snapshot without another NX call; omit it to refresh after edits. Snapshot references can expire. Only the four named read-only tools are accepted.""" + if offset < 0 or not 1 <= limit <= 100: + raise ValueError("offset >= 0; limit 1..100") + allowed = { + "nx_list_features": {"objects"}, + "nx_list_sketches": {"objects"}, + "nx_list_topology": {"faces", "edges"}, + "nx_list_annotations": {"items"}, + } + collection = collection or ( + "faces" + if tool == "nx_list_topology" + else ("items" if tool == "nx_list_annotations" else "objects") + ) + if collection not in allowed[tool]: + raise ValueError("Unsupported collection for this tool") + if result_id: + if arguments: + raise ValueError( + "arguments cannot be combined with result_id; omit result_id to refresh" + ) + snapshot = store.get(result_id) + if snapshot.get("inspection_tool") != tool: + raise ValueError("Snapshot belongs to a different inspection tool") + full = snapshot["result"] + else: + arguments = arguments or {} + if tool == "nx_list_annotations" and ({"offset", "limit"} & arguments.keys()): + raise ValueError( + "Use nx_inspect offset/limit, not underlying annotation pagination" + ) + response = await original_call(tool, arguments) + if response.isError: + return response + full = response.structuredContent + if tool == "nx_list_annotations": + while full.get("next_offset") is not None: + response = await original_call( + tool, {"offset": full["next_offset"], "limit": 100} + ) + if response.isError: + return response + more = response.structuredContent + if more.get("total") != full.get("total") or more.get( + "next_offset" + ) == full.get("next_offset"): + raise ValueError( + "Annotation inventory changed during capture; refresh inspection" + ) + full["items"].extend(more["items"]) + full["next_offset"] = more.get("next_offset") + result_id = store.put({"inspection_tool": tool, "result": full}) + rows = full[collection] + + def matches(row): + ref = row.get("object", row) + text = str(ref.get("name", "")) + " " + str(row.get("text", "")) + return name_contains.casefold() in text.casefold() and ( + kind is None or ref.get("kind") == kind + ) + + selected = [row for row in rows if matches(row)] + items = selected[offset : offset + limit] + omitted: dict[str, Any] = {} + rendered = [compact(row, f"/items/{i}", omitted) for i, row in enumerate(items)] + return envelope( + { + "result_id": result_id, + "tool": tool, + "collection": collection, + "items": rendered, + "omitted": omitted, + "count": len(items), + "total_count": len(selected), + "unfiltered_count": len(rows), + "offset": offset, + "next_offset": offset + len(items) if offset + len(items) < len(selected) else None, + "units": full.get("units"), + "coordinate_frame": full.get( + "coordinate_frame", full.get("position_frame", "not_reported") + ), + "warnings": full.get("warnings", []), + } + ) + + helper_names = { + "nx_discover_tools", + "nx_invoke", + "nx_result", + "nx_inspect", + "nx_result_cleanup", + } + for name in helper_names: + tool = mcp._tool_manager.get_tool(name) + tool.fn_metadata.arg_model.model_config["extra"] = "forbid" + tool.fn_metadata.arg_model.model_rebuild(force=True) + tool.parameters = tool.fn_metadata.arg_model.model_json_schema() + + async def call(name, arguments): + try: + if name in helper_names: + return await mcp._tool_manager.call_tool(name, arguments, convert_result=False) + if name not in registry: + raise ValueError("Unknown tool") + return present( + await original_call(name, {**DEFAULTS.get(name, {}), **(arguments or {})}), + "compact", + name, + ) + except (ValueError, KeyError, IndexError, OSError, TypeError, ToolError) as error: + from nx_mcp.runtime import NXToolError + + return envelope( + NXToolError( + "NX_RESULT_EXPIRED" + if isinstance(error, FileNotFoundError) + or isinstance(error.__cause__, FileNotFoundError) + else "NX_INVALID_ARGUMENT", + str(error), + details={"mutation_outcome": "not_started"}, + ).as_dict(), + True, + ) + + async def listing(): + result = [t for t in await original_list() if t.name in CORE | helper_names] + for tool in result: + # Agent presentation is an extensible envelope; exact full contracts are discoverable. + tool.outputSchema = None + if tool.name in DEFAULTS: + tool.inputSchema = json.loads(json.dumps(tool.inputSchema)) + for key, value in DEFAULTS[tool.name].items(): + if key in tool.inputSchema.get("properties", {}): + tool.inputSchema["properties"][key]["default"] = value + tool.description += ( + " Agent profile defaults: " + json.dumps(DEFAULTS[tool.name]) + "." + ) + return result + + mcp.call_tool = call + mcp.list_tools = listing + mcp._mcp_server.call_tool(validate_input=False)(call) + mcp._mcp_server.list_tools()(listing) + mcp._mcp_server.instructions = "NX agent profile. Use nx_inspect for filtered stable inventory pages and nx_result_cleanup for snapshot maintenance. Discover exact schemas with nx_discover_tools, then call nx_invoke. Inventories default to 20 rows; follow next_offset. Compact responses retain result_id for nx_result expansion. Supply durable operation_id for mutations and query nx_operation_status before retrying uncertain operations. File downloads default to metadata; retrieve bytes programmatically, never paste base64 into model context. Full compatibility surface remains available with NX_MCP_SURFACE=full." diff --git a/src/nx_mcp/animation.py b/src/nx_mcp/animation.py new file mode 100644 index 0000000..82fdd8e --- /dev/null +++ b/src/nx_mcp/animation.py @@ -0,0 +1,48 @@ +"""Rigid pose interpolation and self-contained frame playback artifact generation.""" + +from __future__ import annotations + +import math + + +def quaternion(matrix): + """Unit w,x,y,z quaternion from a validated row-major rotation matrix.""" + m = matrix + trace = sum(m[i][i] for i in range(3)) + if trace > 0: + s = math.sqrt(trace + 1) * 2 + q = [s / 4, (m[2][1] - m[1][2]) / s, (m[0][2] - m[2][0]) / s, (m[1][0] - m[0][1]) / s] + else: + i = max(range(3), key=lambda k: m[k][k]) + j, k = (i + 1) % 3, (i + 2) % 3 + s = math.sqrt(1 + m[i][i] - m[j][j] - m[k][k]) * 2 + q = [(m[k][j] - m[j][k]) / s, 0.0, 0.0, 0.0] + q[i + 1] = s / 4 + q[j + 1] = (m[j][i] + m[i][j]) / s + q[k + 1] = (m[k][i] + m[i][k]) / s + length = math.sqrt(sum(v * v for v in q)) + return [v / length for v in q] + + +def interpolate_rotation(first, second, fraction): + a, b = quaternion(first), quaternion(second) + cosine = sum(x * y for x, y in zip(a, b, strict=True)) + if cosine < 0: + b = [-v for v in b] + cosine = -cosine + if cosine > 0.9995: + q = [x + fraction * (y - x) for x, y in zip(a, b, strict=True)] + else: + angle = math.acos(max(-1, min(1, cosine))) + q = [ + (math.sin((1 - fraction) * angle) * x + math.sin(fraction * angle) * y) + / math.sin(angle) + for x, y in zip(a, b, strict=True) + ] + length = math.sqrt(sum(v * v for v in q)) + w, x, y, z = [v / length for v in q] + return [ + [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], + [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], + [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], + ] diff --git a/src/nx_mcp/annotation_updates.py b/src/nx_mcp/annotation_updates.py new file mode 100644 index 0000000..352f569 --- /dev/null +++ b/src/nx_mcp/annotation_updates.py @@ -0,0 +1,158 @@ +"""Native bend tables and transactional refresh of measured sheet-metal PMI.""" + +import json + +from nx_mcp.runtime import NXToolError + + +class AnnotationUpdatesMixin: + def _bend_table(self, view, position, table=None, automatic=True, columns=None): + import NXOpen.Annotations as A + + from nx_mcp.freeform import points3 + + target = self._drawing_object(view, "drawing_view") + point = points3([list(position) + [0] if len(position) == 2 else position])[0] + allowed = {"BendID", "BendName", "BendAngle", "BendDirection", "BendRadius"} + if columns is not None and ( + not columns or len(set(columns)) != len(columns) or not set(columns) <= allowed + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply unique native bend-table columns") + existing = self._engineering_owned(table, "annotation") if table else None + part = self._work_part() + b = part.Annotations.BendTables.CreateBendTableBuilder(existing) + try: + b.FlatPatternView.Value = target + b.Style.BendTable.AutomaticUpdate = automatic + if columns is not None: + b.Style.BendTable.SetColumnOrder( + [getattr(A.BendTableSettingsBuilder.ColumnType, x) for x in columns] + ) + b.AnnotationOrigin.Origin.SetValue(None, None, self.nxopen.Point3d(*point)) + if not b.Validate(): + raise NXToolError( + "NX_ANNOTATION_INVALID", "Select an actual flat-pattern drafting view" + ) + obj = b.Commit() + if obj is None: + raise NXToolError("NX_VERIFICATION_FAILED", "NX returned no bend table") + self._update_model() + ref = self._reference(obj, "annotation", part, "Bend table") + return { + "table": ref, + "rows": self._table_cells(obj), + "columns": [str(x).split(".")[-1] for x in b.Style.BendTable.GetColumnOrder()], + "created": [] if existing else [ref], + "modified": [ref] if existing else [], + "automatic_update": b.Style.BendTable.AutomaticUpdate, + "units": self._units(), + "coordinate_frame": "drawing_sheet", + } + finally: + b.Destroy() + + def _table_cells(self, obj): + import NXOpen.UF as U + + tab = U.UFSession.GetUFSession().Tabnot + columns = [tab.AskNthColumn(obj.Tag, i) for i in range(tab.AskNmColumns(obj.Tag))] + return [ + [ + tab.AskEvaluatedCellText(tab.AskCellAtRowCol(tab.AskNthRow(obj.Tag, i), c)) + for c in columns + ] + for i in range(tab.AskNmRows(obj.Tag)) + ] + + def _remember_managed_annotation(self, annotation, kind, body, faces, measured): + import NXOpen.UF as U + + tags = U.UFSession.GetUFSession().Tag + record = { + "kind": kind, + "body": tags.AskHandleFromTag(body.Tag), + "faces": [tags.AskHandleFromTag(f.Tag) for f in faces], + "measured": measured, + } + annotation.SetAttribute("NX_MCP_MEASURED_PMI_V1", json.dumps(record, sort_keys=True)) + + def _refresh_annotations(self): + import NXOpen.UF as U + + from nx_mcp.hardened import xyz + + part = self._work_part() + self._refresh_detail_boundaries() + updated = [] + for obj in self._documentation_annotations(part): + if not hasattr(obj, "HasUserAttribute") or not obj.HasUserAttribute( + "NX_MCP_MEASURED_PMI_V1", self.nxopen.NXObject.AttributeType.String, -1 + ): + continue + tags = U.UFSession.GetUFSession().Tag + raw = obj.GetStringAttribute("NX_MCP_MEASURED_PMI_V1") + if raw == "disabled": + continue + record = json.loads(raw) + try: + bodytag = int(tags.AskTagOfHandle(record["body"])) + facetags = [int(tags.AskTagOfHandle(x)) for x in record["faces"]] + body = next(b for b in part.Bodies if int(b.Tag) == bodytag) + faces = [next(f for f in body.GetFaces() if int(f.Tag) == tag) for tag in facetags] + except Exception as error: + raise NXToolError( + "NX_STALE_ANNOTATION_SOURCE", + "A managed annotation source was deleted or no longer resolves", + ) from error + manager = self._sm_manager() + if record["kind"] == "body": + measured = {"thickness": float(manager.GetBodyThickness(body))} + else: + values = [manager.GetBendParameters(f) for f in faces] + measured = { + "bends": [ + { + "inner_radius": float(v.InnerRadius), + "angle_degrees": float(v.BendAngle), + "neutral_factor": float(v.NeutralFactor), + } + for v in values + ] + } + if measured == record["measured"]: + continue + result = self._sheet_metal_annotation( + record["kind"], + self._reference(body, "body", part, "Body")["id"], + xyz(obj.AnnotationOrigin), + faces=[self._reference(f, "face", part, "Face")["id"] for f in faces] + if faces + else None, + annotation=self._reference(obj, "annotation", part, "PMI")["id"], + automatic=True, + ) + updated.extend(result["modified"]) + tables = getattr(getattr(part, "Annotations", None), "BendTables", None) + for table in list(tables or []): + builder = tables.CreateBendTableBuilder(table) + try: + if not builder.Style.BendTable.AutomaticUpdate: + continue + before = self._table_cells(table) + if not builder.Validate(): + raise NXToolError( + "NX_ANNOTATION_INVALID", "An automatic bend table no longer validates" + ) + obj = builder.Commit() + if obj is None: + raise NXToolError("NX_VERIFICATION_FAILED", "NX returned no updated bend table") + if self._table_cells(obj) != before: + updated.append(self._reference(obj, "annotation", part, "Bend table")) + finally: + builder.Destroy() + return { + "updated": updated, + "updated_count": len(updated), + "units": self._units(), + "semantics": "Managed PMI refresh after MCP mutations; invoke explicitly after manual NX edits. Automatic bend tables are rebuilt through their native builder in the same transaction.", + } diff --git a/src/nx_mcp/assembly_documentation.py b/src/nx_mcp/assembly_documentation.py new file mode 100644 index 0000000..fe1230d --- /dev/null +++ b/src/nx_mcp/assembly_documentation.py @@ -0,0 +1,475 @@ +"""Native parts lists, associative callouts and explosion documentation.""" + +from __future__ import annotations + +from nx_mcp.authoring import finite +from nx_mcp.runtime import NXToolError + + +class AssemblyDocumentationMixin: + @staticmethod + def _documentation_annotations(part): + values = [*getattr(part, "Notes", []), *getattr(part, "Labels", [])] + annotations = getattr(part, "Annotations", None) + if annotations is not None: + for name in [ + "Datums", + "Fcfs", + "IdSymbols", + "PartsLists", + "BendTables", + "TableSections", + "Tables", + ]: + values.extend(getattr(annotations, name, [])) + values.extend(getattr(getattr(part, "DraftingManager", None), "TitleBlocks", [])) + return list({int(obj.Tag): obj for obj in values}.values()) + + def _parts_list_object(self, reference): + obj = self._engineering_owned(reference, "annotation") + if obj not in list(self._work_part().Annotations.PartsLists): + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a native parts list") + return obj + + def _parts_list_info(self, parts_list): + import NXOpen.UF as U + + obj = self._parts_list_object(parts_list) + uf = U.UFSession.GetUFSession() + columns = [ + uf.Tabnot.AskNthColumn(obj.Tag, i) for i in range(uf.Tabnot.AskNmColumns(obj.Tag)) + ] + rows = [] + for i in range(uf.Tabnot.AskNmRows(obj.Tag)): + row = uf.Tabnot.AskNthRow(obj.Tag, i) + rows.append( + [ + uf.Tabnot.AskEvaluatedCellText(uf.Tabnot.AskCellAtRowCol(row, col)) + for col in columns + ] + ) + column_info = [] + for col in columns: + cp = uf.Plist.AskColPrefs(col) + header = ( + uf.Tabnot.AskNthHeaderRow(obj.Tag, 0) + if uf.Tabnot.AskNmHeaderRows(obj.Tag) + else None + ) + column_info.append( + { + "width": uf.Tabnot.AskColumnWidth(col), + "field": cp.DefaultString, + "key": cp.IsKeyField, + "protected": cp.IsProtected, + "title": uf.Tabnot.AskCellText(uf.Tabnot.AskCellAtRowCol(header, col)) + if header + else None, + } + ) + prefs = uf.Plist.AskPrefs(obj.Tag) + return { + "parts_list": self._reference(obj, "annotation", self._work_part(), "Parts list"), + "rows": rows, + "columns": column_info, + "row_count": len(rows), + "column_count": len(columns), + "automatic_update": prefs.AutoUpdate, + "units": self._units(), + "coordinate_frame": "drawing_sheet", + "columns_source": "native NX parts-list defaults", + } + + def _create_parts_list(self, drawing, position, scope="leaves"): + import NXOpen.UF as U + + if scope not in {"leaves", "top_level", "all"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported parts-list scope") + if not isinstance(position, list) or len(position) != 2: + raise NXToolError("NX_INVALID_ARGUMENT", "position must contain two sheet coordinates") + point = [finite(v, "position") for v in position] + [0.0] + sheet = self._drawing_object(drawing, "drawing_sheet") + if sheet.OwningPart != self._work_part(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Drawing must belong to work part") + if not self._walk_components(self._work_part()): + raise NXToolError("NX_NO_COMPONENTS", "A parts list requires an assembly") + sheet.Open() + uf = U.UFSession.GetUFSession() + prefs = uf.Plist.AskDefaultPrefs() + prefs.AutoUpdate = True + prefs.CreateNewRowsAsLocked = False + prefs.InitialCalloutField = "1" + prefs.MainSymbolText = "$~C" + prefs.SymbolType = U.Plist.SymbolType.SYMBOL_TYPE_ID_SYMBOL_CIRCLE + tag = uf.Plist.Create(prefs, point) + settings = uf.Plist.AskTraversalSettings(tag) + settings.LeavesOnly = scope == "leaves" + settings.TopLevelOnly = scope == "top_level" + uf.Plist.SetTraversalSettings(tag, settings) + uf.Plist.Update(tag) + obj = next(o for o in self._work_part().Annotations.PartsLists if int(o.Tag) == int(tag)) + ref = self._reference(obj, "annotation", self._work_part(), "Parts list") + result = self._parts_list_info(ref["id"]) + result.update({"created": [ref], "scope": scope}) + return result + + def _update_parts_list(self, parts_list): + obj = self._parts_list_object(parts_list) + obj.Update() + return self._parts_list_info(parts_list) + + def _parts_list_balloons(self, parts_list, view): + obj = self._parts_list_object(parts_list) + target = self._drawing_object(view, "drawing_view") + before = {int(a.Tag) for a in self._work_part().Annotations.IdSymbols} + obj.ShowBalloonsInView(target) + created = [ + self._reference(a, "annotation", self._work_part(), "Balloon") + for a in self._work_part().Annotations.IdSymbols + if int(a.Tag) not in before + ] + return { + "parts_list": self._reference(obj, "annotation", self._work_part(), "Parts list"), + "view": self._reference(target, "drawing_view", self._work_part(), "Drawing view"), + "balloons": created, + "created": created, + "balloon_count": len(created), + "association": "native parts-list callout groups", + "units": self._units(), + "coordinate_frame": "drawing_sheet", + } + + def _explosion_trace( + self, + explosion, + start_edge, + end_edge, + start_percent=50.0, + end_percent=50.0, + start_direction=None, + end_direction=None, + ): + from nx_mcp.hardened import IDENTITY + + part = self._explosion_context() + ex = self._explosion(explosion) + tree = self._exploded_tree(ex) + edges = [self._resolve(r, {"edge"}) for r in (start_edge, end_edge)] + for edge in edges: + component = edge.OwningComponent + if ( + not edge.IsOccurrence + or component is None + or int(component.Tag) not in tree + or tree[int(component.Tag)][3] + ): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", + "Select edges of unsuppressed component occurrences in this explosion", + ) + values = [finite(v, "percent") for v in [start_percent, end_percent]] + if any(not 0 <= v <= 100 for v in values): + raise NXToolError("NX_INVALID_ARGUMENT", "Edge percentage must be 0..100") + directions = [ + self._engineering_direction(v or [0, 0, 1]) for v in [start_direction, end_direction] + ] + import NXOpen.UF as U + + tags = U.UFSession.GetUFSession().Tag + self._require_api(tags, "AskHandleFromTag") + self._require_api(tags, "AskTagOfHandle") + records = [ + { + "edge": tags.AskHandleFromTag(edge.Prototype.Tag), + "component": tags.AskHandleFromTag(edge.OwningComponent.Tag), + "percent": percent, + } + for edge, percent in zip(edges, values, strict=True) + ] + positions = [self._trace_position(ex, record) for record in records] + points = [part.Points.CreatePoint(self.nxopen.Point3d(*position)) for position in positions] + for point in points: + point.Blank() + line = part.Tracelines.CreateAutomaticTraceline( + ex, + points[0], + directions[0], + points[1], + directions[1], + self._nx_matrix(IDENTITY), + self.nxopen.AutomaticTraceline.ModeOption.Infer, + 0, + 0.0, + 0.0, + [], + [], + ) + import json + + encoded = json.dumps(records) + line.SetAttribute("NX_MCP_TRACE_V1", encoded) + if line.GetStringAttribute("NX_MCP_TRACE_V1") != encoded: + raise NXToolError("NX_VERIFICATION_FAILED", "Trace anchor metadata was not retained") + self._update_model() + ref = self._reference(line, "traceline", part, "Explosion trace") + return { + "traceline": ref, + "created": [ref], + "explosion": self._reference(ex, "explosion", part, "Explosion"), + "endpoints": [self._reference(p, "point", part, "Trace endpoint") for p in points], + "association": "Native persistent component/edge handles; exploded endpoints refreshed by MCP explosion edits, show, and animation. After manual NX edits, call nx_show_explosion to refresh.", + "units": self._units(), + "coordinate_frame": "assembly", + } + + def _trace_position(self, explosion, record): + from nx_mcp.hardened import matvec, rows, transpose, xyz + + part = self._work_part() + import NXOpen.UF as U + + try: + tags = U.UFSession.GetUFSession().Tag + component_tag = int(tags.AskTagOfHandle(record["component"])) + edge_tag = int(tags.AskTagOfHandle(record["edge"])) + except Exception as error: + raise NXToolError( + "NX_STALE_TRACE_ANCHOR", "A persistent trace handle no longer resolves" + ) from error + matches = [ + entry + for entry in self._exploded_tree(explosion).values() + if int(entry[1].Tag) == component_tag + ] + if len(matches) != 1: + raise NXToolError( + "NX_STALE_TRACE_ANCHOR", "A managed trace component is missing or ambiguous" + ) + child, component, _, suppressed = matches[0] + if suppressed: + raise NXToolError("NX_STALE_TRACE_ANCHOR", "A managed trace component is suppressed") + edges = [ + edge + for body in component.Prototype.Bodies + for edge in body.GetEdges() + if int(edge.Tag) == edge_tag + ] + if len(edges) != 1: + raise NXToolError( + "NX_STALE_TRACE_ANCHOR", "A managed trace edge is missing or ambiguous" + ) + edge = component.FindOccurrence(edges[0]) + if edge is None: + raise NXToolError("NX_STALE_TRACE_ANCHOR", "Trace edge occurrence is unavailable") + percent = finite(record["percent"], "percent") + if not 0 <= percent <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Stored trace percentage must be 0..100") + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "Evaluate trace anchor" + ) + try: + scalar = part.Scalars.CreateScalar( + percent, + self.nxopen.Scalar.DimensionalityType.NotSet, + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + point = part.Points.CreatePoint( + edge, + scalar, + self.nxopen.PointCollection.PointOnCurveLocationOption.PercentArcLength, + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + if self.session.UpdateManager.DoUpdate(mark): + raise NXToolError("NX_UPDATE_FAILED", "Trace anchor evaluation failed") + coordinates = xyz(point.Coordinates) + finally: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + assembled_position, assembled_rotation = component.GetPosition() + exploded_position, exploded_rotation = child.GetPosition() + local = matvec( + transpose(rows(assembled_rotation)), + [a - b for a, b in zip(coordinates, xyz(assembled_position), strict=True)], + ) + return [ + a + b + for a, b in zip( + matvec(rows(exploded_rotation), local), xyz(exploded_position), strict=True + ) + ] + + def _refresh_explosion_traces(self, explosion): + import json + + pending = [] + for line in getattr(self._work_part(), "Tracelines", []): + if line.AskExplosion() != explosion or not line.HasUserAttribute( + "NX_MCP_TRACE_V1", self.nxopen.NXObject.AttributeType.String, -1 + ): + continue + records = json.loads(line.GetStringAttribute("NX_MCP_TRACE_V1")) + positions = [self._trace_position(explosion, record) for record in records] + pending.append((line, positions)) + if not pending: + return + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "Refresh explosion traces" + ) + try: + for line, positions in pending: + import math + + if math.dist(*positions) <= 1e-9: + line.Blank() + continue + line.Unblank() + for point, position in zip( + [line.StartPoint, line.EndPoint], positions, strict=True + ): + point.SetCoordinates(self.nxopen.Point3d(*position)) + if self.session.UpdateManager.DoUpdate(mark): + raise NXToolError("NX_UPDATE_FAILED", "Managed trace refresh failed") + except Exception: + self.session.UndoToMark(mark, None) + raise + finally: + self.session.DeleteUndoMark(mark, None) + + def _export_explosion_animation( + self, explosion, path, frames=12, fps=12, width=960, height=600 + ): + import base64 + import hashlib + import json + import shutil + import tempfile + from pathlib import Path + + import NXOpen.UF as U + + from nx_mcp.animation import interpolate_rotation + + if ( + type(frames) is not int + or not 2 <= frames <= 30 + or type(fps) is not int + or not 1 <= fps <= 60 + ): + raise NXToolError("NX_INVALID_ARGUMENT", "frames must be 2..30 and fps 1..60") + if any(type(v) is not int or not 128 <= v <= 1600 for v in [width, height]): + raise NXToolError("NX_INVALID_ARGUMENT", "Frame dimensions must be 128..1600 pixels") + part = self._explosion_context() + if self.session.IsBatch: + raise NXToolError( + "NX_VIEWPORT_UNAVAILABLE", "Animation rendering requires interactive NX" + ) + if part.DrawingSheets.CurrentDrawingSheet is not None: + raise NXToolError( + "NX_DRAWING_ACTIVE", "Show a modeling view before exporting an animation" + ) + display = U.UFSession.GetUFSession().Disp + self._require_api(display, "RegenerateDisplay") + ex = self._explosion(explosion) + entries = [ + self._exploded_record(entry) + for entry in self._exploded_tree(ex).values() + if not entry[3] + ] + if not entries or len(entries) > 1000: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Explosion must have 1..1000 unsuppressed components" + ) + destination = self.workspace.resolve(path) + if destination.suffix.lower() != ".html" or destination.exists(): + raise NXToolError("NX_INVALID_ARGUMENT", "Choose an unused workspace .html path") + destination.parent.mkdir(parents=True, exist_ok=True) + stage = tempfile.mkdtemp(prefix=".nx-animation-", dir=destination.parent) + view = part.ModelingViews.WorkView + uf = self._explosion_uf() + original_explosion = uf.AskViewExplosion(view.Tag) + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP animation preview" + ) + previous_mark = getattr(self, "_active_mark", None) + self._active_mark = mark + images = [] + reports = [] + try: + uf.SetViewExplosion(view.Tag, ex.Tag) + for index in range(frames): + fraction = index / (frames - 1) + placements = [ + { + "component": r["component"]["id"], + "translation": [ + a + fraction * (b - a) + for a, b in zip( + r["assembled_translation"], r["translation"], strict=True + ) + ], + "rotation_matrix": interpolate_rotation( + r["assembled_rotation_matrix"], r["rotation_matrix"], fraction + ), + } + for r in entries + ] + self._edit_explosion(explosion, placements=placements) + self._refresh_explosion_traces(ex) + display.RegenerateDisplay() + result = self._render_view( + path=str(Path(stage) / f"{index:03d}.png"), + width=width, + height=height, + style="shaded_with_edges", + ) + data = (Path(stage) / f"{index:03d}.png").read_bytes() + images.append("data:image/png;base64," + base64.b64encode(data).decode("ascii")) + reports.append( + { + "index": index, + "fraction": fraction, + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + "camera": result.get("camera"), + } + ) + finally: + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + uf.SetViewExplosion(view.Tag, original_explosion) + display.RegenerateDisplay() + view.UpdateDisplay() + finally: + self._active_mark = previous_mark + shutil.rmtree(stage) + payload = json.dumps(images) + html = ( + 'NX explosion animation

Assembly explosion

NX assembly animation frame" + ) + # Exclusive creation prevents a race from overwriting an existing artifact. + with destination.open("x", encoding="utf-8") as stream: + stream.write(html) + data = destination.read_bytes() + return { + "path": str(destination), + "mime_type": "text/html", + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "frames": reports, + "frame_count": frames, + "fps": fps, + "width": width, + "height": height, + "units": self._units(), + "coordinate_frame": "assembly", + "model_restored": True, + "interpolation": "linear translation and shortest-arc quaternion rotation from assembled to current exploded poses", + "camera": "fixed current NX modeling camera; frame the full motion before export", + } diff --git a/src/nx_mcp/assembly_documentation_server.py b/src/nx_mcp/assembly_documentation_server.py new file mode 100644 index 0000000..0125d5a --- /dev/null +++ b/src/nx_mcp/assembly_documentation_server.py @@ -0,0 +1,49 @@ +"""Assembly documentation contracts with native associations and artifact delivery.""" + +from __future__ import annotations + +from typing import Literal + +READ_ONLY = {"nx_parts_list_info"} +NON_MODEL = {"nx_export_explosion_animation"} + + +def nx_create_parts_list( + drawing: str, position: list[float], scope: Literal["leaves", "top_level", "all"] = "leaves" +): + """Create a native automatically updating BOM on a work-part assembly drawing. position is [x,y] in sheet units. Scope controls assembly traversal. Uses installed native parts-list column defaults and returns their actual evaluated rows and quantities. Repeated instances aggregate according to the native key columns. No prototype part is modified.""" + + +def nx_parts_list_info(parts_list: str): + """Read a native parts list's evaluated rows, column/row counts and automatic-update setting. parts_list is the typed annotation ID returned by nx_create_parts_list. Reading does not refresh or mutate the table.""" + + +def nx_update_parts_list(parts_list: str): + """Explicitly update an existing native parts list after assembly changes and return its actual evaluated rows. Preserves native callout associations.""" + + +def nx_parts_list_balloons(parts_list: str, view: str): + """Ask NX to show native associative parts-list callout balloons in a drawing view, including an exploded view. Returns newly created balloon annotation IDs and count. Native automatic placement and grouping follow the parts-list preferences; inspect the drawing before publishing.""" + + +def nx_explosion_trace( + explosion: str, + start_edge: str, + end_edge: str, + start_percent: float = 50.0, + end_percent: float = 50.0, + start_direction: list[float] | None = None, + end_direction: list[float] | None = None, +): + """Create a native automatic explosion trace anchored to two unsuppressed component-occurrence edges. Percentages are 0..100 along edge arc length. Directions are in assembly coordinates and default to +Z; NX infers the trace routing from them. Trace belongs to the named explosion and can appear in its associated drawing views. Native component/edge handles persist; MCP explosion edits/show/animation refresh their exploded endpoints. After manual NX changes, call nx_show_explosion to refresh. Returns trace and endpoint IDs without moving assembled components.""" + + +def nx_export_explosion_animation( + explosion: str, + path: str, + frames: int = 12, + fps: int = 12, + width: int = 960, + height: int = 600, +): + """Render a self-contained HTML animation from assembled to current exploded poses, with native PNG frames, playback and a scrubber. Requires interactive NX with a modeling view active and an unused workspace .html path. Uses linear translation and quaternion rotation, 2..30 frames, 1..60 fps, dimensions 128..1600. Keeps the current camera fixed: frame the full motion before export. Runs serially on the NX thread and restores model/view state under an explicit temporary undo mark. Returns checksum and per-frame metadata; retrieve with nx_download_file. This is a presentation animation, not a collision-certified disassembly path.""" diff --git a/src/nx_mcp/assembly_loading.py b/src/nx_mcp/assembly_loading.py new file mode 100644 index 0000000..5bb3031 --- /dev/null +++ b/src/nx_mcp/assembly_loading.py @@ -0,0 +1,101 @@ +"""Read assembly load state without assuming an occurrence has a loaded Part.""" + +from nx_mcp.runtime import NXToolError + + +def component_info(component): + prototype = component.Prototype + path = getattr(prototype, "FullPath", None) + loaded = bool(path) + result = { + "part_path": path or None, + "load_state": "fully_loaded" + if loaded and getattr(prototype, "IsFullyLoaded", True) + else "partially_loaded" + if loaded + else "unloaded", + "prototype_type": type(prototype).__name__ if prototype is not None else None, + } + if not loaded: + import NXOpen.UF as U + + try: + path = U.UFSession.GetUFSession().Assem.AskComponentData(component.Tag)[0] + result["part_path"] = path or None + except Exception as error: + result["load_diagnostic"] = str(error) + return result + + +def require_loaded(executor, part): + incomplete = [] + for component, path in executor._walk_components(part): + if component.IsSuppressed: + continue + info = component_info(component) + if info["load_state"] != "fully_loaded": + incomplete.append({"occurrence_path": path, **info}) + if incomplete: + raise NXToolError( + "NX_ASSEMBLY_NOT_LOADED", + "Load assembly components before updating or exporting drawings", + details={"components": incomplete, "mutation_outcome": "not_started"}, + ) + + +def dependent_assemblies(executor, target): + parents = [] + for part in executor.session.Parts: + if part == target: + continue + for component, _ in executor._walk_components(part): + prototype = component.Prototype + if prototype is not None and int(prototype.Tag) == int(target.Tag): + parents.append(part.FullPath) + break + return parents + + +def load_components(executor, part): + """Explicitly load occurrence prototypes; restore the session loading preference.""" + options = executor.session.Parts.LoadOptions + partial = options.UsePartialLoading + opened = set() + try: + options.UsePartialLoading = False + while True: + pending = [ + c + for c, _ in executor._walk_components(part) + if not c.IsSuppressed and int(c.Tag) not in opened + ] + if not pending: + break + if len(opened) + len(pending) > 10000: + raise NXToolError("NX_OBJECT_LIMIT", "Component loading exceeds 10000 occurrences") + status, _ = part.ComponentAssembly.OpenComponents( + part.ComponentAssembly.OpenOption.ComponentOnly, pending + ) + try: + if status and status.NumberUnloadedParts: + raise NXToolError( + "NX_COMPONENT_LOAD_FAILED", + "Some component prototypes could not be loaded", + details={ + "parts": [ + {"path": status.GetPartName(i), "nx_code": status.GetStatus(i)} + for i in range(status.NumberUnloadedParts) + ] + }, + ) + finally: + if status: + status.Dispose() + opened.update(int(c.Tag) for c in pending) + require_loaded(executor, part) + return {"requested": True, "checked_occurrences": len(opened), "complete": True} + except NXToolError as error: + error.details["mutation_outcome"] = "partial" + raise + finally: + options.UsePartialLoading = partial diff --git a/src/nx_mcp/authoring.py b/src/nx_mcp/authoring.py new file mode 100644 index 0000000..dcde0bd --- /dev/null +++ b/src/nx_mcp/authoring.py @@ -0,0 +1,707 @@ +"""Geometric queries and guarded authoring on the serialized NX thread.""" + +from __future__ import annotations + +import math +import re + +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import enum_name, unit_normal + + +def page(items, offset=0, limit=50): + if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= 200: + raise NXToolError("NX_INVALID_ARGUMENT", "offset >= 0 and limit 1–200 are required") + end = offset + limit + return { + "items": items[offset:end], + "total": len(items), + "offset": offset, + "next_offset": end if end < len(items) else None, + } + + +def finite(value, name, positive=False): + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or (positive and value <= 0) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", name + " must be finite" + (" and positive" if positive else "") + ) + return float(value) + + +class AuthoringMixin: + def _require_api(self, obj, *names): + missing = [n for n in names if not hasattr(obj, n)] + if missing: + raise NXToolError("NX_API_UNAVAILABLE", "Installed NX API lacks: " + ", ".join(missing)) + + def _update_model(self): + count = self.session.UpdateManager.DoUpdate(self._active_mark) + if count: + raise NXToolError("NX_UPDATE_FAILED", str(count) + " native update errors") + + def _error_list(self, errors): + if errors is None: + return + try: + if errors.Length: + messages = [str(errors.GetErrorInfo(i)) for i in range(errors.Length)] + raise NXToolError("NX_UPDATE_FAILED", "; ".join(messages)) + finally: + errors.Dispose() + + def _expression(self, reference): + if reference.startswith("obj_"): + return self._resolve(reference, {"expression"}) + matches = [e for e in self._work_part().Expressions if e.Name == reference] + if len(matches) != 1: + raise NXToolError( + "NX_NOT_FOUND", "Expression names are case-sensitive; use an expression ID" + ) + return matches[0] + + def _expression_record(self, exp): + part = self._work_part() + return { + "object": self._reference(exp, "expression", part, "Expression"), + "name": exp.Name, + "formula": exp.RightHandSide, + "type": exp.Type, + "value": ( + exp.GetValueUsingUnits(self.nxopen.Expression.UnitsOption.Expression) + if exp.Type == "Number" + else exp.IntegerValue + if exp.Type == "Integer" + else None + ), + "units": exp.Units.Abbreviation if exp.Units else "unitless", + "editable": not ( + exp.IsNoEdit or exp.IsRightHandSideLockedFromEdit or exp.IsInterpartExpression + ), + "parents": [ + self._reference(e, "expression", part, "Expression") + for e in exp.GetExpressionParents() + ], + "dependents": [ + self._reference(e, "expression", part, "Expression") + for e in exp.GetReferencingExpressions() + ], + "dependency_scope": "stored immediate expression dependencies; conditional branches may be incomplete", + } + + def _list_expressions(self, name_contains=None, offset=0, limit=50): + values = sorted(self._work_part().Expressions, key=lambda e: e.Name) + if name_contains is not None: + values = [e for e in values if name_contains.casefold() in e.Name.casefold()] + result = page(values, offset, limit) + result["items"] = [self._expression_record(e) for e in result["items"]] + return result + + def _set_expression(self, expression, formula, create=False, units="unitless"): + part = self._work_part() + if not isinstance(formula, str) or not formula.strip() or len(formula) > 4096: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "formula must contain 1–4096 characters of NX expression syntax", + ) + self._require_api(part.Expressions, "EditExpression", "CreateNumberExpression") + if create: + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,99}", expression): + raise NXToolError( + "NX_INVALID_ARGUMENT", "New expression requires a simple identifier" + ) + if any(e.Name == expression for e in part.Expressions): + raise NXToolError("NX_NAME_EXISTS", "Expression already exists") + unit_names = { + "mm": "MilliMeter", + "inch": "Inch", + "deg": "Degrees", + "rad": "Radian", + "unitless": None, + } + if units not in unit_names: + raise NXToolError( + "NX_INVALID_ARGUMENT", "units must be mm, inch, deg, rad or unitless" + ) + unit = part.UnitCollection.FindObject(unit_names[units]) if unit_names[units] else None + exp = part.Expressions.CreateNumberExpression(expression + "=" + formula, unit) + else: + if units != "unitless": + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Existing expressions retain their units; do not supply units when editing", + ) + exp = self._expression(expression) + if not self._expression_record(exp)["editable"] or exp.Type != "Number": + raise NXToolError( + "NX_EXPRESSION_READ_ONLY", + "Only editable, local Number expressions are supported", + ) + part.Expressions.EditExpression(exp, formula) + self._update_model() + row = self._expression_record(exp) + return { + "expression": row, + "modified": [row["object"]], + "created": [row["object"]] if create else [], + } + + def _bind_parameter(self, feature, parameter, expression): + f = self._resolve(feature, {"feature"}) + exp = self._expression(expression) + if exp.Type != "Number": + raise NXToolError("NX_INVALID_ARGUMENT", "Binding requires a Number expression") + builders = { + "EXTRUDE": ("CreateExtrudeBuilder", {"start", "end"}), + "PATTERN_FEATURE": ("CreatePatternFeatureBuilder", {"count", "spacing"}), + } + kind = f.FeatureType.upper().replace(" ", "_") + if kind not in builders or parameter not in builders[kind][1]: + raise NXToolError( + "NX_UNSUPPORTED_EDIT", "Supported: EXTRUDE start/end; PATTERN_FEATURE count/spacing" + ) + collection = self._work_part().Features + self._require_api(collection, builders[kind][0]) + b = getattr(collection, builders[kind][0])(f) + try: + target = ( + (b.Limits.StartExtend.Value if parameter == "start" else b.Limits.EndExtend.Value) + if kind == "EXTRUDE" + else ( + b.PatternService.RectangularDefinition.XSpacing.NCopies + if parameter == "count" + else b.PatternService.RectangularDefinition.XSpacing.PitchDistance + ) + ) + self._work_part().Expressions.EditExpression(target, exp.Name) + b.CommitFeature() + finally: + b.Destroy() + self._update_model() + return { + "feature": self._get_feature_info(feature), + "parameter": parameter, + "expression": self._expression_record(exp), + "modified": [self._reference(f, "feature", self._work_part(), "Feature")], + } + + def _find_geometry( + self, + owner=None, + kind="face", + geometry_type="any", + normal=None, + radius=None, + near=None, + order="nearest", + axis="Z", + tolerance=0.001, + offset=0, + limit=50, + ): + import NXOpen.UF + + from nx_mcp.hardened import dot, vector + + if kind not in {"face", "edge"} or geometry_type not in { + "any", + "plane", + "cylinder", + "circle", + "line", + }: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported kind or geometry_type") + if (kind == "face" and geometry_type in {"circle", "line"}) or ( + kind == "edge" and geometry_type in {"plane", "cylinder"} + ): + raise NXToolError("NX_INVALID_ARGUMENT", "geometry_type does not apply to kind") + if normal is not None and (kind != "face" or geometry_type != "plane"): + raise NXToolError("NX_INVALID_ARGUMENT", "Normal filtering requires planar faces") + if radius is not None and geometry_type not in {"cylinder", "circle"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Radius filtering requires cylinders or circles" + ) + if order not in {"nearest", "highest", "lowest"} or axis not in {"X", "Y", "Z"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "order must be nearest/highest/lowest; axis X/Y/Z" + ) + tol = finite(tolerance, "tolerance", True) + target = vector(near, "near") if near is not None else None + if order == "nearest" and target is None: + raise NXToolError("NX_INVALID_ARGUMENT", "nearest ordering requires a work-part point") + direction = unit_normal(normal) if normal is not None else None + wanted_radius = finite(radius, "radius", True) if radius is not None else None + uf = NXOpen.UF.UFSession.GetUFSession() + self._require_api(uf.Modeling, "AskFaceData", "AskMinimumDist3") + values = self._geometry(owner) + entities = {} + for body in values: + if not isinstance(body, self.nxopen.Body): + raise NXToolError( + "NX_OBJECT_TYPE_MISMATCH", + "owner must resolve to bodies, features or components", + ) + for obj in body.GetFaces() if kind == "face" else body.GetEdges(): + entities[int(obj.Tag)] = obj + if len(entities) > 20000: + raise NXToolError("NX_OBJECT_LIMIT", "Narrow owner to at most 20000 candidate entities") + result = [] + for obj in entities.values(): + row = { + "object": self._reference(obj, kind, self._work_part(), kind.title()), + "geometry_type": "other", + } + box = list(uf.ModlGeneral.AskBoundingBox(obj.Tag)) + if kind == "face": + code, point, direct, box, rad, _, sign = uf.Modeling.AskFaceData(obj.Tag) + row.update( + geometry_type={22: "plane", 16: "cylinder"}.get(code, "other"), native_type=code + ) + if code == 22: + row["normal"] = list(direct) + if code == 16: + row.update( + radius=rad, + axis=list(direct), + axis_point=list(point), + surface_orientation=sign, + cylindrical_role="bore" if sign < 0 else "boss", + ) + else: + type_name = enum_name(obj.SolidEdgeType, self.nxopen.Edge.EdgeType) + row.update( + geometry_type={"Circular": "circle", "Linear": "line"}.get(type_name, "other"), + native_type=type_name, + ) + if row["geometry_type"] == "circle": + arc = uf.Curve.AskArcData(obj.Tag) + row.update(radius=arc.Radius) + if geometry_type != "any" and row["geometry_type"] != geometry_type: + continue + if direction is not None and dot(direction, row["normal"]) < 1 - tol: + continue + if wanted_radius is not None and abs(row["radius"] - wanted_radius) > tol: + continue + center = [(box[i] + box[i + 3]) / 2 for i in range(3)] + row.update(bounds=list(box), bounds_type="conservative", bounds_center=center) + if target is not None: + row["distance_to_bounds_center"] = math.dist(target, center) + distance, on_geometry, on_point, accuracy = uf.Modeling.AskMinimumDist3( + 2, obj.Tag, 0, 0, [0.0, 0.0, 0.0], 1, list(target) + ) + row.update(distance=distance, closest_point=list(on_geometry), accuracy=accuracy) + row["rank_value"] = row["distance"] if order == "nearest" else center["XYZ".index(axis)] + result.append(row) + result.sort(key=lambda r: (r["rank_value"], r["object"]["id"]), reverse=order == "highest") + return { + **page(result, offset, limit), + "coordinate_frame": "work_part", + "units": self._units(), + "ranking": "native BREP minimum distance" + if order == "nearest" + else "conservative bounds center along axis", + "selector": { + "version": 1, + "owner_part": self._work_part().FullPath, + "owner": self._geometry_owner_locator(owner) if owner else None, + "query": { + "kind": kind, + "geometry_type": geometry_type, + "normal": normal, + "radius": radius, + "near": near, + "order": order, + "axis": axis, + "tolerance": tolerance, + }, + }, + "normal_tolerance": "dot(requested,outward_normal) >= 1-tolerance", + } + + def _highlight_objects(self, objects): + self._visual_part() + values = self._display_targets(objects) + self._clear_highlights() + try: + for obj in values: + obj.Highlight() + self._highlighted_objects.append(obj) + except Exception: + self._clear_highlights() + raise + return {"highlighted": [self._display_ref(v) for v in values], "count": len(values)} + + def _consistency_fault(self, owner, code, tag): + result = {"code": int(code), "native_tag": int(tag), "object": None} + try: + result["message"] = self.nxopen.NXException(int(code)).GetMessage() + except Exception: + result["message"] = "Native diagnostic message unavailable" + try: + obj = self.nxopen.TaggedObjectManager.GetTaggedObject(tag) + kind = next( + k + for k, cls in [ + ("face", self.nxopen.Face), + ("edge", self.nxopen.Edge), + ("body", self.nxopen.Body), + ] + if isinstance(obj, cls) + ) + result["object"] = self._reference(obj, kind, owner, "Faulty entity") + except Exception as error: + result["reference_warning"] = str(error) + return result + + def _model_health(self, scope="part", offset=0, limit=50): + import NXOpen.UF + + if scope not in {"part", "assembly"}: + raise NXToolError("NX_INVALID_ARGUMENT", "scope must be part or assembly") + part = self._work_part() + uf = NXOpen.UF.UFSession.GetUFSession() + issues = [] + feature_count = 0 + parts = {int(part.Tag): part} + components = self._walk_components(part) if scope == "assembly" else [] + for c, path in components: + if c.IsSuppressed: + issues.append( + {"severity": "info", "kind": "suppressed_component", "occurrence_path": path} + ) + elif c.Prototype is None or not hasattr(c.Prototype, "Features"): + issues.append( + {"severity": "warning", "kind": "unloaded_component", "occurrence_path": path} + ) + else: + parts[int(c.Prototype.Tag)] = c.Prototype + checked = 0 + for owner in parts.values(): + for f in owner.Features: + feature_count += 1 + self._require_api(f, "GetFeatureErrorMessages", "GetFeatureWarningMessages") + for severity, messages in [ + ("error", f.GetFeatureErrorMessages()), + ("warning", f.GetFeatureWarningMessages()), + ]: + for message in messages: + issues.append( + { + "severity": severity, + "kind": "feature_diagnostic", + "part": owner.FullPath, + "feature": f.JournalIdentifier, + "message": message, + } + ) + if f.Suppressed: + issues.append( + { + "severity": "info", + "kind": "suppressed_feature", + "part": owner.FullPath, + "feature": f.JournalIdentifier, + } + ) + for body in owner.Bodies: + checked += 1 + self._require_api(uf.Modeling, "AskBodyConsistency") + n, codes, tags = uf.Modeling.AskBodyConsistency(body.Tag) + if n: + issues.append( + { + "severity": "error", + "kind": "body_consistency", + "part": owner.FullPath, + "body": body.JournalIdentifier, + "object": self._reference(body, "body", owner, "Faulty body"), + "faults": [ + self._consistency_fault(owner, code, tag) + for code, tag in zip(codes, tags, strict=False) + ], + "repair_guidance": "Inspect the identified native entities. Self-intersection requires repairing or replacing the source face; nx_edit_faces heal is an explicit local edit, not a guaranteed body repair. Re-run nx_model_health after any repair and compare bounds/volume before acceptance.", + "fault_codes": list(codes), + "native_fault_tags": [int(t) for t in tags], + } + ) + if not body.IsSolidBody: + issues.append( + { + "severity": "info", + "kind": "sheet_body", + "part": owner.FullPath, + "body": body.JournalIdentifier, + } + ) + errors = sum(i["severity"] == "error" for i in issues) + warnings = sum(i["severity"] == "warning" for i in issues) + return { + **page(issues, offset, limit), + "healthy": errors == 0 and warnings == 0, + "error_count": errors, + "warning_count": warnings, + "parts_checked": len(parts), + "features_checked": feature_count, + "bodies_checked": checked, + "scope": scope, + "checks": [ + "native feature diagnostics", + "suppression", + "loaded prototype availability", + "UF body consistency", + ], + "limitations": [ + "Does not force a rebuild, prove design intent or check external files not loaded into NX.", + "Shared prototypes checked once; suppressed components are reported, not loaded.", + ], + } + + def _rebuild_model(self): + self._update_model() + return {"health": self._model_health(), "modified": None, "update": "native DoUpdate"} + + def _edit_sketch(self, sketch_id, operations): + from nx_mcp.hardened import add + + sketch = self._resolve(sketch_id, {"sketch"}) + active = self.session.ActiveSketch + if active and active != sketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the other active sketch first") + if not isinstance(operations, list) or not 1 <= len(operations) <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "operations requires 1–100 sketch edits") + geometry = {int(c.Tag): c for c in sketch.GetAllGeometry()} + constraints = { + int(c.Tag): c + for c in sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + } + schemas = { + "line": {"action", "curve", "start", "end"}, + "arc": {"action", "curve", "center", "radius", "start_angle", "end_angle"}, + "delete": {"action", "object"}, + "add_line": {"action", "start", "end"}, + "constraint": {"action", "curve", "type"}, + } + prepared = [] + frame = self._sketch_frame(sketch) + + def point2(value): + if not isinstance(value, list) or len(value) != 2: + raise NXToolError("NX_INVALID_ARGUMENT", "Sketch points require local [x,y]") + x, y = (finite(v, "coordinate") for v in value) + return self.nxopen.Point3d( + *add( + frame["origin"], + [x * frame["x_axis"][i] + y * frame["y_axis"][i] for i in range(3)], + ) + ) + + for op in operations: + if ( + not isinstance(op, dict) + or op.get("action") not in schemas + or set(op) != schemas[op["action"]] + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Unsupported or extra sketch edit arguments" + ) + action = op["action"] + obj = ( + self._resolve(op.get("curve", op.get("object")), {"curve", "constraint"}) + if "curve" in op or "object" in op + else None + ) + if ( + obj + and int(obj.Tag) not in geometry + and not (action == "delete" and int(obj.Tag) in constraints) + ): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Object does not belong to this sketch" + ) + data = dict(op) + if action in {"line", "add_line"}: + data["start"], data["end"] = point2(op["start"]), point2(op["end"]) + if op["start"] == op["end"]: + raise NXToolError("NX_INVALID_ARGUMENT", "Line endpoints must differ") + if obj: + self._require_api(obj, "SetEndpoints") + if action == "arc": + self._require_api(obj, "SetParameters") + data["center"] = point2(op["center"]) + data["radius"] = finite(op["radius"], "radius", True) + start, end = ( + finite(op["start_angle"], "start_angle"), + finite(op["end_angle"], "end_angle"), + ) + if not 0 < end - start <= 360: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Arc sweep must be >0 and <=360 degrees" + ) + data["start_angle"], data["end_angle"] = math.radians(start), math.radians(end) + if action == "constraint" and op["type"] not in {"fixed", "horizontal", "vertical"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Constraint type must be fixed, horizontal or vertical" + ) + prepared.append((obj, data)) + activated = not active + try: + if activated: + sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue) + for obj, op in prepared: + action = op["action"] + if action == "line": + obj.SetEndpoints(op["start"], op["end"]) + elif action == "arc": + obj.SetParameters( + op["radius"], op["center"], op["start_angle"], op["end_angle"] + ) + elif action == "add_line": + curve = self._work_part().Curves.CreateLine(op["start"], op["end"]) + sketch.AddGeometry( + curve, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints + ) + elif action == "delete": + self._error_list(sketch.DeleteObjects([obj])) + else: + names = { + "fixed": "CreateFixedConstraint", + "horizontal": "CreateHorizontalConstraint", + "vertical": "CreateVerticalConstraint", + } + geom = self.nxopen.Sketch.ConstraintGeometry() + geom.Geometry = obj + getattr(sketch, names[op["type"]])(geom) + if activated: + sketch.Deactivate( + self.nxopen.Sketch.ViewReorient.FalseValue, self.nxopen.Sketch.UpdateLevel.Model + ) + self._update_model() + return { + "sketch": self._sketch_info(sketch_id), + "diagnostics": self._sketch_diagnostics(sketch_id), + "edit_count": len(prepared), + "modified": [self._reference(sketch, "sketch", self._work_part(), "Sketch")], + } + finally: + if activated and self.session.ActiveSketch == sketch: + sketch.Deactivate( + self.nxopen.Sketch.ViewReorient.FalseValue, self.nxopen.Sketch.UpdateLevel.Model + ) + + def _component_action(self, component, action, name=None, part_path=None): + from nx_mcp.hardened import rows, xyz + + part = self._work_part() + c = self._resolve(component, {"component"}) + if c.Parent != part.ComponentAssembly.RootComponent: + raise NXToolError( + "NX_UNSUPPORTED_SCOPE", + "Activate the immediate owning assembly to edit this occurrence", + ) + if action not in {"rename", "suppress", "unsuppress", "remove", "replace"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported component action") + if (name is not None) != (action == "rename") or (part_path is not None) != ( + action == "replace" + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "rename requires only name; replace requires only part_path" + ) + ref = self._reference(c, "component", part, "Component") + position, matrix = c.GetPosition() + if action == "rename": + if not name.strip() or len(name) > 132: + raise NXToolError("NX_INVALID_ARGUMENT", "name requires 1–132 characters") + c.SetName(name) + elif action in {"suppress", "unsuppress"}: + fn = ( + part.ComponentAssembly.SuppressComponents + if action == "suppress" + else part.ComponentAssembly.UnsuppressComponents + ) + self._error_list(fn([c])) + elif action == "remove": + self.session.UpdateManager.AddToDeleteList(c) + self._update_model() + else: + path = self.workspace.ensure_inside(part_path) + if path.suffix.lower() != ".prt" or not path.is_file(): + raise NXToolError( + "NX_FILE_NOT_FOUND", "Replacement requires an existing workspace .prt" + ) + self._require_api(part.AssemblyManager, "CreateReplaceComponentBuilder") + b = part.AssemblyManager.CreateReplaceComponentBuilder() + try: + b.ComponentsToReplace.Add(c) + b.ReplacementPart = str(path) + b.ReplaceAllOccurrences = False + b.MaintainRelationships = True + b.Commit() + self._error_list(b.GetErrorList()) + finally: + b.Destroy() + self._update_model() + if action != "remove": + after_p, after_m = c.GetPosition() + if math.dist(xyz(position), xyz(after_p)) > 1e-8 or any( + abs(x - y) > 1e-8 + for a, b in zip(rows(matrix), rows(after_m), strict=True) + for x, y in zip(a, b, strict=True) + ): + raise NXToolError( + "NX_PLACEMENT_CHANGED", "Component edit changed placement; rolling back" + ) + return { + "action": action, + "object": self._reference(c, "component", part, "Component") + if action != "remove" + else None, + "deleted": [ref] if action == "remove" else [], + "modified": [ref] if action != "remove" else [], + "placement_preserved": True, + "suppression_scope": "all arrangements" + if action in {"suppress", "unsuppress"} + else None, + } + + def _pattern_components(self, component, direction, spacing, count): + from nx_mcp.hardened import rows, xyz + + c = self._resolve(component, {"component"}) + part = self._work_part() + if c.Parent != part.ComponentAssembly.RootComponent or c.IsSuppressed: + raise NXToolError( + "NX_UNSUPPORTED_SCOPE", "Seed must be an unsuppressed immediate child" + ) + if type(count) is not int or not 2 <= count <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "count includes seed and must be 2–100") + step = finite(spacing, "spacing", True) + axis = unit_normal(direction) + p, m = c.GetPosition() + start = xyz(p) + result = [] + for i in range(1, count): + r = self._add_component( + c.Prototype.FullPath, + c.Name + "_" + str(i + 1), + [start[j] + axis[j] * step * i for j in range(3)], + rows(m), + ) + result.append(r) + return { + "seed": self._reference(c, "component", part, "Component"), + "instances": result, + "total_instances": count, + "spacing": step, + "associative": False, + "pattern_type": "ordinary positioned occurrences", + "warnings": [ + "Instances are independent; changes to pitch require explicit repositioning." + ], + } diff --git a/src/nx_mcp/authoring_server.py b/src/nx_mcp/authoring_server.py new file mode 100644 index 0000000..c176fa2 --- /dev/null +++ b/src/nx_mcp/authoring_server.py @@ -0,0 +1,510 @@ +"""Public signatures and contracts for authoring/review tools.""" + +from __future__ import annotations + +from typing import Any, Literal + +READ_ONLY = { + "nx_list_explosions", + "nx_explosion_info", + "nx_find_geometry", + "nx_list_expressions", + "nx_model_health", + "nx_model_summary", + "nx_resolve_geometry", + "nx_recognize_holes", + "nx_list_component_patterns", + "nx_sketch_conflicts", + "nx_feature_parameters", +} +NON_MODEL = { + "nx_highlight_objects", + "nx_save_presentation", + "nx_inspection_report", + "nx_preview_change", +} + + +def nx_find_geometry( + owner: str | None = None, + kind: Literal["face", "edge"] = "face", + geometry_type: Literal["any", "plane", "cylinder", "circle", "line"] = "any", + normal: list[float] | None = None, + radius: float | None = None, + near: list[float] | None = None, + order: Literal["nearest", "highest", "lowest"] = "nearest", + axis: Literal["X", "Y", "Z"] = "Z", + tolerance: float = 0.001, + offset: int = 0, + limit: int = 50, +): + """Find geometry within a body/feature/component or full assembly. Coordinates and radii use work-part units/frame. Nearest uses native BREP point-to-face/edge minimum distance and closest points; highest/lowest use conservative bounds centers. Returns a reusable geometric selector; resolve it explicitly after edits with nx_resolve_geometry. nearest requires near=[x,y,z]. Normal filter requires planar faces; tolerance is 1-dot for normals and absolute length for radii. Returns paginated candidates; never silently selects one.""" + + +def nx_highlight_objects(objects: list[str]): + """Preview candidate body/face/edge/curve/component references using native selection highlighting. Replaces MCP-owned highlights; clear with nx_clear_highlights. No persistent recoloring.""" + + +def nx_list_expressions(name_contains: str | None = None, offset: int = 0, limit: int = 50): + """Paginate work-part expressions, formulas, numeric values in expression units, editability and stored immediate dependencies. Names are case-sensitive. Non-numeric values are null. Conditional dependencies may be incomplete.""" + + +def nx_set_expression( + expression: str, + formula: str, + create: bool = False, + units: Literal["unitless", "mm", "inch", "deg", "rad"] = "unitless", +): + """Create a named Number expression or edit a local editable expression ID/name. Formula is NX expression syntax. Units apply only on creation; edits retain original units. Rebuild dependent features and roll back on errors. New name must be a simple identifier. Locked/interpart expressions are rejected.""" + + +def nx_bind_parameter( + feature: str, parameter: Literal["start", "end", "count", "spacing"], expression: str +): + """Bind EXTRUDE start/end limits or PATTERN_FEATURE count/spacing to an existing Number expression. Native units/dimensional compatibility applies; failed updates roll back. Returns feature dependencies and expression read-back.""" + + +def nx_model_health(scope: Literal["part", "assembly"] = "part", offset: int = 0, limit: int = 50): + """Inspect native feature errors/warnings, suppression, unloaded prototype availability and UF body consistency. Assembly scope checks unique loaded unsuppressed prototypes. Does not force rebuild or certify design intent. Paginate issues; counts cover the whole query.""" + + +def nx_rebuild_model(): + """Run native DoUpdate for pending changes and return model health. Update errors roll back this operation; does not force every already-current feature to regenerate.""" + + +def nx_edit_sketch(sketch_id: str, operations: list[dict[str, Any]]): + """Reopen an existing sketch for 1–100 atomic local edits; restore prior activation and return solver diagnostics. Points are local [x,y], angles degrees. Operations: {action:add_line,start,end}; {action:line,curve,start,end}; {action:arc,curve,center,radius,start_angle,end_angle}; {action:delete,object} for owned curve/constraint; {action:constraint,curve,type:fixed|horizontal|vertical}. Curve ownership is checked; another active sketch is rejected. Edit dimensional constraints through their expression IDs with nx_set_expression. Deleting or recreating constraints is explicit; no automatic constraint removal.""" + + +def nx_component_action( + component: str, + action: Literal["rename", "suppress", "unsuppress", "remove", "replace"], + name: str | None = None, + part_path: str | None = None, +): + """Rename, replace, remove, suppress or unsuppress an immediate child occurrence of the work assembly. rename requires name; replace requires existing workspace .prt part_path and replaces only this occurrence while retaining relationships. Other actions reject those fields. Suppression affects all arrangements. Checks unchanged placement; native errors roll back. Prototype files are not deleted by remove.""" + + +def nx_pattern_components(component: str, direction: list[float], spacing: float, count: int): + """Create 2–100 total occurrences including the seed at a positive pitch in work-part units along normalized direction. Seed must be an unsuppressed immediate child. Returns independent positioned occurrences, not a native associative pattern. Runs serially in one rollback transaction.""" + + +def nx_set_camera(rotation: list[list[float]], origin: list[float], scale: float): + """Set absolute NX viewport camera. rotation is right-handed orthonormal row-major 3x3 with columns NX view axes; origin is the view-space point centered in the viewport; scale is positive absolute NX scale. Work and display part must match. Returns actual camera.""" + + +def nx_save_presentation(path: str): + """Save camera, one active section, explicit appearance and loaded assembly body/component visibility into a new workspace JSON file. Includes per-face colors/transparency and journal locators. Does not save the CAD part. Excludes datum visibility, materials and inherited-override semantics.""" + + +def nx_restore_presentation(path: str): + """Restore a saved presentation in its original owner part. Preflight all journal references before mutation; stale references reject restoration. Geometry is unchanged, but display attributes can mark the part modified. Across topology revisions verify journal references still denote intended geometry.""" + + +def nx_inspection_report( + path: str, + objects: list[str] | None = None, + minimum_clearance: float = 0.0, + max_pairs: int = 100, + include_clear: bool = False, + capture: bool = True, + section_planes: list[dict[str, list[float]]] | None = None, +): + """Create a new workspace ZIP containing HTML/JSON clearance results, checksums, native viewport PNG, up to eight flagged-pair close-ups and six section screenshots. section_planes entries require origin and normal in work-part coordinates. Restores temporary view/visibility/section changes. max_pairs caps native clearance work; report does not imply unchecked pairs are clear. Retrieve with nx_download_file.""" + + +def nx_model_summary( + section: Literal["overview", "components", "features", "expressions", "sketches"] = "overview", + offset: int = 0, + limit: int = 50, +): + """Compact work-part summary: counts, bounds, native health and paginated assembly hierarchy, feature parents/parameters, expressions or sketch frames. Owned-body and occurrence counts are separate. Does not infer missing design dimensions.""" + + +def nx_preview_change(operations: list[dict[str, Any]], capture: bool = True): + """Temporarily apply 1–25 edits under a native checkpoint, return before/after bounds/volume/parameters/health and optional screenshot, then roll back before returning. Each entry has method and params. Supports nx_set_expression, nx_bind_parameter, nx_edit_feature, nx_edit_sketch, nx_set_component_transform and nx_reposition_component. Returned geometry IDs are stale after rollback. Preview is session-scoped and expires on intervening mutations or manual handoff.""" + + +def nx_finish_preview(preview_id: str, action: Literal["accept", "discard"]): + """Accept by atomically reapplying stored preview edits only if its part and mutation epoch are unchanged, or discard the stored plan. Preview already restored original geometry. Accepted edits remain unsaved and undoable. Stable operation_id prevents double application.""" + + +# Nested operation schemas are published explicitly; embedded NX validates the +# same exact key sets before changing sketch geometry. +POINT2_SCHEMA = {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2} +SKETCH_OPERATION_SCHEMAS = [] +_SKETCH_FIELDS: dict[str, dict[str, Any]] = { + "add_line": {"start": POINT2_SCHEMA, "end": POINT2_SCHEMA}, + "line": {"curve": {"type": "string"}, "start": POINT2_SCHEMA, "end": POINT2_SCHEMA}, + "arc": { + "curve": {"type": "string"}, + "center": POINT2_SCHEMA, + "radius": {"type": "number", "exclusiveMinimum": 0}, + "start_angle": {"type": "number"}, + "end_angle": {"type": "number"}, + }, + "delete": {"object": {"type": "string"}}, + "constraint": { + "curve": {"type": "string"}, + "type": {"type": "string", "enum": ["fixed", "horizontal", "vertical"]}, + }, +} +for _action, _fields in _SKETCH_FIELDS.items(): + SKETCH_OPERATION_SCHEMAS.append( + { + "type": "object", + "properties": {"action": {"const": _action}, **_fields}, + "required": ["action", *_fields], + "additionalProperties": False, + } + ) + + +def nx_resolve_geometry(selector: dict[str, Any], tie_tolerance: float = 0.001): + """Re-evaluate a selector returned by nx_find_geometry in its original owner part. Returns a fresh face/edge ID only if the best match is unique within tie_tolerance in part units. No match, ambiguous rank or stale owner is an explicit error. Survives edits/reopen by geometric rule, not a promise of persistent topological identity. Whole-part rules may select new geometry that now satisfies the rule.""" + + +def nx_recognize_holes(owner: str | None = None, offset: int = 0, limit: int = 50): + """Recognize inward cylindrical BREP faces and return bore radius, axis, angular coverage and coaxial groups in work-part coordinates/units. Partial cylindrical faces are identified explicitly. Does not infer threads, manufacturing features, blind/through termination or fit classes.""" + + +def nx_native_component_pattern(component: str, direction: list[float], spacing: float, count: int): + """Create a native associative linear component pattern with 2–100 total occurrences including one immediate unsuppressed seed. Direction is normalized in work-part coordinates; spacing is positive part units. Native builder and pattern members are read back. Rollback on update/count failure. Existing nx_pattern_components retains independent-instance behavior.""" + + +def nx_edit_component_pattern( + pattern: str, + spacing: float | None = None, + count: int | None = None, + count_y: int | None = None, + spacing_y: float | None = None, + angle: float | None = None, +): + """Edit an associative single-seed rectangular or circular component pattern. Rectangular supports count/spacing and existing second-direction count_y/spacing_y. Circular supports count/angular pitch in degrees. Counts include seed and total instances must be <=100. Return native expressions and all actual placements; invalid edits roll back.""" + + +def nx_list_component_patterns(): + """Enumerate native work-assembly component patterns, IDs, native type, association, count/pitch expressions for linear patterns and member occurrence poses. Independent instances are not patterns.""" + + +def nx_sketch_dimension( + sketch_id: str, + curve: str, + dimension_type: Literal["length", "horizontal", "vertical", "radius", "diameter"], + value: float, + origin: list[float], + reference: bool = False, +): + """Create a native sketch dimension: line endpoint length/horizontal/vertical distance or arc radius/diameter. Value is positive part units; annotation origin is local [x,y]. Driving dimensions use value, reference dimensions measure existing geometry and require value matching it within 0.001 part units. Returns the associated expression for later formula editing. Atomic and restores activation; conflicting solver state rolls back.""" + + +def nx_sketch_relation( + sketch_id: str, + curve1: str, + curve2: str, + relation: Literal[ + "parallel", "perpendicular", "equal_length", "equal_radius", "concentric", "coincident" + ], + point1: Literal["start", "end", "center"] | None = None, + point2: Literal["start", "end", "center"] | None = None, +): + """Create a persistent two-curve sketch relation using the installed solver; modern sketches keep curve1 stationary and move curve2 as needed. Geometric residual is checked before success. Parallel/perpendicular/equal_length require lines; equal_radius/concentric require arcs; coincident requires explicit start/end (line) or center (arc) for each curve. Other relations reject point arguments. Ownership and types checked before mutation. Conflicting solver results roll back; no constraints are automatically removed.""" + + +def nx_sketch_conflicts(sketch_id: str, max_checks: int = 20): + """Diagnose an over/inconsistently constrained sketch by temporary single-constraint removal and native solver reevaluation, restoring each trial. max_checks 1–50 bounds serial NX work. Returns constraints whose removal relieves the conflict, statuses, checked/total and completeness. Also checks contradictory horizontal/vertical persistent relations on a nonzero line because NX solver status can omit them. This is not a minimal conflicting set; multiple independent conflicts may produce no single-removal relief. No geometry or constraints retained from trials.""" + + +def nx_feature_parameters(feature: str): + """List native expressions owned by a feature, with IDs, formulas, units, editability and dependencies. Parameter names are NX expression names, not inferred semantic labels. Applicable to feature types exposing GetExpressions.""" + + +def nx_set_feature_parameters(feature: str, values: dict[str, str]): + """Atomically set 1–25 owned, editable local Number expressions on a feature. Keys are expression IDs or exact names from nx_feature_parameters; values are NX formulas in existing expression units. Preflight ownership/editability; native update failures roll back all changes. Can bind to another expression by its name. Does not alter unexposed builder options or locked/interpart expressions.""" + + +def nx_extrude( + sketch_id: str, + distance: float | None = None, + reverse: bool = False, + start: float = 0.0, + end_type: Literal["distance", "through_all", "up_to_face"] = "distance", + symmetric: bool = False, + direction: list[float] | None = None, + target_face: str | None = None, + boolean: Literal["none", "unite", "subtract", "intersect"] = "none", + targets: list[str] | None = None, +): + """Extrude an owned sketch along its normal or a work-part direction. Lengths use part units. distance is the end coordinate from the sketch plane; start is the start coordinate. Symmetric uses +/- distance/2 and requires start=0. Omit distance for through_all/up_to_face; up_to_face requires target_face. Boolean operations require explicit owned target bodies; through_all requires a boolean. Returns every result body. reverse flips the chosen direction.""" + + +def nx_shell( + body: str, thickness: float, remove_faces: list[str] | None = None, outward: bool = False +): + """Create a native shell in an owned solid body. Positive thickness uses part units; outward reverses the thickness side. remove_faces must belong to that body; omit for a closed hollow body. Native failures roll back.""" + + +def nx_loft(sketches: list[str], solid: bool = True): + """Create a native through-curves loft through 2–20 ordered owned sketches. Solid output requires compatible closed profiles; solid=false creates a sheet. Sketch order controls loft direction. Returns all bodies; native errors roll back.""" + + +def nx_sketch_primitive( + sketch_id: str, + primitive: Literal["circle", "slot", "rounded_rectangle"], + center: list[float], + width: float | None = None, + height: float | None = None, + radius: float | None = None, +): + """Add editable native sketch curves atomically in sketch-local coordinates. Circle needs radius only. Horizontal slot needs width>height and has end radius=height/2. Rounded rectangle needs width,height and corner radius less than half the shorter side. center is [x,y]; all lengths use part units. Returns curve IDs and solver diagnostics; no implicit constraints.""" + + +def nx_copy_project(path: str, prefix: str, activate: bool = False): + """Clone the saved work assembly and all loaded prototypes into a new workspace directory using native NX cloning. Preserve relative subfolders; prepend a required simple prefix to each part basename to avoid NX loaded-name conflicts. Verify rewritten dependencies and source hashes; write a manifest. Source files remain intact. activate selects the copied assembly; otherwise restore the original session. Reject existing destinations, unsaved sources and unloaded dependencies.""" + + +def nx_mass_properties( + body: str | None = None, scope: Literal["auto", "part", "assembly"] = "auto" +): + """Measure native solid mass, volume, area, center of gravity and centroidal inertia using assigned densities. Outputs kg, m and kg*m^2 in the work-part WCS, including that WCS's origin/basis and native error estimates. Assembly scope includes loaded unsuppressed occurrence geometry. Overlaps are summed, not geometrically united; review assigned densities.""" + + +READ_ONLY.add("nx_mass_properties") + + +def nx_draft(faces: list[str], stationary_face: str, direction: list[float], angle: float): + """Create native face draft about a stationary face on the same owned body. direction is in work-part coordinates; angle is signed degrees with magnitude <89. Selected faces must exclude the stationary face. Returns all result bodies; native errors roll back.""" + + +def nx_transform_bodies( + translation: list[float], + rotation_matrix: list[list[float]], + bodies: list[str] | None = None, + copy: bool = False, + feature: str | None = None, +): + """Create or edit an associative in-part move/copy feature. Transform is p_out=R*p_input+translation, in work-part units with a right-handed orthonormal row-major R. New features require owned bodies; copy=true preserves originals. Pass the returned feature ID, omitting bodies/copy, to replace its absolute transform without accumulating motion. operation_id deduplicates retries. No assembly occurrence moves.""" + + +def nx_component_array( + component: str, + pattern_type: Literal["rectangular", "circular"], + count: int, + spacing: float | None = None, + direction: list[float] | None = None, + count_y: int = 1, + spacing_y: float | None = None, + direction_y: list[float] | None = None, + center: list[float] | None = None, + axis: list[float] | None = None, + angle: float | None = None, +): + """Create an associative native component array with 2–100 total instances including seed. Rectangular uses direction/spacing and optional count_y/direction_y/spacing_y. Circular uses center/axis and positive angular pitch in degrees; positions must not wrap to duplicate the seed. Coordinates/lengths use work-part frame/units. Seed must be an unsuppressed immediate child. Parameters for the other pattern type are rejected.""" + + +def nx_set_material(bodies: list[str], name: str, density: float): + """Create a named local isotropic physical material with density in kg/m^3 and assign it to owned solid bodies. Reject an existing material name to avoid implicit edits to other assignments. Verify native body density. Defines density only; does not invent elastic, thermal or appearance properties.""" + + +def nx_material_info(body: str | None = None, scope: Literal["auto", "part", "assembly"] = "auto"): + """Read native physical material names and body densities in kg/m^3, including occurrence prototypes. Does not infer density from color or labels.""" + + +def nx_sketch_angle(sketch_id: str, line1: str, line2: str, value: float, origin: list[float]): + """Create a driving angular dimension between two owned sketch lines. value is 0–180 degrees exclusive; origin is the annotation position in local [x,y]. Returns its editable expression and solver diagnostics.""" + + +def nx_sketch_tangent(sketch_id: str, curve1: str, curve2: str): + """Create a persistent native tangent relation for line/arc or arc/arc pairs. First curve is stationary. Verify geometric tangency and solver diagnostics; native failures roll back.""" + + +def nx_sketch_symmetry(sketch_id: str, curve1: str, curve2: str, centerline: str): + """Create native sketch symmetry between two owned lines about a distinct owned straight centerline. Restore prior activation and return solver diagnostics; no implicit constraint deletion.""" + + +def nx_sketch_trim_extend( + sketch_id: str, + curve: str, + boundaries: list[str], + pick: list[float], + action: Literal["trim", "extend"], +): + """Trim or extend an owned sketch curve against explicit owned boundary curves. pick=[x,y] in sketch-local coordinates identifies the segment to remove or the end to extend. Does not extend boundaries. Atomic native edit; reacquire curve references from the returned sketch after topology changes.""" + + +READ_ONLY.add("nx_material_info") + + +def nx_render_view( + path: str | None = None, + width: int = 1600, + height: int = 1000, + background: Literal["white", "original", "transparent", "color"] = "white", + color: list[float] | None = None, + style: Literal["studio", "shaded", "shaded_with_edges"] = "studio", + lighting: int | None = None, +): + """Render the current interactive NX camera to a new PNG using native Studio image capture. Exact width/height in pixels (128–4096), optional native lighting preset 1–5, and original/white/transparent/custom RGB [0,1] background. Restore temporary view style and lighting afterward. Return camera, checksum, artifact path and inline image. Uses existing model appearance; does not invent physical materials. Requires installed native rendering capability.""" + + +def nx_list_assembly_constraints(): + """Inspect native constraints defined in the work assembly: typed IDs, component/geometry references, alignment, suppression, expressions and native solver status. Does not infer that unconstrained components are fixed.""" + + +def nx_assembly_constraint( + constraint_type: Literal[ + "fix", "touch", "distance", "parallel", "perpendicular", "angle", "concentric" + ], + component: str, + geometry: str | None = None, + target_component: str | None = None, + target_geometry: str | None = None, + value: float | None = None, + alignment: Literal["infer", "same", "opposite"] = "infer", +): + """Create a persistent native assembly constraint between unsuppressed immediate child components. Fix takes only component. Other types require geometry and target_geometry occurrence face/edge IDs owned by the respective components. Distance uses part units, angle degrees. Native solver must report Solved or operation rolls back. Can move components while solving; reacquire poses afterward.""" + + +def nx_edit_assembly_constraint( + constraint: str, + value: float | None = None, + suppressed: bool | None = None, + alignment: Literal["infer", "same", "opposite"] | None = None, +): + """Edit an existing typed assembly constraint's distance/angle, suppression, or alignment. Solve natively and roll back unsatisfied edits. Values are absolute, in part length units or degrees. Component positions may change during solving.""" + + +READ_ONLY.add("nx_list_assembly_constraints") + + +def nx_blend(edges: list[str], radius: float): + """Create an associative native edge blend on explicitly selected owned edge IDs from one body. Positive radius uses part units. Return all result bodies and roll back invalid blends.""" + + +def nx_chamfer(edges: list[str], offset: float): + """Create an associative symmetric-offset native chamfer on owned edge IDs from one body. Positive offset uses part units. Reacquire topology references after editing.""" + + +def nx_hole( + diameter: float, + depth: float, + x: float, + y: float, + z: float, + body: str | None = None, + direction: list[float] | None = None, +): + """Cut a simple cylindrical hole from [x,y,z] along direction (default +Z) for positive depth. Part units and work-part coordinates. Uses native cylinder subtraction; no drill tip, thread or counterbore. Require explicit body in a multi-body part. Native failures roll back.""" + + +def nx_sweep( + section: str, + guide: str, + boolean: Literal["none", "unite", "subtract", "intersect"] = "none", + targets: list[str] | None = None, +): + """Create an associative native sweep from an owned section sketch along a distinct owned guide sketch. Closed compatible sections produce solids. Optional boolean requires exactly one explicit owned target body; none forbids targets. Return all output bodies; native failures roll back.""" + + +def nx_mate_component( + component: str, + mate_type: Literal["touch", "align", "orient", "center", "align_angle"], + references: list[str] | None = None, + offset: float = 0.0, +): + """Create a native assembly mate using two occurrence face/edge references [moving,target]. Touch uses opposite alignment; align uses same alignment; nonzero offset creates a distance. Orient is parallel, center is concentric, align_angle uses offset in degrees. Other lengths use work-part units. Prefer nx_assembly_constraint for explicit constraint semantics.""" + + +def nx_mirror_body(body: str, plane: Literal["XY", "XZ", "YZ"]): + """Create a native mirrored copy of an owned body about a principal plane through the work-part origin. Preserve source body; create a datum plane and editable mirror feature. Return every result body.""" + + +def nx_create_drawing( + name: str = "Sheet1", + size: Literal["A0", "A1", "A2", "A3", "A4"] = "A3", + scale: float = 1.0, + units: Literal["mm", "in"] = "mm", +): + """Create and open a native landscape A0–A4 sheet with independent mm/in sheet units, first-angle projection and positive scale. Sheet sizes retain their physical dimensions; coordinates use the selected sheet units, independent of model units.""" + + +def nx_add_base_view( + drawing: str, + body: str | None = None, + view: Literal["top", "front", "back", "right", "left", "bottom", "isometric"] = "isometric", + position: list[float] | None = None, + scope: Literal["body", "assembly"] = "body", + explosion: str | None = None, +): + """Add a native base view to a drawing sheet. scope=body requires body and a single-body part. scope=assembly requires no body, uses current component reference sets/suppression, and optionally associates a typed explosion from the same work part. Omitted explosion explicitly uses assembled positions. position=[x,y] uses sheet units, default [100,100]. Return typed view reference; open the target sheet.""" + + +def nx_export_drawing_pdf(path: str): + """Export all work-part drawing sheets to a new workspace PDF using native NX plotting. Temporarily display sheets to refresh their presentation, then restore the prior work/display part and drawing/modeling view. Full sheet scale, metric dimensions and searchable text. Returns actual path, sheet names/count, size and checksum. Reject missing drawings and existing files.""" + + +def nx_add_projection_view( + base_view: str, direction: Literal["right", "left", "top", "bottom"], spacing: float = 60.0 +): + """Create a native associative projected view on the currently open sheet. Direction describes sheet placement relative to the parent; projection follows the sheet convention. Positive spacing uses sheet units. Returns a typed drawing-view reference.""" + + +def nx_add_dimension( + view: str, + object1: str, + object2: str | None = None, + dim_type: Literal["aligned", "horizontal", "vertical"] = "aligned", + origin: list[float] | None = None, + dimension: str | None = None, +): + """Create a native associative linear drawing dimension from owned or work-assembly occurrence edge IDs. One edge measures start-to-end; two edges measure their start vertices. Types are aligned/horizontal/vertical in the drawing view. origin=[x,y] uses sheet units, default [100,80]. Pass dimension to rebind an existing linear dimension explicitly, retaining its identity; origin/default and dim_type apply to edits too. Returns actual computed size in model units and a typed dimension ID.""" + + +def nx_create_explosion(name: str): + """Create a named native explosion in the work/display assembly, initially at assembled positions. Names are unique case-insensitively, nonempty and <=132 characters. Return a typed explosion ID. Requires assemblies license. Does not reposition actual components or show the explosion automatically.""" + + +def nx_list_explosions(): + """List native explosions owned by the work part, typed IDs, names and referencing model/drawing views. No model or view changes.""" + + +def nx_explosion_info(explosion: str, offset: int = 0, limit: int = 50): + """Inspect an explosion by typed ID. Paginate occurrences (limit 1–200), actual exploded and assembled positions, rotations, paths and suppression. Positions are absolute work-assembly coordinates in part units; matrices are right-handed row-major. Ordinary body/clearance/mass tools still measure assembled geometry.""" + + +def nx_edit_explosion( + explosion: str, + placements: list[dict[str, Any]] | None = None, + reset_components: list[str] | None = None, +): + """Atomically replace absolute exploded poses or reset selected components to inherited parent-explosion positions. Provide 1–1000 unique occurrences across placements and reset_components. Each placement has component ID, translation=[x,y,z] in assembly coordinates/part units, and optional right-handed orthonormal row-major rotation_matrix; omitted rotation retains the current exploded world orientation at request start. Parents apply before children; descendants inherit parent changes. Resolves and validates every item before mutation, verifies final native poses and unchanged actual placements, and updates associated drawing views. Safe repeated absolute placement; use operation_id for transport retries.""" + + +def nx_show_explosion( + explosion: str | None = None, drawing_view: str | None = None, model_view: str | None = None +): + """Display a native explosion, or assembled positions when explosion is null. Without drawing_view, return to 3D modeling and fit the work view. With an owned drawing-view ID, set that view's explosion association and update it. model_view alternatively targets an existing saved model-view ID without switching the visible view. The two targets are mutually exclusive. Work and display parts must match; finish active sketches first. Changes view presentation, never actual component placement.""" + + +def nx_delete_explosion(explosion: str): + """Delete a native explosion only when no model/drawing views reference it. Detach dependent views with nx_show_explosion(explosion=null) first. Native deletion is transactional; returned reference becomes stale. Actual assembly components are retained.""" + + +EXPLOSION_PLACEMENT_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["component", "translation"], + "properties": { + "component": { + "type": "string", + "description": "Typed occurrence reference in this explosion.", + }, + "translation": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, + "rotation_matrix": { + "type": "array", + "minItems": 3, + "maxItems": 3, + "items": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}, + }, + }, +} diff --git a/src/nx_mcp/bridge.py b/src/nx_mcp/bridge.py index 244c317..7103802 100644 --- a/src/nx_mcp/bridge.py +++ b/src/nx_mcp/bridge.py @@ -10,7 +10,7 @@ import secrets import socketserver from collections.abc import Callable -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from pathlib import Path from queue import Empty, Queue from threading import Event, Lock, Thread @@ -69,6 +69,8 @@ def write(self, path: str | Path) -> None: def default_descriptor_path() -> Path: + if configured := os.environ.get("NX_MCP_BRIDGE_DESCRIPTOR"): + return Path(configured) local_app_data = os.environ.get("LOCALAPPDATA") if local_app_data: return Path(local_app_data) / "nx-mcp" / "bridge.json" @@ -88,7 +90,8 @@ class _BridgeTCPServer(socketserver.TCPServer): executor: Any token: str - def __init__(self, executor: Any, token: str) -> None: + def __init__(self, executor: Any, token: str, result_directory: Path | None = None) -> None: + self.result_directory = result_directory self.executor = executor self.token = token super().__init__(("127.0.0.1", 0), _BridgeRequestHandler) @@ -118,6 +121,9 @@ def handle(self) -> None: if not isinstance(method, str) or not isinstance(params, dict): raise NXToolError("NX_INVALID_REQUEST", "Bridge method and params are invalid") result = self.server.executor(method, params) + from nx_mcp.result_transport import bound_result + + result = bound_result(result, self.server.result_directory) response = { "jsonrpc": "2.0", "protocol_version": BRIDGE_PROTOCOL_VERSION, @@ -144,11 +150,26 @@ def handle(self) -> None: self.wfile.write(json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n") +class _ThreadedBridgeTCPServer(socketserver.ThreadingMixIn, _BridgeTCPServer): + daemon_threads = True + + class BridgeServer: """A serialized loopback JSON-RPC server for an NX-side executor.""" - def __init__(self, executor: Any, *, token: str) -> None: - self._server = _BridgeTCPServer(executor, token) + def __init__( + self, + executor: Any, + *, + token: str, + result_directory: Path | None = None, + concurrent_requests: bool = False, + ) -> None: + # Interactive callers serialize NXOpen through MainThreadDispatcher. + # Concurrent socket handling permits cached health reads during work; + # the batch/default executor retains its original serialized transport. + server_type = _ThreadedBridgeTCPServer if concurrent_requests else _BridgeTCPServer + self._server = server_type(executor, token, result_directory) self._thread: Thread | None = None @property @@ -177,6 +198,8 @@ class _PendingBridgeCall: complete: Event = field(default_factory=Event) result: dict[str, Any] | None = None error: Exception | None = None + started: bool = False + cancelled: bool = False class MainThreadDispatcher: @@ -206,10 +229,15 @@ def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: self._calls.put(pending) if not pending.complete.wait(self._timeout): + with self._lock: + if not pending.started: + pending.cancelled = True + outcome = "unknown" if pending.started else "not_started" raise NXToolError( "NX_MAIN_THREAD_UNAVAILABLE", - "NX did not process the bridge request before the timeout.", - retryable=True, + "NX did not complete the bridge request before the timeout. Query the operation receipt before retrying a started mutation.", + retryable=not pending.started, + details={"mutation_outcome": outcome}, ) if pending.error is not None: raise pending.error @@ -254,18 +282,24 @@ def stop(self) -> None: def _execute(self, pending: _PendingBridgeCall) -> None: with self._lock: - if self._stopped: + if self._stopped or pending.cancelled: pending.error = NXToolError( "NX_BRIDGE_UNAVAILABLE", - "NX bridge is stopping.", + "Bridge stopped or queued request expired before execution.", retryable=True, + details={"mutation_outcome": "not_started"}, ) - else: - try: - pending.result = self._executor(pending.method, pending.params) - except Exception as error: - pending.error = error - pending.complete.set() + pending.complete.set() + return + pending.started = True + # The queue consumer is serialized; release the queue lock so a waiting + # caller can distinguish queued expiry from a running, unknown outcome. + try: + pending.result = self._executor(pending.method, pending.params) + except Exception as error: + pending.error = error + finally: + pending.complete.set() class BridgeClient: @@ -301,6 +335,12 @@ async def call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: writer.write(json.dumps(request, ensure_ascii=False).encode("utf-8") + b"\n") await writer.drain() raw = await asyncio.wait_for(reader.readline(), timeout=self.timeout) + except ValueError as error: + raise NXToolError( + "NX_RESPONSE_TOO_LARGE", + "Bridge response exceeded framing limit; inspect operation status before retry", + details={"operation_id": params.get("operation_id"), "mutation_outcome": "unknown"}, + ) from error except (OSError, asyncio.TimeoutError) as error: raise NXToolError( "NX_BRIDGE_UNAVAILABLE", @@ -385,6 +425,7 @@ class ObjectRegistry: """Maps opaque, session-scoped IDs to live NXOpen objects.""" def __init__(self) -> None: + self.session_id: str | None = None self._objects: dict[str, _ObjectEntry] = {} self._stale_ids: set[str] = set() self._identities: dict[tuple[str, ObjectKind, str], str] = {} @@ -394,9 +435,12 @@ def register(self, value: Any, *, kind: ObjectKind, name: str, part_id: str) -> identity = str(native_identity if native_identity is not None else id(value)) identity_key = (part_id, kind, identity) if object_id := self._identities.get(identity_key): - return self._objects[object_id].reference + entry = self._objects[object_id] + if entry.reference.name != name: + entry.reference = replace(entry.reference, name=name) + return entry.reference reference = ObjectRef( - id=f"obj_{uuid4().hex}", + id=f"obj_{getattr(self, 'session_id', '') + '_' if getattr(self, 'session_id', None) else ''}{uuid4().hex}", kind=kind, name=name, part_id=part_id, @@ -414,7 +458,15 @@ def resolve( ) -> Any: entry = self._objects.get(object_id) if entry is None: - code = "NX_OBJECT_STALE" if object_id in self._stale_ids else "NX_OBJECT_NOT_FOUND" + foreign_session = bool( + self.session_id is not None + and not object_id.startswith("obj_" + self.session_id + "_") + ) + code = ( + "NX_OBJECT_STALE" + if object_id in self._stale_ids or foreign_session + else "NX_OBJECT_NOT_FOUND" + ) raise NXToolError(code, f"Object reference is not valid: {object_id}") if expected_kind is not None and entry.reference.kind != expected_kind: raise NXToolError( diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json new file mode 100644 index 0000000..5067901 --- /dev/null +++ b/src/nx_mcp/capability_manifest.json @@ -0,0 +1,971 @@ +{ + "revision": "2606-manufacturing-r1", + "nx_version": "v2606", + "bridge_protocol": 1, + "tools": { + "nx_rollback": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Explicit checkpoint rollback; stale references rejected afterward" + }, + "nx_export_drawing_pdf": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native full-sheet PDF with explicit high-resolution shaded images and updated view lists. Saved/reopened shaded solid and section verified visually; unloaded unsuppressed prototypes fail preflight. Hidden-edge invisibility verified with processing on and font 0." + }, + "nx_finish_sketch": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Principal/custom sketch completion and subsequent extrusion" + }, + "nx_chamfer": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native single-edge symmetric-offset chamfer on a cube; explicit edge collector and tolerance." + }, + "nx_mirror_body": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native body mirror about YZ origin plane; doubled total volume and reflected bounding box." + }, + "nx_create_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Fresh millimeter parts in isolated NX test workspace" + }, + "nx_activate_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Used by loaded-part opening; work/display activation; modified flags preserved" + }, + "nx_add_projection_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native right projected view and associative parent; exported PDF visually reviewed." + }, + "nx_upload_file": { + "status": "tested", + "evidence_type": "local_contract_test", + "scope": "Chunk replay, final checksum, atomic publication, no overwrite and workspace boundary tests" + }, + "nx_edit_feature": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Extrusion distance 46.25 and native linear-pattern count/pitch; unsupported edit unchanged" + }, + "nx_sketch_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native XY sketch with two perpendicular lines; frame and owned curve readback. Principal/custom-frame geometry also covered by the sketch creation fixtures." + }, + "nx_hole": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native cylindrical subtraction with numeric coordinates, target body and direction; not a threaded/drill-tip HolePackage feature." + }, + "nx_close_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Blocks closing prototypes referenced by other loaded assemblies before saving. Native edited-child guard passed; close parents first. Reports all parts actually closed and invalidates their references." + }, + "nx_measure_volume": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Part and nested assembly sum, returned in mm^3; no union/mass claim Inch-part 0.5 cubic inch volume independently checked as 8193.532 mm3 using explicit native AnalysisUnit." + }, + "nx_batch": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Two-step partial failure fully rolled back; serial execution on journal thread" + }, + "nx_undo": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Undo after read-only inspection; save boundary explicitly inspected" + }, + "nx_status": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Live graphical NX 2606 connection, version and UI/main-thread status; restored saved session." + }, + "nx_sketch_line": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Principal-plane profile coordinates checked against resultant solids" + }, + "nx_export_step": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Solid/assembly exports verified by import counts, volumes, exact bounds and transforms" + }, + "nx_import_geometry": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "STEP solids and nested assembly through WorkPart importer, normal new-part creation; source prototypes closed explicitly; names preflighted" + }, + "nx_workspace_list": { + "status": "tested", + "evidence_type": "live_sidecar_and_contract_tests", + "scope": "Live sidecar nested fixture directory with one-entry pagination; workspace boundary and pagination contracts tested locally." + }, + "nx_revolve": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "XY rectangular profile around global Y, boolean none, case-insensitive name lookup" + }, + "nx_list_open_parts": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Loaded names, paths, IDs, work/display status and modified flags" + }, + "nx_rename_object": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native extrusion rename resolved again by returned name after Save As. Other object kinds are not independently covered by this fixture." + }, + "nx_add_dimension": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native horizontal edge dimension with computed size10mm; exported PDF visually reviewed. Aligned/vertical variations are not separately kernel-tested." + }, + "nx_measure_distance": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Body/body, face/face, nested component/body occurrences; closest points and units" + }, + "nx_mate_component": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native touch mate at zero clearance and offset mate at7mm verified by measured separation. Other mate types have narrower validation." + }, + "nx_set_component_transform": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Absolute immediate-child placement; repeated identical pose" + }, + "nx_operation_status": { + "status": "tested", + "evidence_type": "local_contract_test", + "scope": "Durable committed/failed/unknown receipt tests; no crash reconstruction claimed" + }, + "nx_add_component": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Initial placement, typed references and nested source assembly" + }, + "nx_sketch_arc": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Full circles on XY, XZ and YZ; resulting solid dimensions and volumes checked" + }, + "nx_open_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Open or activate saved workspace paths. Explicit load_components=true recovers unloaded prototypes on an already loaded parent without closing it; native occurrence pose and 1000 mm3 solid retained." + }, + "nx_measure_angle": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native perpendicular sketch lines measure 90 degrees using typed references. Straight-edge and planar-face paths use native geometry; broader pair variants are not separately kernel-tested." + }, + "nx_delete_feature": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native extrusion deletion after Save As with a reacquired feature ID; resulting body count zero. Dependency cascades beyond this fixture are not independently tested." + }, + "nx_reposition_component": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native immediate-child 20-unit X translation and 90-degree Z rotation; identical operation-ID replay does not apply translation twice." + }, + "nx_add_base_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native top base view of a single-body part; sheet placement verified in exported PDF." + }, + "nx_checkpoint": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "In-session model checkpoint and available-state inspection" + }, + "nx_screenshot": { + "status": "tested", + "evidence_type": "real_NX_v2606_interactive", + "scope": "Native viewport PNG, white/transparent backgrounds, shaded/shaded-with-edges; requested dimensions advisory; actual device resolution returned" + }, + "nx_list_bodies": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native work-part inventory reports one extruded body and zero after feature deletion." + }, + "nx_save_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Saves only the work part, preserving drawing-preview context. A native 73-loaded-part fixture verified the saved path and file hash, unchanged unrelated files/flags, and an edited child remaining unsaved. Native save errors and unexpected changes return a partial-outcome receipt. Activate and save each component explicitly." + }, + "nx_set_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native Top, Back and Isometric selection on a solid fixture. Other canned views follow the same API but are not individually verified." + }, + "nx_boolean": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Overlapping1000mm3 cubes: unite1500, subtract500, intersect500mm3 verified analytically." + }, + "nx_list_topology": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Face and edge enumeration; face references used in actual distance query Native face-restricted boundary enumeration and bidirectional adjacency used for flat-pattern orientation." + }, + "nx_download_file": { + "status": "tested", + "evidence_type": "local_contract_test", + "scope": "Chunk bytes, full checksum and boundary/overwrite tests" + }, + "nx_extrude": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native offset/symmetric/arbitrary-direction extrusion, through-all subtraction and up-to-face solids; analytic volume and bounds checked." + }, + "nx_list_sketches": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native two-sketch work-part inventory with typed references." + }, + "nx_list_features": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native sketch/extrusion inventory and reacquisition of a renamed feature following Save As." + }, + "nx_pattern": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Native Pattern Feature; 16 total at 16.5 pitch, width 261.5; edit to 3 at 20 pitch" + }, + "nx_get_feature_info": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Extrude and Pattern Feature expressions and dependencies" + }, + "nx_package_assembly": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Saved two-part assembly ZIP downloaded with verified checksum, two .prt members and valid archive CRCs; deeper dependency trees are outside this fixture." + }, + "nx_sweep": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native square sketch swept along a straight guide sketch; boolean variants use existing boolean operation." + }, + "nx_fit_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native fit on a displayed solid fixture in graphical NX; framing quality remains view-dependent." + }, + "nx_capabilities": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Live exact-tool manifest filtering, NX version and API-detection response. API presence is not a geometry correctness certificate." + }, + "nx_blend": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native single-edge radius1 blend on a cube; installed AddChainset API and cleanup verified." + }, + "nx_cancel_operation": { + "status": "tested", + "evidence_type": "local_contract_tests", + "scope": "Sidecar contract accepts cancellation only for running batches and creates the cancellation marker; batch boundary seam verifies rollback. Live non-running request rejected. No mid-NXOpen interruption claim." + }, + "nx_create_sketch": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "XY, XZ, YZ and an offset arbitrary orthonormal basis; actual frames and curve coordinates checked" + }, + "nx_checkpoint_state": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Checks actual NX mark availability, including save expiration" + }, + "nx_sketch_rectangle": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Principal/custom bases, multiple loops, retry deduplication and batch rollback" + }, + "nx_get_bounding_box": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Part and two-level assembly; conservative and exact with axis-aligned WCS" + }, + "nx_save_as": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native metric solid saved into a nested folder; work-part filename changes and references are reacquired before deletion. Original saved prototype loads into the test assembly." + }, + "nx_list_components": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Native loaded and unloaded prototype inventory retains occurrence references, paths and transforms with explicit load_state. Unknown source paths are nullable; unloaded subassemblies can hide descendants. No implicit loading." + }, + "nx_create_drawing": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native metric A3 sheet at 1:1 first-angle projection; sheet opening and typed reference." + }, + "nx_sketch_constraint": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native horizontal constraint on an owned sketch line through the supported sketch editor. Other routed relations/dimensions retain their dedicated-tool scopes; midpoint is explicitly unsupported." + }, + "nx_ui_control": { + "status": "tested", + "evidence_type": "real_NX_v2606_interactive", + "scope": "UI-thread identity, visible model mutations, manual pause rejection and resume; native panel" + }, + "nx_view_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_interactive", + "scope": "Interactive display-part camera axes, origin and scale" + }, + "nx_check_interference": { + "status": "tested", + "evidence_type": "real_NX_v2606_interactive", + "scope": "10 mm cubes: 500 mm^3 overlap, touching and 2 mm gap; rotated nested occurrence overlap; cleanup preserves saved flags and checkpoint" + }, + "nx_check_clearance": { + "status": "tested", + "evidence_type": "real_NX_v2606_interactive", + "scope": "Native 2 mm gap flagged below 3 mm requirement; bounded pair selection and conservative broad phase" + }, + "nx_display_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Body/face and nested occurrence color, transparency and explicit blank state" + }, + "nx_set_display": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Named color and transparency; face attribute restoration; nested occurrence override leaves shared prototypes unchanged" + }, + "nx_set_visibility": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Show/hide, nested isolation and restoration of previously hidden components" + }, + "nx_restore_display": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Reverse-order restore; invalid order rejected before mutation; face IDs retained across appearance and camera changes" + }, + "nx_highlight_collisions": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Native highlights on two intersecting nested body occurrences; clear pair not highlighted; inline viewport verified" + }, + "nx_clear_highlights": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Clears MCP-owned native highlights without persistent appearance changes" + }, + "nx_list_sections": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Native plane enumeration; active view state and saved flags preserved" + }, + "nx_section_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Principal and arbitrary single-plane clips on solids and assemblies; native cap images; geometry bounds and volume unchanged" + }, + "nx_section_control": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Enable, disable and delete native dynamic sections without modifying solids" + }, + "nx_sketch_diagnostics": { + "status": "tested", + "evidence_type": "real_NX_v2606_public_MCP", + "scope": "Active/inactive whole-sketch native evaluation; underconstrained and fully fixed fixtures; remaining DOF, constraints and curve links; saved state preserved" + }, + "nx_find_geometry": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Native trimmed BREP point-to-face/edge distance; selector queries, principal plane filter and radius filter. Highest/lowest retain conservative center ordering." + }, + "nx_highlight_objects": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_list_expressions": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_set_expression": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_bind_parameter": { + "status": "tested", + "scope": "Native EXTRUDE end expression binding and dependent update; EXTRUDE start and PATTERN count/spacing share builder paths but are not separately native-tested.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_model_health": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_rebuild_model": { + "status": "tested", + "scope": "Native DoUpdate success and health read-back; failed-update rollback covered by local fault injection.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_edit_sketch": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_component_action": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_pattern_components": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_set_camera": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_save_presentation": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_restore_presentation": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_inspection_report": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_model_summary": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_preview_change": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_finish_preview": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/tools.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_resolve_geometry": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Exact point-to-face query re-evaluated after extrusion edit and save/close/reopen; ties rejected. Geometric rule, not immutable topology identity." + }, + "nx_recognize_holes": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Annular solid: inner cylinder identified as bore, outer cylinder excluded; axis/radius/full circumference read-back. Coaxial grouping and partial-face reporting covered locally; no manufacturing feature inference." + }, + "nx_native_component_pattern": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "NX 2606 native associative linear pattern: 16 total occurrences of 14 mm seed at 16.5 mm pitch span 261.5 mm." + }, + "nx_edit_component_pattern": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native rectangular 4x3 and circular 5-instance edits; expression and instance readback." + }, + "nx_list_component_patterns": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native linear, two-direction rectangular and circular pattern metadata and actual occurrence transforms." + }, + "nx_sketch_dimension": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Line length (XZ), horizontal/vertical distances and arc radius/diameter creation; associated expression edit. Reference mismatch guard covered locally." + }, + "nx_sketch_relation": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Modern solver parallel/perpendicular/equal_length/equal_radius/concentric/coincident operations with actual geometric residual checks; line endpoints and arc centers. Legacy branch boundary-tested only." + }, + "nx_sketch_conflicts": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Native no-conflict query and explicit horizontal+vertical contradiction, bounded single-removal relief, restored constraint count/status. Not a minimal conflict set or general legacy relation verifier." + }, + "nx_feature_parameters": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Native extrusion-owned expression enumeration; other feature kinds depend on exposed GetExpressions results." + }, + "nx_set_feature_parameters": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Native extrusion-owned Number formula edit and resulting bounds; preflight/rollback boundary tests. No blanket verification of other feature kinds." + }, + "nx_shell": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native open-box shell: inward 1 mm thickness on 10 mm cube gives 424 mm3. Outward configuration separately checked locally." + }, + "nx_loft": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native solid loft between square sections with analytic volume; sheet configuration has local contract coverage." + }, + "nx_draft": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native 5 degree face draft with analytic volume and explicit angle/distance tolerances." + }, + "nx_transform_bodies": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Associative body extraction plus native MoveObject; copy preserves source; absolute transform replacement verified by bounds." + }, + "nx_sketch_primitive": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Circle, horizontal slot and rounded rectangle: native curves and extruded analytic volumes." + }, + "nx_sketch_angle": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native driving angular dimension creation and expression; broader angle configurations remain unverified." + }, + "nx_sketch_tangent": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Modern line/circle tangent relation with persistent constraint enumeration and zero geometric residual." + }, + "nx_sketch_symmetry": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Modern line-pair symmetry about a line; persistent Mirror relation and zero geometric residual." + }, + "nx_sketch_trim_extend": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native line trim and line extension against an explicit crossing line; reacquire geometry after edits." + }, + "nx_component_array": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native associative rectangular 3x2 and circular 4-instance patterns, including seed." + }, + "nx_set_material": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Local density-only physical material assignment, verified by UF native body density." + }, + "nx_material_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native physical material name and kg/m3 density readback; assembly occurrence prototypes supported." + }, + "nx_mass_properties": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native solid mass, volume, center of gravity and centroidal inertia; 2700kg/m3 test cube matches analytic results. Nested rotated/translated two-body assembly: mass0.0054kg and CoG[0.005,0.035,0.035]m verified." + }, + "nx_copy_project": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native clone of saved assembly and prototype, rewritten dependencies, source hashes and manifest; partial-file cleanup covered locally." + }, + "nx_render_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native Studio image capture, exact 800x600 and 640x480 PNGs; preset2/custom RGB tested; native viewport image visually reviewed." + }, + "nx_assembly_constraint": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native fix and face-distance constraints; actual separation measured. Other exposed relation types retain narrower validation." + }, + "nx_edit_assembly_constraint": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native suppression toggle and distance 5->12 edit; actual component separation verified after rebuilding solve network." + }, + "nx_list_assembly_constraints": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native typed constraint references, geometry/occurrence references, expressions, suppression and solver statuses." + }, + "nx_create_explosion": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged." + }, + "nx_list_explosions": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged." + }, + "nx_edit_explosion": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged." + }, + "nx_show_explosion": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native nested assembly explosion: absolute rotated parent/child poses, reset, repeat assignment, model/drawing association, persistence; ordinary assembled placements unchanged." + }, + "nx_explosion_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native exploded and assembled occurrence poses and typed associated view references, including nested assembly." + }, + "nx_delete_explosion": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native unassociated explosion deletion followed by empty explosion inventory; in-use rejection and stale invalidation covered locally." + }, + "nx_sheet_metal_schema": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Strict schemas for 34 native operation families; per-operation examples and validation scopes." + }, + "nx_sheet_metal_context": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Modern graphical UG_APP_SBSM context; no legacy application switch or model undo mark." + }, + "nx_sheet_metal_feature": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "34 native creation fixtures on NX v2606; flange builder edit verified. Individual options and other edit combinations remain experimental. Two-bend partial-width channel with Square/Round reliefs and independently calculated developed DXF dimensions; adjacent mitered flanges and flat export." + }, + "nx_sheet_metal_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native body recognition, thickness, inner bend faces, angle/radius/neutral factor read-back." + }, + "nx_sheet_metal_defaults": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Numeric defaults and bend-definition read-back; unsafe native material catalog enumeration intentionally not called." + }, + "nx_set_sheet_metal_defaults": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Value-mode thickness/radius/neutral factor and numeric read-back. Material/tool tables and custom bend tables remain experimental." + }, + "nx_export_flat_pattern": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native DXF and Trumpf GEO export; staged file publication, checksums. DXF entity geometry inspected." + }, + "nx_add_flat_pattern_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native Flat Pattern named view on metric drawing; PDF exported and visually reviewed." + }, + "nx_sheet_metal_annotation": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native body/bend PMI with measured snapshot text and explicit refresh; no automatic numeric text update claim." + }, + "nx_create_path_sketch": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native edge-path sketch with arc-length percentage, orienting face and frame read-back; successful secondary contour flange." + }, + "nx_spline": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native associative 3D interpolation spline creation/edit and degree-2 control-pole curves; periodic variants unverified." + }, + "nx_surface_mesh": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native planar and quadratic Through Curve Mesh fixtures from two primary and two cross sections." + }, + "nx_bridge_surface": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native full-edge planar G0/G1/G2 bridge creation; requested constraints are not independent geometric certification." + }, + "nx_sew": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native adjacent planar-sheet sewing; incomplete-sew and solid-fallback rejection covered by unit tests." + }, + "nx_thicken": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native 2 mm sheet thickening with independently checked 400 mm^3 solid volume." + }, + "nx_edit_faces": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native directed move, signed offset, replace and delete/heal on controlled solids; analytic volume checks. Arbitrary vendor imports unverified." + }, + "nx_trim_sheet": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native line-boundary half-sheet trim using section and region point." + }, + "nx_curve_analysis": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native derivative evaluation on owned line/spline curves; singular handling unit-tested; sampling is not a global extrema certificate." + }, + "nx_surface_continuity": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Bidirectional sampled UF geometry: matching planes pass G0/G1/G2, separated planes fail, tangent plane/quadratic join fails G2. Not a global certificate." + }, + "nx_thread": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native manual symbolic and detailed internal/external thread creation with explicit start face and cylinder diameter. Standards-table fit classes not exposed." + }, + "nx_pmi_datum": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native geometry-associated datum A on a planar face." + }, + "nx_pmi_fcf": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native single-frame flatness/parallelism annotations and datum A reference; all GD&T modifiers are not exposed." + }, + "nx_face_analysis": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native sampled plane normal/curvature and signed draft; trimmed-domain filtering. Not global draft certification." + }, + "nx_wall_thickness": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native inward-normal ray thickness: nine 5 mm plate samples. Not rolling-ball or global-minimum thickness." + }, + "nx_create_parts_list": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native assembly drawing BOM: three repeated instances aggregate to quantity 3 using installed column defaults." + }, + "nx_parts_list_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native evaluated BOM rows and preference readback." + }, + "nx_update_parts_list": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native BOM refresh after adding a fourth instance gives quantity 4." + }, + "nx_parts_list_balloons": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native associated grouped balloon created for an assembly drawing view." + }, + "nx_explosion_trace": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native traceline with persistent component/edge handles; exact endpoints preserved after save/reopen and updated by MCP placement changes. Manual edits require MCP refresh." + }, + "nx_export_explosion_animation": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Three native frames with fixed camera, pose interpolation and restored state; fully framed visual review. Failure cleanup unit-tested." + }, + "nx_list_drawings": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native A3 sheet/view enumeration, dimensions, scale and active state." + }, + "nx_activate_drawing": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native drawing/modeling switching on saved fixture parts; unrelated parts preserved." + }, + "nx_list_annotations": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native BOM, balloon and managed sheet-metal PMI enumeration and text." + }, + "nx_edit_annotation": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native associative balloon movement; rename/delete have local contract coverage pending native acceptance." + }, + "nx_parts_list_column": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native BOM header/width edits, general column append/remove and evaluated values." + }, + "nx_bend_table": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native flat-pattern bend table create/edit, column ordering, evaluated row readback and angle update from 75 to 80 degrees; PDF visually reviewed." + }, + "nx_refresh_annotations": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native persistent bend PMI, transaction hook and save/reopen exercised through public MCP; automatic bend-table builders also rebuilt because the native flag alone left stale rows." + }, + "nx_thread_catalog": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Reads installed NX catalog in place; exact Metric Coarse M6 x 1.0 row selection without file transfer." + }, + "nx_standard_thread": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native Metric Coarse M6 x 1.0 internal/external symbolic/detailed threads; pitch and material-removal verification. Detailed Metric Fine M6x0.75 and Inch UNC 1/4-20, right and left handed." + }, + "nx_edit_explosion_trace": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native managed edge-anchored trace endpoint percentages and offsets edited in a two-component service assembly; rendered and included in drafting view." + }, + "nx_drawing_view_info": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native scale, sheet coordinates, border and containment readback for base, detail and section views; millimeter and inch sheets." + }, + "nx_edit_drawing_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Base and section style edits, placement, scale and save/reopen tested. Construction filtering excludes sheet-owned section curves. Fonts include 0 invisible. hidden_lines/self_hidden are native processing toggles: false can expose occluded edges. Centerline preference remains read-only." + }, + "nx_add_section_drawing_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Simple native section through a circular hole center; explicit scale and hatch visible in exported PDF. Complex stepped sections are not included." + }, + "nx_add_detail_drawing_view": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Circular detail with explicit model-coordinate center and radius mapped into the parent drawing view; scaled view and exported PDF." + }, + "nx_drawing_table": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native title-block definition and editable revision table with evaluated cell readback and reviewed PDF." + }, + "nx_geometry_anchor": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Owned face persistent handle, owner-part identity and exact native resolution after save/reopen; no nearest-geometry fallback." + }, + "nx_resolve_geometry_anchor": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Owned face survives save/reopen and rollback in a public USB connector STEP fixture; stale and wrong-owner rejection tested locally." + }, + "nx_update_assembly_documentation": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Two-component assembly: extrusion resize and prototype replacement, fixed mates, clearance, explosion traces, BOM and drawing regeneration. Retained dimensions are reported and explicitly rebound." + }, + "nx_list_dimensions": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native computed size and retention diagnostics; occurrence-edge dimension follows extrusion resize and explicitly rebinds after replacement." + }, + "nx_workspace_info": { + "status": "tested", + "evidence_type": "live_sidecar_and_contract_tests", + "scope": "NX host workspace root and path conventions; no NX geometry calls." + }, + "nx_create_directory": { + "status": "tested", + "evidence_type": "local_contract_tests", + "scope": "Workspace-scoped directory creation and idempotent existing-directory reporting." + }, + "nx_flat_pattern_orientation_edges": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native planar formed-web straight boundary discovery; returned edge created a Flat Pattern on first attempt. Geometric candidates only; not a kernel acceptance guarantee." + }, + "nx_list_reference_sets": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native body-only custom set enumeration and exact member count." + }, + "nx_create_reference_set": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native SOLIDS reference set containing one block body; saved and consumed by three assembly occurrences." + }, + "nx_set_component_reference_set": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native direct-child assignment to three occurrences, unchanged translations, persisted drawing excludes prototype datums. Nested children require activating owner." + }, + "nx_list_datums": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native owned datum planes/axes and coordinate-system enumeration on template part." + }, + "nx_set_datum_visibility": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native blank/unblank snapshot restoration, and assembly-owned datum suppression confirmed by exported drawing PDF visual review." + }, + "nx_dimension_format": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native associative dimension readback, computed value, decimal precision, units and physical asymmetric tolerances." + }, + "nx_edit_dimension_format": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native 80 mm dimension formatted to two decimals and +0.05/-0.02 mm without changing its value or two associations; exported PDF visually verified." + }, + "nx_export_planar_dxf": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Analytic lines, circles and arcs from planar sketches and faces; principal/custom frames, layers and inch-to-mm conversion tested in NX. XY sketch/face output independently parsed with ezdxf; splines are rejected." + }, + "nx_read_result": { + "status": "tested", + "evidence_type": "local_contract_tests", + "scope": "Immutable bounded bridge snapshots, JSON-pointer pagination, result cardinality and committed-outcome preservation on storage failure; 2032-object response fixture." + } + }, + "limitations": [ + "No general certification", + "Native save expires undo marks", + "Checkpoint recovery does not survive NX process restart", + "Exact bounds require axis-aligned WCS", + "Assembly import can conflict with loaded STEP prototype names", + "Modified-object tracking is explicit-only; null means not comprehensive", + "Batch structural preflight is not a geometric dry run", + "Cooperative cancellation happens between operations only", + "Absolute placement currently supports immediate children", + "MCP desktop clients must refresh tool schemas after deployment", + "CLIFF vendor STEP conversion still returns no bodies with NX v2606 translators; logs and rolled-back new-part outcome are returned. No vendor-geometry repair is claimed.", + "Adam Tech vendor geometry has two self-intersecting faces (875315); health diagnostics resolve faces and error text. Native healing failed on a disposable copy and was rolled back.", + "Drawing centerline visibility is inspectable but not writable: the tested native setter did not persist." + ], + "unavailable": [ + "batch_model_viewport_image", + "union_of_all_pairwise_interference_volumes" + ] +} diff --git a/src/nx_mcp/certified.py b/src/nx_mcp/certified.py index 8e79347..bffd5f3 100644 --- a/src/nx_mcp/certified.py +++ b/src/nx_mcp/certified.py @@ -54,7 +54,10 @@ def create_certified_server( enable_experimental: bool = False, enable_journal: bool = False, ) -> FastMCP: - mcp = FastMCP("nx-mcp", instructions="Certified Siemens NX tools for a local NX session.") + mcp = FastMCP( + "nx-mcp", + instructions="Siemens NX integration. Runtime support varies by NX version; no general certification is claimed.", + ) async def call(method: str, params: dict[str, Any]) -> dict[str, Any]: try: @@ -199,4 +202,9 @@ async def nx_fit_view() -> OperationResult: enable_journal=enable_journal, ) + if enable_experimental: + from nx_mcp.integration_server import configure + + configure(mcp, bridge, workspace) + return mcp diff --git a/src/nx_mcp/documentation_editing.py b/src/nx_mcp/documentation_editing.py new file mode 100644 index 0000000..9ea108f --- /dev/null +++ b/src/nx_mcp/documentation_editing.py @@ -0,0 +1,237 @@ +"""Native drawing/annotation editing; NXOpen calls remain on the NX thread.""" + +from nx_mcp.authoring import finite, page +from nx_mcp.runtime import NXToolError + + +class DocumentationEditingMixin: + def _list_drawings(self): + part = self._work_part() + current = part.DrawingSheets.CurrentDrawingSheet + return { + "sheets": [ + { + "object": self._reference(s, "drawing_sheet", part, "Sheet"), + "name": s.Name, + "active": s == current, + "width": s.Length, + "height": s.Height, + "units": self._sheet_units(s), + "scale": list(s.GetScale()), + "views": [ + {"object": self._reference(v, "drawing_view", part, "View"), "name": v.Name} + for v in s.GetDraftingViews() + ], + } + for s in part.DrawingSheets + ], + "units": "per_sheet", + "coordinate_frame": "drawing_sheet", + "modeling_active": current is None, + } + + def _activate_drawing(self, drawing=None): + part = self._explosion_context() + if drawing is None: + part.Drafting.ExitDraftingApplication() + else: + self._drawing_object(drawing, "drawing_sheet").Open() + return self._list_drawings() + + def _list_annotations(self, offset=0, limit=100): + from nx_mcp.hardened import xyz + + part = self._work_part() + values = [(x, "annotation") for x in self._documentation_annotations(part)] + [ + (x, "traceline") for x in part.Tracelines + ] + selected_page = page(values, offset, limit) + items = [] + for value, kind in selected_page["items"]: + item = { + "object": self._reference(value, kind, part, "Annotation"), + "native_type": type(value).__name__, + } + if hasattr(value, "HasUserAttribute") and value.HasUserAttribute( + "NX_MCP_MEASURED_PMI_V1", self.nxopen.NXObject.AttributeType.String, -1 + ): + item["managed_refresh"] = ( + value.GetStringAttribute("NX_MCP_MEASURED_PMI_V1") != "disabled" + ) + if hasattr(value, "IsRetained"): + item["retained"] = bool(value.IsRetained) + if hasattr(value, "AnnotationOrigin"): + item["position"] = xyz(value.AnnotationOrigin) + if hasattr(value, "GetText"): + item["text"] = list(value.GetText()) + if hasattr(value, "HasUserAttribute") and value.HasUserAttribute( + "NX_MCP_DRAWING_TABLE_V1", self.nxopen.NXObject.AttributeType.String, -1 + ): + item["table_kind"] = value.GetStringAttribute("NX_MCP_DRAWING_TABLE_V1") + if type(value).__name__ == "BendTable": + item["rows"] = self._table_cells(value) + if kind == "traceline": + item.update( + start=xyz(value.StartPoint.Coordinates), + end=xyz(value.EndPoint.Coordinates), + start_offset=value.StartOffset, + end_offset=value.EndOffset, + ) + items.append(item) + return { + "items": items, + "total": len(values), + "offset": offset, + "next_offset": offset + limit if offset + limit < len(values) else None, + "units": self._units(), + "position_frame": "native annotation frame: sheet for drafting, work part for PMI; assembly for traces", + } + + def _edit_annotation(self, annotation, position=None, name=None, delete=False): + from nx_mcp.freeform import points3 + + obj = self._resolve(annotation, {"annotation", "traceline"}) + if delete and (position is not None or name is not None): + raise NXToolError("NX_INVALID_ARGUMENT", "Delete cannot be combined with edits") + if not delete and position is None and name is None: + raise NXToolError("NX_INVALID_ARGUMENT", "Supply an edit") + point = points3([position])[0] if position is not None else None + if point is not None and not hasattr(obj, "AnnotationOrigin"): + raise NXToolError( + "NX_UNSUPPORTED_EDIT", "Use the dedicated trace editor for trace geometry" + ) + if name is not None and (not isinstance(name, str) or not name.strip()): + raise NXToolError("NX_INVALID_ARGUMENT", "Name must not be empty") + if delete: + self.session.UpdateManager.AddToDeleteList([obj]) + self._update_model() + return {"deleted": [annotation]} + if point is not None: + obj.AnnotationOrigin = self.nxopen.Point3d(*point) + if name is not None: + obj.SetName(name) + self._update_model() + return { + "modified": [ + self._reference( + obj, + "traceline" if hasattr(obj, "AskExplosion") else "annotation", + self._work_part(), + "Annotation", + ) + ], + "position": point, + "units": self._units(), + } + + def _parts_list_column( + self, + parts_list, + action, + index=None, + title=None, + width=None, + field=None, + key=None, + protected=None, + ): + import NXOpen.UF as U + + obj = self._parts_list_object(parts_list) + uf = U.UFSession.GetUFSession() + tab = uf.Tabnot + count = tab.AskNmColumns(obj.Tag) + if action not in {"edit", "append", "remove"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown column action") + if action == "append": + if index is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "Append does not take an index") + if width is None or title is None or field is None: + raise NXToolError("NX_INVALID_ARGUMENT", "Append requires title, width and field") + elif type(index) is not int or not 0 <= index < count: + raise NXToolError("NX_INVALID_ARGUMENT", "Column index is out of range") + if action == "remove" and ( + count <= 1 or any(x is not None for x in [title, width, field, key, protected]) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Remove only takes index; retain at least one column" + ) + if action == "edit" and all(x is None for x in [title, width, field, key, protected]): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply a column edit") + if width is not None: + width = finite(width, "width", True) + for value in [title, field]: + if value is not None and (not isinstance(value, str) or len(value) > 1024): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Column strings must be at most 1024 characters" + ) + if action == "append": + prefs = uf.Plist.AskDefaultColPrefs() + prefs.DefaultString = field + prefs.IsKeyField = bool(key) + prefs.IsProtected = bool(protected) + col = uf.Plist.CreateColumn(width, prefs, U.Plist.ColumnType.COLUMN_TYPE_GENERAL) + tab.AddColumn(obj.Tag, col, count) + else: + col = tab.AskNthColumn(obj.Tag, index) + if action == "remove": + tab.RemoveColumn(col) + uf.Obj.DeleteObject(col) + else: + prefs = uf.Plist.AskColPrefs(col) + for attr, value in [ + ("DefaultString", field), + ("IsKeyField", key), + ("IsProtected", protected), + ]: + if value is not None: + setattr(prefs, attr, value) + uf.Plist.SetColPrefs(col, prefs) + if width is not None: + tab.SetColumnWidth(col, width) + if title is not None: + if not tab.AskNmHeaderRows(obj.Tag): + raise NXToolError("NX_NO_TABLE_HEADER", "Parts list has no header row") + tab.SetCellText(tab.AskCellAtRowCol(tab.AskNthHeaderRow(obj.Tag, 0), col), title) + uf.Plist.Update(obj.Tag) + return self._parts_list_info(parts_list) + + def _edit_explosion_trace( + self, traceline, start_percent=None, end_percent=None, start_offset=None, end_offset=None + ): + import json + + from nx_mcp.hardened import xyz + + line = self._resolve(traceline, {"traceline"}) + ex = line.AskExplosion() + if all(x is None for x in [start_percent, end_percent, start_offset, end_offset]): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply an edit") + if not line.HasUserAttribute( + "NX_MCP_TRACE_V1", self.nxopen.NXObject.AttributeType.String, -1 + ): + raise NXToolError("NX_UNSUPPORTED_EDIT", "Select a managed MCP trace") + records = json.loads(line.GetStringAttribute("NX_MCP_TRACE_V1")) + for record, value in zip(records, [start_percent, end_percent], strict=True): + if value is not None: + value = finite(value, "percent") + if not 0 <= value <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Percentage must be 0..100") + record["percent"] = value + offsets = [ + finite(x, "offset") if x is not None else None for x in [start_offset, end_offset] + ] + line.SetAttribute("NX_MCP_TRACE_V1", json.dumps(records)) + for attr, value in zip(["StartOffset", "EndOffset"], offsets, strict=True): + if value is not None: + setattr(line, attr, value) + self._refresh_explosion_traces(ex) + self._regenerate_explosion_display() + return { + "traceline": self._reference(line, "traceline", self._work_part(), "Trace"), + "start": xyz(line.StartPoint.Coordinates), + "end": xyz(line.EndPoint.Coordinates), + "start_offset": line.StartOffset, + "end_offset": line.EndOffset, + "units": self._units(), + } diff --git a/src/nx_mcp/documentation_editing_server.py b/src/nx_mcp/documentation_editing_server.py new file mode 100644 index 0000000..63e878e --- /dev/null +++ b/src/nx_mcp/documentation_editing_server.py @@ -0,0 +1,177 @@ +"""Drawing inspection and precise native documentation edits.""" + +from typing import Annotated, Literal + +from nx_mcp.schema_types import BaseModel, ConfigDict, Field + +Point3 = Annotated[list[float], Field(min_length=3, max_length=3)] +Point2 = Annotated[list[float], Field(min_length=2, max_length=2)] + +READ_ONLY = {"nx_list_drawings", "nx_list_annotations"} +NON_MODEL = {"nx_activate_drawing"} + + +def nx_list_drawings(): + """List work-part drawing sheets and views with typed IDs, dimensions, scale and active-sheet state. No activation or mutation.""" + + +def nx_activate_drawing(drawing: str | None = None): + """Open an owned drawing sheet, or return to modeling when drawing=null. Work/display parts must match and no sketch may be active. Preserves unrelated parts.""" + + +def nx_list_annotations(offset: int = 0, limit: int = 100): + """Enumerate native notes, PMI, BOMs, balloons and explosion traces in the work part. Returns subtype, typed references, position/text where available, and managed-refresh metadata. Paged, maximum 200 items per call.""" + + +def nx_edit_annotation( + annotation: str, + position: Point3 | None = None, + name: str | None = None, + delete: bool = False, +): + """Move or rename a native annotation (including associative balloons), or delete it explicitly. Position is [x,y,z] in the existing sheet/work-part frame; use z=0 for drawing-sheet annotations. Preserves callout text and native associations; does not convert balloons to plain text. Delete cannot be combined with other changes.""" + + +def nx_parts_list_column( + parts_list: str, + action: Literal["edit", "append", "remove"], + index: int | None = None, + title: str | None = None, + width: float | None = None, + field: str | None = None, + key: bool | None = None, + protected: bool | None = None, +): + """Edit, append or remove a zero-based native BOM column. width uses sheet units. field is an explicit native NX parts-list default expression, such as ; inspect existing columns first. Appends general columns; existing callout/quantity semantics are preserved. Remove requires index and rejects other properties. Returns actual evaluated rows and column preferences.""" + + +def nx_edit_explosion_trace( + traceline: str, + start_percent: float | None = None, + end_percent: float | None = None, + start_offset: float | None = None, + end_offset: float | None = None, +): + """Edit a managed native explosion trace's anchored edge arc-length percentages (0..100) and native endpoint offsets in assembly units. Persistent edge/component anchors and the named explosion are retained. Recomputes endpoints and returns readback. Use nx_edit_annotation(delete=true) to remove a trace.""" + + +def nx_bend_table( + view: str, + position: list[float], + table: str | None = None, + automatic: bool = True, + columns: list[Literal["BendID", "BendName", "BendAngle", "BendDirection", "BendRadius"]] + | None = None, +): + """Create/edit a native associative bend table for an actual flat-pattern drafting view. Position uses sheet units. Select ordered unique native columns and native automatic updating. table edits an existing native bend table. Geometry and bend data remain associated with the flat pattern.""" + + +def nx_refresh_annotations(): + """Refresh persistent managed sheet-metal PMI from current native measurements. These annotations also refresh transactionally after MCP model mutations. Call explicitly after manual NX edits. Missing sources reject the operation rather than retaining silently incorrect values. Rebuilds native bend tables with automatic updating enabled; their flag alone may leave stale rows after reopening.""" + + +READ_ONLY.update({"nx_drawing_view_info", "nx_geometry_anchor", "nx_resolve_geometry_anchor"}) + + +def nx_drawing_view_info(view: str): + """Inspect actual native view scale, absolute sheet position, view border and sheet containment. Returns each sheet's actual mm/in units. Borders exclude separately placed annotations. Read-only.""" + + +class ViewStyle(BaseModel): + construction_geometry: bool | None = None + model_config = ConfigDict(extra="forbid") + hidden_lines: bool | None = None + hidden_font: Annotated[int, Field(ge=0, le=7)] | None = None + hidden_width: ( + Literal["original", "thin", "normal", "thick", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + | None + ) = None + self_hidden: bool | None = None + visible_font: Annotated[int, Field(ge=0, le=7)] | None = None + visible_width: ( + Literal["original", "thin", "normal", "thick", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + | None + ) = None + smooth_edges: bool | None = None + smooth_font: Annotated[int, Field(ge=0, le=7)] | None = None + rendering: Literal["wireframe", "fully_shaded", "partially_shaded"] | None = None + + +def nx_edit_drawing_view( + view: str, + position: Point2 | None = None, + scale: float | None = None, + style: ViewStyle | None = None, +): + """Assign absolute drawing-view position [x,y] in sheet units and/or positive model-to-sheet scale. Native aligned views may constrain movement; verifies readback and rolls back mismatches. Updates the view, retaining its native associations. style sets native hidden/visible/smooth (tangent) edges and rendering with readback. hidden_lines and self_hidden are native hidden-line processing toggles, not visibility switches: false can draw occluded edges as visible. To hide obscured edges use hidden_lines=true, self_hidden=true, hidden_font=0. Fonts: 0 invisible, 1 solid, 2 dashed; widths are named thin/normal/thick or native width names 1..9. Default new base views use dashed hidden edges and exclude model curves/datums through per-view erasures. construction_geometry excludes sheet-owned section/detail curves and restores or erases model objects in this view without changing model visibility. Centerline visibility is read-only: the NX v2606 builder did not persist its setter in native tests.""" + + +def nx_add_section_drawing_view( + parent_view: str, + cut_object: str, + position: list[float], + step_direction: Point3, + arrow_direction: Point3, + scale: float = 1.0, + cut_association: Literal["start", "end", "arc_center"] = "start", +): + """Create a native simple section drawing view anchored to an owned model edge endpoint (start/end) or arc_center for circular edges. Step and arrow are three-component [x,y,0] vectors perpendicular in the sheet XY plane. position is [x,y] in sheet units. Positive scale is model-to-sheet. Creates a native section line and cut view; requires drafting license.""" + + +def nx_add_detail_drawing_view( + parent_view: str, center: list[float], radius: float, position: list[float], scale: float = 2.0 +): + """Create a native circular detail view. center=[x,y,z] and positive radius use work-part coordinates/units; choose a circle lying in the parent view plane. position=[x,y] uses sheet units. The native view remains associated with the parent; boundary points retain their explicit model coordinates.""" + + +def nx_drawing_table( + drawing: str, + kind: Literal["title_block", "revision"], + rows: list[list[str]], + widths: list[float], + position: list[float], + table: str | None = None, + row_height: float = 7.0, +): + """Create/edit an owned native drawing table with explicit rectangular rows and per-column widths in sheet units. title_block uses the lower-right anchor and creates a native NX title-block definition; revision uses the upper-left anchor of an editable tabular history. Editing requires the returned table ID and retains column count; row count may change. Maximum 100 rows/20 columns/1024 characters per cell. No dates, approval, or revision content is inferred.""" + + +def nx_geometry_anchor(object: str): + """Capture a persistent native handle for an owned body/face/edge in a saved part. Anchor survives save/reopen while the native entity survives. Store the returned anchor rather than session-scoped object IDs. Occurrence geometry must be anchored in its prototype work part.""" + + +def nx_resolve_geometry_anchor(anchor: dict): + """Resolve an exact native geometry anchor into a current typed ID. Verifies owner path and object kind. Deleted or replaced topology explicitly returns NX_STALE_REFERENCE; never silently substitutes the nearest face.""" + + +def nx_update_assembly_documentation(): + """Explicitly propagate loaded prototype changes in the active assembly. Solve existing mates, refresh managed explosion traces, native BOMs, measured annotations and drafting views. Returns constraint states, evaluated BOM rows, view bounds, retained annotations/dimensions, documentation_complete and assembly health. Retained values require explicit reassociation or recreation. Requires work/display match and no active sketch. Unsatisfied mates or stale managed anchors roll back this update; earlier prototype edits remain separate operations.""" + + +READ_ONLY.add("nx_list_dimensions") + + +def nx_list_dimensions(): + """List actual computed native dimension values, native retention and measurement_valid flags, typed IDs and annotation origins in the work part. Includes drafting and PMI dimensions, identified by native subtype. Values/origins use native work-part units; inspect view association separately. Read-only, no regeneration.""" + + +READ_ONLY.add("nx_dimension_format") + + +def nx_dimension_format(dimension: str): + """Read native associative dimension precision, display units, decimal separator and tolerances. Computed value uses work-part units; formatting does not override measured text.""" + + +def nx_edit_dimension_format( + dimension: str, + decimal_places: Annotated[int, Field(ge=0, le=8)] | None = None, + trailing_zeros: bool | None = None, + units: Literal["mm", "in", "m", "um"] | None = None, + decimal_separator: Literal["period", "comma"] | None = None, + tolerance_type: Literal["none", "bilateral", "symmetric", "limits", "basic", "reference"] + | None = None, + upper_tolerance: float | None = None, + lower_tolerance: float | None = None, + tolerance_decimal_places: Annotated[int, Field(ge=0, le=8)] | None = None, +): + """Edit native dimension preferences while preserving measured value and associations. Tolerance values use native work-part units. No text override. Omitted properties remain unchanged. Returns actual native formatting; save to persist.""" diff --git a/src/nx_mcp/drawing_preferences.py b/src/nx_mcp/drawing_preferences.py new file mode 100644 index 0000000..d9b66f2 --- /dev/null +++ b/src/nx_mcp/drawing_preferences.py @@ -0,0 +1,277 @@ +"""Associative dimension formatting and native drafting view presentation.""" + +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import enum_name + +UNITS = {"mm": "Millimeters", "in": "Inches", "m": "Meters", "um": "Micrometers"} +TOLERANCES = { + "none": "NotSet", + "bilateral": "BilateralTwoLines", + "symmetric": "BilateralOneLine", + "limits": "LimitTwoLines", + "basic": "Basic", + "reference": "Reference", +} +WIDTHS = { + "original": "Original", + "thin": "Thin", + "normal": "Normal", + "thick": "Thick", + **{ + str(i): name + for i, name in enumerate( + ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"], 1 + ) + }, +} +RENDERING = { + "fully_shaded": "FullyShaded", + "partially_shaded": "PartiallyShaded", + "wireframe": "Wireframe", +} +STYLE_PROPERTIES = { + "hidden_lines": ("ViewStyleHiddenLines", "HiddenLine"), + "hidden_font": ("ViewStyleHiddenLines", "Font"), + "hidden_width": ("ViewStyleHiddenLines", "Width"), + "self_hidden": ("ViewStyleHiddenLines", "SelfHidden"), + "visible_font": ("ViewStyleVisibleLines", "VisibleFont"), + "visible_width": ("ViewStyleVisibleLines", "VisibleWidth"), + "smooth_edges": ("ViewStyleSmoothEdges", "SmoothEdge"), + "smooth_font": ("ViewStyleSmoothEdges", "Font"), + "centerlines": ("ViewStyleGeneral", "Centerlines"), + "rendering": ("ViewStyleShading", "RenderingStyle"), +} + + +class DrawingPreferencesMixin: + def _dimension_format(self, dimension): + import NXOpen.Annotations as A + + d = self._engineering_owned(dimension, "dimension") + prefs = d.GetDimensionPreferences() + units = prefs.GetUnitsFormatPreferences() + try: + return { + "object": self._reference(d, "dimension", self._work_part(), "Dimension"), + "computed_value": d.ComputedSize, + "measurement_units": self._units(), + "measurement_valid": not bool(d.IsRetained), + "decimal_places": d.NominalDecimalPlaces, + "tolerance_decimal_places": d.ToleranceDecimalPlaces, + "upper_tolerance": d.UpperMetricToleranceValue + if self._units() == "mm" + else d.UpperToleranceValue, + "lower_tolerance": d.LowerMetricToleranceValue + if self._units() == "mm" + else d.LowerToleranceValue, + "tolerance_type": enum_name(d.ToleranceType, A.ToleranceType), + "trailing_zeros": bool(units.DisplayTrailingZeros), + "display_units": enum_name(units.PrimaryDimensionUnit, A.DimensionUnit), + "decimal_separator": enum_name( + units.DecimalPointCharacter, A.DecimalPointCharacter + ), + "association_count": d.NumberOfAssociativities, + } + finally: + units.Dispose() + prefs.Dispose() + + def _edit_dimension_format( + self, + dimension, + decimal_places=None, + trailing_zeros=None, + units=None, + decimal_separator=None, + tolerance_type=None, + upper_tolerance=None, + lower_tolerance=None, + tolerance_decimal_places=None, + ): + import NXOpen.Annotations as A + + from nx_mcp.authoring import finite + + values = locals().copy() + if all( + values[k] is None + for k in ( + "decimal_places", + "trailing_zeros", + "units", + "decimal_separator", + "tolerance_type", + "upper_tolerance", + "lower_tolerance", + "tolerance_decimal_places", + ) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply at least one formatting property") + for key in ["decimal_places", "tolerance_decimal_places"]: + if values[key] is not None and ( + type(values[key]) is not int or not 0 <= values[key] <= 8 + ): + raise NXToolError("NX_INVALID_ARGUMENT", f"{key} must be 0..8") + if ( + units is not None + and units not in UNITS + or tolerance_type is not None + and tolerance_type not in TOLERANCES + or decimal_separator is not None + and decimal_separator not in {"period", "comma"} + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported formatting enum") + for key in ["upper_tolerance", "lower_tolerance"]: + if values[key] is not None: + values[key] = finite(values[key], key) + d = self._engineering_owned(dimension, "dimension") + before = (d.ComputedSize, d.NumberOfAssociativities, bool(d.IsRetained)) + prefs = d.GetDimensionPreferences() + formatting = prefs.GetUnitsFormatPreferences() + try: + if trailing_zeros is not None: + formatting.DisplayTrailingZeros = trailing_zeros + if units is not None: + formatting.PrimaryDimensionUnit = getattr(A.DimensionUnit, UNITS[units]) + if decimal_separator is not None: + formatting.DecimalPointCharacter = getattr( + A.DecimalPointCharacter, "Period" if decimal_separator == "period" else "Comma" + ) + prefs.SetUnitsFormatPreferences(formatting) + d.SetDimensionPreferences(prefs) + finally: + formatting.Dispose() + prefs.Dispose() + if tolerance_type is not None: + d.ToleranceType = getattr(A.ToleranceType, TOLERANCES[tolerance_type]) + for key, native, metric in [ + ("decimal_places", "NominalDecimalPlaces", "MetricNominalDecimalPlaces"), + ("tolerance_decimal_places", "ToleranceDecimalPlaces", "MetricToleranceDecimalPlaces"), + ]: + if values[key] is not None: + setattr(d, native, values[key]) + setattr(d, metric, values[key]) + for key, native, metric in [ + ("upper_tolerance", "UpperToleranceValue", "UpperMetricToleranceValue"), + ("lower_tolerance", "LowerToleranceValue", "LowerMetricToleranceValue"), + ]: + if values[key] is not None: + metric_value = values[key] * (25.4 if self._units() == "inch" else 1.0) + setattr(d, native, metric_value / 25.4) + setattr(d, metric, metric_value) + d.RedisplayObject() + if before != (d.ComputedSize, d.NumberOfAssociativities, bool(d.IsRetained)): + raise NXToolError( + "NX_VERIFICATION_FAILED", "Formatting changed measured value or association state" + ) + result = self._dimension_format(dimension) + result["modified"] = [result["object"]] + return result + + def _drawing_construction_visibility(self, view, visible): + import NXOpen.UF as U + + part = self._work_part() + uf_view = U.UFSession.GetUFSession().View + # Part.Curves also includes section lines and other sheet-owned curves. + # They are annotations, not model construction geometry. + values = [c for c in part.Curves if not uf_view.AskViewDependentStatus(c.Tag)[0]] + values += self._datum_objects() + for component, _ in self._walk_components(part): + if component.IsSuppressed or component.Prototype is None: + continue + prototype = component.Prototype + if not hasattr(prototype, "Curves"): + continue + for value in ( + list(prototype.Curves) + list(prototype.Datums) + list(prototype.CoordinateSystems) + ): + if uf_view.AskViewDependentStatus(value.Tag)[0]: + continue + occurrence = component.FindOccurrence(value) + if occurrence is not None: + values.append(occurrence) + values = list({int(x.Tag): x for x in values}.values()) + if len(values) > 20000: + raise NXToolError( + "NX_OBJECT_LIMIT", "Drawing construction selection exceeds 20000 objects" + ) + if values: + if visible: + view.DependentDisplay.RemoveErasureOnObjectAndSubobjects(values, False) + else: + view.DependentDisplay.Erase(values) + view.SetAttribute("NX_MCP_DRAWING_CONSTRUCTION_V1", "show" if visible else "hide") + return len(values) + + def _view_style(self, view, updates=None): + import NXOpen.Preferences as P + + if updates and "centerlines" in updates: + raise NXToolError( + "NX_UNSUPPORTED_ARGUMENT", + "NX v2606 does not persist the tested centerline preference setter; centerlines is read-only", + details={"mutation_outcome": "not_started"}, + ) + obj = self._drawing_object(view, "drawing_view") + b = self._work_part().SettingsManager.CreateDrawingEditViewSettingsBuilder([obj]) + try: + b.InheritSettingsFromSelectedObjects(obj) + if updates: + for key, value in updates.items(): + if key == "construction_geometry": + self._drawing_construction_visibility(obj, value) + continue + if key not in STYLE_PROPERTIES: + raise NXToolError( + "NX_INVALID_ARGUMENT", f"Unsupported view-style property: {key}" + ) + group, prop = STYLE_PROPERTIES[key] + if key.endswith("font"): + if type(value) is not int or not 0 <= value <= 7: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Line font must be 0..7 (0 invisible)" + ) + value = P.Font.ValueOf(value) + elif key.endswith("width"): + if value not in WIDTHS: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported line width") + value = getattr(P.Width, WIDTHS[value]) + elif key == "rendering": + if value not in RENDERING: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported rendering style") + value = getattr(P.ShadingRenderingStyleOption, RENDERING[value]) + setattr(getattr(b.ViewStyle, group), prop, value) + b.Commit() + result = { + key: getattr(getattr(b.ViewStyle, group), prop) + for key, (group, prop) in STYLE_PROPERTIES.items() + } + for key, value in result.items(): + if key.endswith("font"): + result[key] = getattr(value, "value", value) + elif key.endswith("width"): + native = enum_name(value, P.Width) + result[key] = next((k for k, v in WIDTHS.items() if v == native), native) + elif key == "rendering": + native = enum_name(value, P.ShadingRenderingStyleOption) + result[key] = next((k for k, v in RENDERING.items() if v == native), native) + finally: + b.Destroy() + attribute = "NX_MCP_DRAWING_CONSTRUCTION_V1" + result["construction_geometry"] = ( + obj.GetStringAttribute(attribute) == "show" + if obj.HasUserAttribute(attribute, self.nxopen.NXObject.AttributeType.String, -1) + else None + ) + if updates: + self._update_model() + self._work_part().DraftingViews.UpdateViews([obj]) + result = self._view_style(view) + if any(result[k] != v for k, v in updates.items()): + raise NXToolError( + "NX_VERIFICATION_FAILED", + "Native drafting style did not match requested settings", + details={"requested": updates, "actual": result}, + ) + return result diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py new file mode 100644 index 0000000..17e52ac --- /dev/null +++ b/src/nx_mcp/engineering.py @@ -0,0 +1,1701 @@ +"""Native engineering builders; all calls run in the existing NX transaction dispatcher.""" + +from __future__ import annotations + +import math + +from nx_mcp.authoring import finite +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import unit_normal + + +class EngineeringMixin: + def _engineering_owned(self, ref, kind): + obj = self._resolve(ref, {kind}) + if obj.IsOccurrence or obj.OwningPart != self._work_part(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select geometry owned by the work part") + return obj + + def _engineering_section(self, sketch): + part = self._work_part() + section = part.Sections.CreateSection() + options = part.ScRuleFactory.CreateRuleOptions() + try: + rule = part.ScRuleFactory.CreateRuleCurveFeature([sketch.Feature], None, options) + finally: + options.Dispose() + section.AddToSection( + [rule], None, None, None, sketch.Origin, self.nxopen.Section.Mode.Create, False + ) + return section + + def _engineering_collector(self, objects, kind): + part = self._work_part() + factory = getattr(part.ScRuleFactory, "CreateRule" + kind + "Dumb") + rule = factory(objects) + collector = part.ScCollectors.CreateCollector() + collector.ReplaceRules([rule], False) + return collector + + def _engineering_result(self, feature, modified=()): + part = self._work_part() + bodies = [self._reference(b, "body", part, "Body") for b in feature.GetBodies()] + ref = self._reference(feature, "feature", part, "Feature") + return { + "feature": ref, + "bodies": bodies, + "body_count": len(bodies), + "body": bodies[0] if bodies else None, + "units": self._units(), + "coordinate_frame": "work_part", + "created": [ref], + "modified": [self._reference(b, "body", part, "Body") for b in modified], + } + + def _engineering_direction(self, direction, origin=(0, 0, 0)): + return self._work_part().Directions.CreateDirection( + self.nxopen.Point3d(*map(float, origin)), + self.nxopen.Vector3d(*unit_normal(direction)), + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + + def _extrude( + self, + sketch_id, + distance=None, + reverse=False, + start=0.0, + end_type="distance", + symmetric=False, + direction=None, + target_face=None, + boolean="none", + targets=None, + ): + if ( + start == 0 + and end_type == "distance" + and not symmetric + and direction is None + and target_face is None + and boolean == "none" + and not targets + ): + if distance is None: + raise NXToolError("NX_INVALID_ARGUMENT", "distance is required") + return self._simple_extrude(sketch_id, distance, reverse) + import NXOpen.GeometricUtilities as G + + start = finite(start, "start") + if end_type not in {"distance", "through_all", "up_to_face"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported end_type") + if boolean not in {"none", "unite", "subtract", "intersect"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported boolean") + if symmetric and (end_type != "distance" or start != 0): + raise NXToolError("NX_INVALID_ARGUMENT", "Symmetric uses distance with start=0") + if (end_type == "up_to_face") != (target_face is not None): + raise NXToolError("NX_INVALID_ARGUMENT", "target_face is required only for up_to_face") + if end_type == "distance": + distance = finite(distance, "distance", True) + if not symmetric and distance <= start: + raise NXToolError("NX_INVALID_ARGUMENT", "End distance must exceed start") + elif distance is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "distance is ignored by non-distance limits") + bodies = [self._engineering_owned(r, "body") for r in targets or []] + if (boolean == "none") == bool(bodies): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Booleans require explicit targets; none forbids them" + ) + if end_type == "through_all" and not bodies: + raise NXToolError("NX_INVALID_ARGUMENT", "Through-all requires boolean targets") + sketch = self._engineering_owned(sketch_id, "sketch") + face = self._engineering_owned(target_face, "face") if target_face else None + axis = unit_normal(direction or self._sketch_frame(sketch)["normal"]) + if reverse: + axis = [-v for v in axis] + part = self._work_part() + b = part.Features.CreateExtrudeBuilder(None) + try: + b.Section = self._engineering_section(sketch) + b.Direction = self._engineering_direction( + axis, [sketch.Origin.X, sketch.Origin.Y, sketch.Origin.Z] + ) + b.Limits.StartExtend.TrimType = G.Extend.ExtendType.Value + b.Limits.StartExtend.Value.RightHandSide = str(-distance / 2 if symmetric else start) + end = b.Limits.EndExtend + end.TrimType = { + "distance": G.Extend.ExtendType.Value, + "through_all": G.Extend.ExtendType.ThroughAll, + "up_to_face": G.Extend.ExtendType.UntilSelected, + }[end_type] + if end_type == "distance": + end.Value.RightHandSide = str(distance / 2 if symmetric else distance) + if face: + end.Target = face + b.BooleanOperation.Type = getattr( + G.BooleanOperation.BooleanType, + { + "none": "Create", + "unite": "Unite", + "subtract": "Subtract", + "intersect": "Intersect", + }[boolean], + ) + if bodies: + b.BooleanOperation.SetTargetBodies(bodies) + feature = b.CommitFeature() + finally: + b.Destroy() + result = self._engineering_result(feature, bodies) + result["limits"] = { + "start": start, + "end_type": end_type, + "distance": distance, + "symmetric": symmetric, + "direction": axis, + } + result["boolean"] = boolean + return result + + def _shell(self, body, thickness, remove_faces=None, outward=False): + target = self._engineering_owned(body, "body") + thickness = finite(thickness, "thickness", True) + faces = [self._engineering_owned(ref, "face") for ref in remove_faces or []] + if any(f.GetBody() != target for f in faces): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Removed faces must belong to body") + b = self._work_part().Features.CreateShellBuilder(None) + try: + b.Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4 + b.Body = target + b.DefaultThickness.RightHandSide = str(thickness) + b.DefaultThicknessFlip = not outward + if faces: + b.RemovedFacesCollector = self._engineering_collector(faces, "Face") + feature = b.CommitFeature() + finally: + b.Destroy() + return self._engineering_result(feature, [target]) + + def _loft(self, sketches, solid=True): + if not 2 <= len(sketches) <= 20 or len(set(sketches)) != len(sketches): + raise NXToolError("NX_INVALID_ARGUMENT", "Use 2–20 distinct ordered sketches") + sections = [self._engineering_owned(s, "sketch") for s in sketches] + b = self._work_part().Features.CreateThroughCurvesBuilder(None) + try: + b.BodyPreference = b.BodyPreferenceTypes.Solid if solid else b.BodyPreferenceTypes.Sheet + for sketch in sections: + b.SectionsList.Append(self._engineering_section(sketch)) + feature = b.CommitFeature() + finally: + b.Destroy() + return self._engineering_result(feature) + + def _sketch_primitive(self, sketch_id, primitive, center, width=None, height=None, radius=None): + sketch = self._engineering_owned(sketch_id, "sketch") + if primitive not in {"circle", "slot", "rounded_rectangle"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown primitive") + cx, cy = [finite(x, "center") for x in center] + curves = [] + + def line(a, b): + curves.append( + self._create_sketch_line( + sketch, self._work_part(), {"x": a[0], "y": a[1]}, {"x": b[0], "y": b[1]} + ) + ) + + def arc(x, y, r, a, b): + self._sketch_arc_legacy(x, y, r, a, b, sketch_id) + + before = {int(c.Tag) for c in sketch.GetAllGeometry()} + if primitive == "circle": + if width is not None or height is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "Circle accepts radius only") + radius = finite(radius, "radius", True) + with self._editing_sketch(sketch): + arc(cx, cy, radius, 0, 360) + else: + width, height = finite(width, "width", True), finite(height, "height", True) + if primitive == "slot": + if radius is not None or width <= height: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Horizontal slot needs width>height and no radius" + ) + radius = height / 2 + else: + radius = finite(radius, "radius", True) + if radius >= min(width, height) / 2: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Corner radius must be less than half the shortest side", + ) + left, r, b, t = cx - width / 2, cx + width / 2, cy - height / 2, cy + height / 2 + with self._editing_sketch(sketch): + line((left + radius, b), (r - radius, b)) + line((r - radius, t), (left + radius, t)) + if primitive == "slot": + arc(r - radius, cy, radius, -90, 90) + arc(left + radius, cy, radius, 90, 270) + else: + line((r, b + radius), (r, t - radius)) + line((left, t - radius), (left, b + radius)) + for x, y, a in [ + (r - radius, b + radius, 270), + (r - radius, t - radius, 0), + (left + radius, t - radius, 90), + (left + radius, b + radius, 180), + ]: + arc(x, y, radius, a, a + 90) + created = [ + self._reference(c, "curve", self._work_part(), "Curve") + for c in sketch.GetAllGeometry() + if int(c.Tag) not in before + ] + return { + "curves": created, + "curve_count": len(created), + "created": created, + "diagnostics": self._check_sketch_result(sketch_id), + } + + def _copy_project(self, path, prefix, activate=False): + import hashlib + import json + import os + import shutil + from pathlib import Path + + import NXOpen.UF + + destination = self.workspace.ensure_inside(path) + if destination.exists(): + raise NXToolError("NX_FILE_EXISTS", "Project destination must be a new directory") + if not prefix or any( + c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + for c in prefix + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use a nonempty simple filename prefix for the new project" + ) + part = self._work_part() + sources = {Path(part.FullPath).resolve()} + for component, _ in self._walk_components(part): + prototype = component.Prototype + if prototype is None or not hasattr(prototype, "FullPath"): + raise NXToolError( + "NX_UNLOADED_COMPONENT", "Load all project prototypes before copying" + ) + sources.add(Path(prototype.FullPath).resolve()) + for loaded in self.session.Parts: + if Path(loaded.FullPath).resolve() in sources and loaded.IsModified: + raise NXToolError("NX_UNSAVED_PART", "Save all project prototypes before copying") + for source in sources: + self.workspace.ensure_inside(source) + if not source.is_file(): + raise NXToolError("NX_FILE_NOT_FOUND", str(source)) + base = Path(os.path.commonpath([str(p.parent) for p in sources])) + if destination.is_relative_to(base) and any(destination == p.parent for p in sources): + raise NXToolError("NX_INVALID_ARGUMENT", "Choose a separate project directory") + mapping = {p: destination / p.relative_to(base).parent / (prefix + p.name) for p in sources} + loaded_names = {Path(p.FullPath).name.casefold() for p in self.session.Parts} + if any(p.name.casefold() in loaded_names for p in mapping.values()): + raise NXToolError( + "NX_FILE_EXISTS", "Choose a prefix that does not collide with loaded part basenames" + ) + originals = {int(p.Tag) for p in self.session.Parts} + hashes = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources} + clone = NXOpen.UF.UFSession.GetUFSession().Clone + self._require_api( + clone, "Initialise", "AddAssembly", "SetNaming", "PerformClone", "Terminate" + ) + created = False + try: + destination.mkdir(parents=True, exist_ok=False) + created = True + for p in mapping.values(): + p.parent.mkdir(parents=True, exist_ok=True) + clone.Initialise(clone.OperationClass.CLONE_OPERATION) + try: + clone.SetDefAction(clone.Action.CLONE) + clone.AddAssembly(str(Path(part.FullPath).resolve())) + for source, target in mapping.items(): + clone.SetNaming(str(source), clone.NamingTechnique.USER_NAME, str(target)) + clone.PerformClone(clone.InitNamingFailures()) + finally: + clone.Terminate() + for source, target in mapping.items(): + if not target.is_file(): + raise NXToolError( + "NX_CLONE_FAILED", "Native clone did not produce every mapped part" + ) + if hashlib.sha256(source.read_bytes()).hexdigest() != hashes[str(source)]: + raise NXToolError("NX_SOURCE_CHANGED", "Source changed during project copy") + new_top = mapping[Path(part.FullPath).resolve()] + self._open_part(str(new_top)) + copied = self._work_part() + resolved = { + Path(c.Prototype.FullPath).resolve() for c, _ in self._walk_components(copied) + } + expected = set(mapping.values()) - {new_top} + if resolved != expected: + raise NXToolError( + "NX_CLONE_REFERENCE_MISMATCH", + "Copied assembly does not resolve to mapped prototypes", + ) + manifest = { + "source_root": str(base), + "destination": str(destination), + "top_part": str(new_top), + "files": [ + { + "source": str(s), + "path": str(t), + "sha256": hashlib.sha256(t.read_bytes()).hexdigest(), + } + for s, t in sorted(mapping.items()) + ], + "dependency_count": len(expected), + "references_verified": True, + "originals_preserved": True, + } + (destination / "nx-project-manifest.json").write_text(json.dumps(manifest, indent=2)) + if not activate: + for p in list(self.session.Parts): + if int(p.Tag) not in originals and Path(p.FullPath).resolve().is_relative_to( + destination + ): + p.Close( + self.nxopen.BasePart.CloseWholeTree.FalseValue, + self.nxopen.BasePart.CloseModified.CloseModified, + None, + ) + self._activate_part(self._reference(part, "part", part, "Part")["id"]) + return manifest + except Exception: + for p in list(self.session.Parts): + if int(p.Tag) not in originals and Path(p.FullPath).resolve().is_relative_to( + destination + ): + p.Close( + self.nxopen.BasePart.CloseWholeTree.FalseValue, + self.nxopen.BasePart.CloseModified.CloseModified, + None, + ) + self._activate_part(self._reference(part, "part", part, "Part")["id"]) + if created: + shutil.rmtree(destination) + raise + + def _mass_properties(self, body=None, scope="auto"): + import NXOpen.UF + + from nx_mcp.hardened import rows, xyz + + bodies = self._geometry(body, scope) + if not bodies or any(not b.IsSolidBody for b in bodies): + raise NXToolError("NX_NOT_SOLID", "Mass properties require solid bodies") + uf = NXOpen.UF.UFSession.GetUFSession() + mass, accuracy = uf.Modeling.AskMassProps3d( + [b.Tag for b in bodies], len(bodies), 1, 4, 0.0, 1, [0.999] + [0.0] * 10 + ) + frame = self._work_part().WCS.CoordinateSystem + return { + "mass_kg": mass[2], + "volume_m3": mass[1], + "area_m2": mass[0], + "center_of_gravity_m": list(mass[3:6]), + "inertia_tensor_centroid_kg_m2": [ + [mass[12], -mass[19], -mass[21]], + [-mass[19], mass[13], -mass[20]], + [-mass[21], -mass[20], mass[14]], + ], + "principal_moments_kg_m2": list(mass[31:34]), + "density_kg_m3": mass[46], + "body_count": len(bodies), + "bodies": [self._reference(b, "body", self._work_part(), "Body") for b in bodies], + "coordinate_frame": "work_part_wcs", + "wcs_origin_in_part_units": xyz(frame.Origin), + "wcs_rotation": rows(frame.Orientation.Element), + "native_error_estimates": list(accuracy), + "semantics": "sum_of_included_bodies_with_assigned_densities", + "warnings": [ + "Overlapping solids are counted separately. Review assigned densities before using mass results." + ], + } + + def _draft(self, faces, stationary_face, direction, angle): + angle = finite(angle, "angle") + if not 0 < abs(angle) < 89: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Draft angle must have magnitude between 0 and 89 degrees" + ) + selected = [self._engineering_owned(f, "face") for f in faces] + fixed = self._engineering_owned(stationary_face, "face") + if ( + not selected + or fixed in selected + or any(f.GetBody() != fixed.GetBody() for f in selected) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Draft faces must share the stationary face's body and exclude that face", + ) + part = self._work_part() + b = part.Features.CreateDraftBuilder(None) + try: + b.AngleTolerance = 0.1 + b.DistanceTolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4 + b.TypeOfDraft = b.Type.Face + b.Direction = self._engineering_direction(direction) + stationary = self._engineering_collector([fixed], "Face") + b.StationaryReference.ReplaceRules(stationary.GetRules(), False) + group = part.CreateExpressionCollectorSet( + self._engineering_collector(selected, "Face"), str(angle), "Angle", 0 + ) + b.FaceSetAngleExpressionList.Append(group) + feature = b.CommitFeature() + finally: + b.Destroy() + return self._engineering_result(feature, [fixed.GetBody()]) + + def _transform_bodies( + self, translation, rotation_matrix, bodies=None, copy=False, feature=None + ): + from nx_mcp.hardened import IDENTITY, vector + + rotation = self._validate_rotation(rotation_matrix) + translation = vector(translation, "translation") + if feature is not None and (bodies is not None or copy): + raise NXToolError( + "NX_INVALID_ARGUMENT", "When editing a motion feature, omit bodies and copy" + ) + selected = [self._engineering_owned(r, "body") for r in bodies or []] + if feature is None and not selected: + raise NXToolError("NX_INVALID_ARGUMENT", "Select bodies for a new motion feature") + existing = self._engineering_owned(feature, "feature") if feature else None + part = self._work_part() + copied_feature = None + if copy: + extract = part.Features.CreateExtractFaceBuilder(None) + try: + extract.Type = extract.ExtractType.Body + extract.Associative = True + extract.HideOriginal = False + extract.InheritDisplayProperties = True + collector = self._engineering_collector(selected, "Body") + extract.ExtractBodyCollector.ReplaceRules(collector.GetRules(), False) + copied_feature = extract.CommitFeature() + selected = list(copied_feature.GetBodies()) + finally: + extract.Destroy() + before_features = {int(f.Tag) for f in part.Features} + b = part.BaseFeatures.CreateMoveObjectBuilder(existing) + try: + b.Associative = True + b.MoveParents = False + if existing is None: + b.ObjectToMoveObject.Add(selected) + b.MoveObjectResult = b.MoveObjectResultOptions.MoveOriginal + b.TransformMotion.Option = b.TransformMotion.Options.CsysToCsys + b.TransformMotion.FromCsys = part.CoordinateSystems.CreateCoordinateSystem( + self.nxopen.Point3d(0.0, 0.0, 0.0), self._nx_matrix(IDENTITY), False + ) + b.TransformMotion.ToCsys = part.CoordinateSystems.CreateCoordinateSystem( + self.nxopen.Point3d(*translation), self._nx_matrix(rotation), False + ) + b.MoveParents = False + b.Associative = True + result = b.Commit() + if result is None: + created = [f for f in part.Features if int(f.Tag) not in before_features] + result = existing or (created[0] if len(created) == 1 else None) + if result is None: + raise NXToolError( + "NX_MOTION_RESULT_MISSING", + "Native motion did not expose one associative feature", + ) + finally: + b.Destroy() + if not isinstance(result, self.nxopen.Features.MoveObject): + raise NXToolError( + "NX_MOTION_NOT_ASSOCIATIVE", + "NX did not produce an editable MoveObject feature; rolling back", + ) + out = self._engineering_result(result) + out.update( + copy_feature=self._reference(copied_feature, "feature", part, "Associative copy") + if copied_feature + else None, + translation=translation, + rotation_matrix=rotation, + transform_semantics="absolute mapping from feature input coordinates; edit feature to replace transform", + ) + return out + + def _component_array( + self, + component, + pattern_type, + count, + spacing=None, + direction=None, + count_y=1, + spacing_y=None, + direction_y=None, + center=None, + axis=None, + angle=None, + ): + from nx_mcp.hardened import cross, dot, vector + + if ( + type(count) is not int + or not 2 <= count <= 100 + or type(count_y) is not int + or not 1 <= count_y <= 100 + or count * count_y > 100 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Array requires 2–100 total instances including the seed" + ) + seed = self._resolve(component, {"component"}) + part = self._work_part() + if seed.Parent != part.ComponentAssembly.RootComponent or seed.IsSuppressed: + raise NXToolError( + "NX_UNSUPPORTED_SCOPE", "Select an unsuppressed immediate child occurrence" + ) + if pattern_type == "rectangular": + if center is not None or axis is not None or angle is not None: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Rectangular arrays forbid circular parameters" + ) + dx = unit_normal(direction) + spacing = finite(spacing, "spacing", True) + if count_y > 1: + dy = unit_normal(direction_y) + spacing_y = finite(spacing_y, "spacing_y", True) + if dot(cross(dx, dy), cross(dx, dy)) < 1e-10: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Array directions must not be parallel" + ) + elif direction_y is not None or spacing_y is not None: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Second direction parameters require count_y>1" + ) + elif pattern_type == "circular": + if ( + spacing is not None + or direction is not None + or count_y != 1 + or spacing_y is not None + or direction_y is not None + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Circular arrays forbid rectangular parameters" + ) + center = vector(center, "center") + axis = unit_normal(axis) + angle = finite(angle, "angle", True) + if (count - 1) * angle >= 360: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Pitch must not repeat an angular position" + ) + else: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown pattern_type") + b = part.ComponentAssembly.CreateComponentPatternBuilder(None) + try: + b.Associative = True + b.ComponentPatternSet.Add(seed) + if pattern_type == "rectangular": + b.PatternService.PatternType = b.PatternService.PatternEnum.Linear + d = b.PatternService.RectangularDefinition + d.XDirection = self._engineering_direction(dx) + d.XSpacing.NCopies.RightHandSide = str(count) + d.XSpacing.PitchDistance.RightHandSide = str(spacing) + d.UseYDirectionToggle = count_y > 1 + d.YSpacing.NCopies.RightHandSide = str(count_y) + if count_y > 1: + d.YDirection = self._engineering_direction(dy) + d.YSpacing.PitchDistance.RightHandSide = str(spacing_y) + else: + b.PatternService.PatternType = b.PatternService.PatternEnum.Circular + d = b.PatternService.CircularDefinition + point = part.Points.CreatePoint(self.nxopen.Point3d(*center)) + d.RotationAxis = part.Axes.CreateAxis( + point, + self._engineering_direction(axis), + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + d.AngularSpacing.NCopies.RightHandSide = str(count) + d.AngularSpacing.PitchAngle.RightHandSide = str(angle) + d.RadialSpacing.NCopies.RightHandSide = "1" + pattern = b.Commit() + finally: + b.Destroy() + self._update_model() + result = self._component_pattern_record(pattern) + if result["total_instances"] != count * count_y: + raise NXToolError( + "NX_PATTERN_VERIFICATION_FAILED", "Native array count differs; rolling back" + ) + result.update( + pattern_type=pattern_type, + count=count, + count_y=count_y, + spacing=spacing, + spacing_y=spacing_y, + angle_degrees=angle, + ) + return result + + def _set_material(self, bodies, name, density): + import NXOpen.UF + + density = finite(density, "density", True) + if not isinstance(name, str) or not name.strip() or len(name) > 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Provide a material name of 1–100 characters") + selected = [self._engineering_owned(r, "body") for r in bodies] + if not selected or any(not b.IsSolidBody for b in selected): + raise NXToolError("NX_NOT_SOLID", "Material assignment requires owned solid bodies") + part = self._work_part() + materials = part.MaterialManager.PhysicalMaterials + if any(m.Name.casefold() == name.casefold() for m in materials): + raise NXToolError( + "NX_MATERIAL_EXISTS", + "Choose a new material name to avoid changing other bodies implicitly", + ) + b = materials.CreatePhysicalMaterialBuilder(self.nxopen.PhysicalMaterial.Type.Isotropic) + try: + b.Name = name + b.PropertyTable.SetBaseScalarWithDataPropertyValue( + "MassDensity", density, part.UnitCollection.FindObject("KilogramPerCubicMeter") + ) + material = b.Commit() + finally: + b.Destroy() + material.AssignObjects(selected) + uf = NXOpen.UF.UFSession.GetUFSession() + actual = [ + uf.Modeling.AskBodyDensity(body.Tag, NXOpen.UF.Modl.DensityUnits.KILOGRAMS_METERS) + for body in selected + ] + if any(abs(v - density) > max(1e-8, density * 1e-8) for v in actual): + raise NXToolError( + "NX_MATERIAL_VERIFICATION_FAILED", + "Assigned native body densities differ; rolling back", + ) + return { + "material_name": material.Name, + "density_kg_m3": density, + "bodies": [self._reference(body, "body", part, "Body") for body in selected], + "verified_densities_kg_m3": actual, + "properties_defined": ["mass_density"], + "warnings": [ + "This local material defines density only; elastic, thermal and appearance properties are not inferred." + ], + } + + def _material_info(self, body=None, scope="auto"): + import NXOpen.UF + + part = self._work_part() + uf = NXOpen.UF.UFSession.GetUFSession() + rows = [] + for obj in self._geometry(body, scope): + prototype = obj.Prototype if obj.IsOccurrence else obj + material = prototype.OwningPart.MaterialManager.PhysicalMaterials.AskMaterialOfObject( + prototype + ) + rows.append( + { + "body": self._reference(obj, "body", part, "Body"), + "material_name": material.Name if material else None, + "density_kg_m3": uf.Modeling.AskBodyDensity( + prototype.Tag, NXOpen.UF.Modl.DensityUnits.KILOGRAMS_METERS + ), + } + ) + return {"bodies": rows, "body_count": len(rows)} + + def _sketch_angle(self, sketch_id, line1, line2, value, origin): + sketch = self._engineering_owned(sketch_id, "sketch") + one, two = self._owned_curve(sketch, line1), self._owned_curve(sketch, line2) + if one == two or not all(isinstance(c, self.nxopen.Line) for c in [one, two]): + raise NXToolError("NX_INVALID_ARGUMENT", "Select two different sketch lines") + value = finite(value, "value", True) + if value >= 180: + raise NXToolError("NX_INVALID_ARGUMENT", "Angle must be between 0 and 180 degrees") + a, b = self.nxopen.Sketch.DimensionGeometry(), self.nxopen.Sketch.DimensionGeometry() + a.Geometry = one + b.Geometry = two + with self._editing_sketch(sketch): + constraint = sketch.CreateDimension( + self.nxopen.Sketch.ConstraintType.AngularDim, + a, + b, + self._sketch_local_point(sketch, origin), + None, + self.nxopen.Sketch.DimensionOption.CreateAsDriving, + ) + exp = constraint.AssociatedExpression + self._work_part().Expressions.EditExpression(exp, str(value)) + sketch.Update() + diagnostics = self._check_sketch_result(sketch_id) + return { + "constraint": self._reference( + constraint, "constraint", self._work_part(), "Angular dimension" + ), + "expression": self._expression_record(exp), + "diagnostics": diagnostics, + } + + def _sketch_tangent(self, sketch_id, curve1, curve2): + from nx_mcp.hardened import cross, dot, xyz + + sketch = self._engineering_owned(sketch_id, "sketch") + a, b = self._owned_curve(sketch, curve1), self._owned_curve(sketch, curve2) + if ( + a == b + or not all(isinstance(c, (self.nxopen.Line, self.nxopen.Arc)) for c in [a, b]) + or all(isinstance(c, self.nxopen.Line) for c in [a, b]) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Select a line and arc, or two arcs") + with self._editing_sketch(sketch): + before = { + int(c.Tag) + for c in sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + } + builder = self._work_part().Sketches.CreateSketchMakeTangentBuilder() + try: + builder.StationaryObject.Value = a + builder.MotionObjects.Add(b) + builder.SetCreateConstraints(True) + builder.FindRelations() + builder.Commit() + sketch.Update() + constraints = [ + c + for c in sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, + self.nxopen.Sketch.ConstraintType.NoCon, + ) + if int(c.Tag) not in before + ] + finally: + builder.Destroy() + diagnostics = self._check_sketch_result(sketch_id) + if isinstance(a, self.nxopen.Line) or isinstance(b, self.nxopen.Line): + line, circle = (a, b) if isinstance(a, self.nxopen.Line) else (b, a) + v = [x - y for x, y in zip(xyz(line.EndPoint), xyz(line.StartPoint), strict=True)] + d = [ + x - y + for x, y in zip(xyz(circle.CenterPoint), xyz(line.StartPoint), strict=True) + ] + normal = cross(v, d) + residual = abs((dot(normal, normal) / dot(v, v)) ** 0.5 - circle.Radius) + else: + distance = ( + sum( + (x - y) ** 2 + for x, y in zip(xyz(a.CenterPoint), xyz(b.CenterPoint), strict=True) + ) + ** 0.5 + ) + residual = min( + abs(distance - a.Radius - b.Radius), abs(distance - abs(a.Radius - b.Radius)) + ) + if residual > 1e-6 or not constraints: + raise NXToolError( + "NX_CONSTRAINT_UNSATISFIED", + "Native tangent relation was not satisfied persistently", + details={"residual": residual, "created_constraints": len(constraints)}, + ) + return { + "constraints": [ + self._reference(c, "constraint", self._work_part(), "Tangent") for c in constraints + ], + "residual": residual, + "diagnostics": diagnostics, + } + + def _sketch_symmetry(self, sketch_id, curve1, curve2, centerline): + from nx_mcp.hardened import dot, xyz + + sketch = self._engineering_owned(sketch_id, "sketch") + a, b, c = [self._owned_curve(sketch, r) for r in [curve1, curve2, centerline]] + if len({int(o.Tag) for o in [a, b, c]}) != 3 or not all( + isinstance(o, self.nxopen.Line) for o in [a, b, c] + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Select two lines and a distinct straight centerline" + ) + + def constraints(): + return list( + sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + ) + + with self._editing_sketch(sketch): + before = {int(o.Tag) for o in constraints()} + builder = self._work_part().Sketches.CreateSketchSymmetricBuilder() + try: + builder.StationaryObject.Value = a + builder.MotionObjects.Add(b) + builder.CenterLine.Value = c + builder.ConvertCenterlineToReference = False + builder.SetCreateConstraints(True) + builder.FindRelations() + builder.Commit() + finally: + builder.Destroy() + sketch.Update() + origin = xyz(c.StartPoint) + axis = [x - y for x, y in zip(xyz(c.EndPoint), origin, strict=True)] + + def reflect(point): + delta = [x - y for x, y in zip(xyz(point), origin, strict=True)] + t = dot(delta, axis) / dot(axis, axis) + return [o + 2 * t * d - v for o, d, v in zip(origin, axis, delta, strict=True)] + + def error(points): + return max( + sum((x - y) ** 2 for x, y in zip(reflect(p), xyz(q), strict=True)) ** 0.5 + for p, q in zip([a.StartPoint, a.EndPoint], points, strict=True) + ) + + residual = min(error([b.StartPoint, b.EndPoint]), error([b.EndPoint, b.StartPoint])) + after = constraints() + created = [o for o in after if int(o.Tag) not in before] + if residual > 1e-6 or not created or not before <= {int(o.Tag) for o in after}: + raise NXToolError( + "NX_CONSTRAINT_UNSATISFIED", + "Native symmetry was not satisfied persistently without deleting constraints", + details={"residual": residual, "created_constraints": len(created)}, + ) + diagnostics = self._check_sketch_result(sketch_id) + return { + "constraints": [ + self._reference(o, "constraint", self._work_part(), "Symmetry") for o in created + ], + "residual": residual, + "diagnostics": diagnostics, + } + + def _sketch_trim_extend(self, sketch_id, curve, boundaries, pick, action): + sketch = self._engineering_owned(sketch_id, "sketch") + selected = self._owned_curve(sketch, curve) + limits = [self._owned_curve(sketch, r) for r in boundaries] + if not limits or selected in limits or action not in {"trim", "extend"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Select distinct boundary curves and trim/extend action" + ) + point = self._sketch_local_point(sketch, pick) + with self._editing_sketch(sketch): + fn = ( + self._work_part().Sketches.CreateQuickTrimBuilder + if action == "trim" + else self._work_part().Sketches.CreateQuickExtendBuilder + ) + builder = fn() + try: + builder.BoundaryObjects.Add(limits) + builder.ExtendBound = False + curves = builder.TrimmedCurves if action == "trim" else builder.ExtendedCurves + curves.Add(selected, self._work_part().ModelingViews.WorkView, point) + builder.Commit() + finally: + builder.Destroy() + diagnostics = self._check_sketch_result(sketch_id) + return {"sketch": self._sketch_info(sketch_id), "diagnostics": diagnostics} + + def _render_view( + self, + path=None, + width=1600, + height=1000, + background="white", + color=None, + style="studio", + lighting=None, + ): + import hashlib + import struct + import uuid + + if self.session.IsBatch: + raise NXToolError( + "NX_VIEWPORT_UNAVAILABLE", "Rendering requires the interactive NX bridge" + ) + if any(type(n) is not int or not 128 <= n <= 4096 for n in [width, height]): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Resolution must be 128–4096 pixels per dimension" + ) + if background not in {"white", "original", "transparent", "color"} or style not in { + "studio", + "shaded", + "shaded_with_edges", + }: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported rendering options") + if background == "color": + if ( + not isinstance(color, list) + or len(color) != 3 + or any(not 0 <= finite(v, "color") <= 1 for v in color) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "color requires three RGB values in [0,1]") + elif color is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "color requires background=color") + if lighting is not None and (type(lighting) is not int or not 1 <= lighting <= 5): + raise NXToolError("NX_INVALID_ARGUMENT", "lighting must be a native preset number 1–5") + file = ( + self.workspace.ensure_inside(path) + if path + else self.workspace.root / "captures" / ("render-" + uuid.uuid4().hex + ".png") + ) + if file.suffix.lower() != ".png" or file.exists(): + raise NXToolError("NX_INVALID_ARGUMENT", "Choose a new .png path") + part = self.session.Parts.Display + if part is None: + raise NXToolError("NX_NO_DISPLAY_PART", "Open a display part first") + file.parent.mkdir(parents=True, exist_ok=True) + view = part.ModelingViews.WorkView + original_style = view.RenderingStyle + lights = builder = None + previous_lighting = None + try: + view.RenderingStyle = getattr( + self.nxopen.View.RenderingStyleType, + {"studio": "Studio", "shaded": "Shaded", "shaded_with_edges": "ShadedWithEdges"}[ + style + ], + ) + if lighting is not None: + lights = part.Views.CreateLighting(None) + previous_lighting = lights.LightsShadedViewsLightingCollection + lights.LightsShadedViewsLightingCollection = getattr( + lights.LightingCollectionType, "Lighting" + str(lighting) + ) + lights.Commit() + view.UpdateDisplay() + camera = self._view_info() + builder = part.Views.CreateStudioImageCaptureBuilder() + builder.Source = builder.SourceType.WorkView + builder.UnitsEnum = builder.UnitsEnumType.Pixels + builder.SetImageDimensionsInteger([height, width]) + builder.NativeFileBrowser = str(file) + builder.BackgroundOption = getattr( + builder.BackgroundOptions, + { + "white": "CustomColor", + "color": "CustomColor", + "original": "Original", + "transparent": "Transparent", + }[background], + ) + if background in {"white", "color"}: + builder.SetCustomBackgroundColor( + [1.0, 1.0, 1.0] if background == "white" else color + ) + builder.Commit() + data = file.read_bytes() + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": + raise NXToolError("NX_CAPTURE_FAILED", "NX did not produce a valid PNG") + resolution = list(struct.unpack(">II", data[16:24])) + if resolution != [width, height]: + raise NXToolError( + "NX_CAPTURE_RESOLUTION_MISMATCH", + "Native rendering did not honor the requested resolution", + ) + except Exception: + file.unlink(missing_ok=True) + raise + finally: + try: + try: + if builder is not None: + builder.Destroy() + finally: + try: + if lights is not None: + try: + if previous_lighting is not None: + lights.LightsShadedViewsLightingCollection = previous_lighting + lights.Commit() + finally: + lights.Destroy() + finally: + view.RenderingStyle = original_style + view.UpdateDisplay() + except Exception as error: + file.unlink(missing_ok=True) + raise NXToolError( + "NX_RENDER_RESTORE_FAILED", + "Native rendering cleanup failed", + details={"mutation_outcome": "partial", "cleanup_error": str(error)}, + ) from error + return { + "path": str(file), + "artifact_path": str(file.relative_to(self.workspace.root)), + "capture_kind": "nx_native_render", + "model_preview": True, + "resolution": resolution, + "requested_resolution": [width, height], + "camera": camera, + "background": background, + "color": color, + "style": style, + "lighting_preset": lighting, + "rendering": "NX StudioImageCaptureBuilder", + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "warnings": [], + } + + def _assembly_constraints(self, part): + positioner = getattr(getattr(part, "ComponentAssembly", None), "Positioner", None) + return list(positioner.Constraints) if positioner is not None else [] + + def _assembly_constraint_record(self, constraint): + import NXOpen.Positioning + + from nx_mcp.visual_tools import enum_name + + part = self._work_part() + cls = NXOpen.Positioning.Constraint + references = [] + for ref in constraint.GetReferences(): + obj, geom = ref.GetMovableObject(), ref.GetGeometry() + kind = next( + ( + name + for name, typ in [ + ("face", self.nxopen.Face), + ("edge", self.nxopen.Edge), + ("body", self.nxopen.Body), + ("component", self.nxopen.Assemblies.Component), + ] + if isinstance(geom, typ) + ), + None, + ) + references.append( + { + "component": self._reference(obj, "component", part, "Component") + if isinstance(obj, self.nxopen.Assemblies.Component) + else None, + "geometry": self._reference(geom, kind, part, kind.title()) if kind else None, + "native_geometry_type": type(geom).__name__, + } + ) + expression = ( + constraint.Expression + if constraint.ConstraintType in {cls.Type.Distance, cls.Type.Angle} + else None + ) + return { + "object": self._reference( + constraint, "assembly_constraint", part, "Assembly constraint" + ), + "constraint_type": enum_name(constraint.ConstraintType, cls.Type), + "alignment": enum_name(constraint.ConstraintAlignment, cls.Alignment), + "solver_status": enum_name(constraint.GetConstraintStatus(), cls.SolverStatus), + "suppressed": constraint.Suppressed, + "expression": self._expression_record(expression) if expression else None, + "references": references, + } + + def _list_assembly_constraints(self): + rows = [ + self._assembly_constraint_record(c) + for c in self._assembly_constraints(self._work_part()) + ] + return { + "constraints": rows, + "constraint_count": len(rows), + "coordinate_frame": "work_part", + "units": self._units(), + } + + def _assembly_constraint( + self, + constraint_type, + component, + geometry=None, + target_component=None, + target_geometry=None, + value=None, + alignment="infer", + ): + import NXOpen.Positioning + + cls = NXOpen.Positioning.Constraint + types = { + "fix": "Fix", + "touch": "Touch", + "distance": "Distance", + "parallel": "Parallel", + "perpendicular": "Perpendicular", + "angle": "Angle", + "concentric": "Concentric", + } + alignments = {"infer": "InferAlign", "same": "CoAlign", "opposite": "ContraAlign"} + if constraint_type not in types or alignment not in alignments: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported constraint type or alignment") + moving = self._resolve(component, {"component"}) + if ( + moving.Parent != self._work_part().ComponentAssembly.RootComponent + or moving.IsSuppressed + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Select an unsuppressed immediate child component" + ) + refs = [] + if constraint_type == "fix": + if ( + any(v is not None for v in [geometry, target_component, target_geometry, value]) + or alignment != "infer" + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Fix takes only component") + refs = [(moving, moving)] + else: + if any( + not isinstance(v, str) or not v + for v in [geometry, target_component, target_geometry] + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Provide moving/target geometry and target_component" + ) + target = self._resolve(target_component, {"component"}) + if target == moving or target.Parent != moving.Parent or target.IsSuppressed: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Target must be a distinct unsuppressed sibling" + ) + for component_obj, geometry_ref in [(moving, geometry), (target, target_geometry)]: + geom = self._resolve(geometry_ref, {"face", "edge"}) + if not geom.IsOccurrence or geom.OwningComponent != component_obj: + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", + "Select occurrence geometry belonging to the specified component", + ) + refs.append((component_obj, geom)) + if constraint_type in {"distance", "angle"}: + value = finite(value, "value") + if value < 0 or (constraint_type == "angle" and value > 180): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Distance must be nonnegative; angle must be 0–180 degrees", + ) + elif value is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "value applies only to distance/angle") + positioner = self._work_part().ComponentAssembly.Positioner + positioner.BeginAssemblyConstraints() + try: + network = positioner.EstablishNetwork() + network.MoveObjectsState = True + constraint = positioner.CreateConstraint(True) + constraint.ConstraintType = getattr(cls.Type, types[constraint_type]) + constraint.ConstraintAlignment = getattr(cls.Alignment, alignments[alignment]) + for component_obj, geom in refs: + constraint.CreateConstraintReference(component_obj, geom, False, False) + if value is not None: + constraint.SetExpression(str(value)) + network.AddConstraint(constraint) + network.Solve() + network.ApplyToModel() + self._update_model() + if constraint.GetConstraintStatus() != cls.SolverStatus.Solved: + raise NXToolError( + "NX_CONSTRAINT_UNSATISFIED", + "Native assembly solver did not satisfy the constraint", + details=self._assembly_constraint_record(constraint), + ) + return self._assembly_constraint_record(constraint) + finally: + try: + positioner.ClearNetwork() + finally: + positioner.EndAssemblyConstraints() + + def _edit_assembly_constraint(self, constraint, value=None, suppressed=None, alignment=None): + import NXOpen.Positioning + + obj = self._resolve(constraint, {"assembly_constraint"}) + cls = NXOpen.Positioning.Constraint + if all(v is None for v in [value, suppressed, alignment]): + raise NXToolError("NX_INVALID_ARGUMENT", "Provide value, suppressed, or alignment") + if value is not None: + value = finite(value, "value") + if ( + obj.ConstraintType not in {cls.Type.Distance, cls.Type.Angle} + or value < 0 + or (obj.ConstraintType == cls.Type.Angle and value > 180) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "value requires distance>=0 or angle 0–180 degrees" + ) + if suppressed is not None and type(suppressed) is not bool: + raise NXToolError("NX_INVALID_ARGUMENT", "suppressed must be boolean") + names = {"infer": "InferAlign", "same": "CoAlign", "opposite": "ContraAlign"} + if alignment is not None and alignment not in names: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported alignment") + positioner = self._work_part().ComponentAssembly.Positioner + positioner.BeginAssemblyConstraints() + try: + if value is not None: + obj.SetExpression(str(value)) + if suppressed is not None: + obj.Suppressed = suppressed + if alignment is not None: + obj.ConstraintAlignment = getattr(cls.Alignment, names[alignment]) + network = positioner.EstablishNetwork() + network.MoveObjectsState = True + network.AddConstraint(obj) + network.Solve() + network.ApplyToModel() + self._update_model() + if not obj.Suppressed and obj.GetConstraintStatus() != cls.SolverStatus.Solved: + raise NXToolError( + "NX_CONSTRAINT_UNSATISFIED", "Edited assembly constraint was not solved" + ) + return self._assembly_constraint_record(obj) + finally: + try: + positioner.ClearNetwork() + finally: + positioner.EndAssemblyConstraints() + + def _blend(self, edges, radius): + radius = finite(radius, "radius", True) + selected = [self._engineering_owned(r, "edge") for r in edges] + if not selected or len({int(e.GetBody().Tag) for e in selected}) != 1: + raise NXToolError("NX_INVALID_ARGUMENT", "Select edges of one owned body") + target = selected[0].GetBody() + b = self._work_part().Features.CreateEdgeBlendBuilder(None) + try: + b.Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4 + b.AddChainset(self._engineering_collector(selected, "Edge"), str(radius)) + result = b.CommitFeature() + finally: + b.Destroy() + return self._engineering_result(result, [target]) + + def _chamfer(self, edges, offset): + offset = finite(offset, "offset", True) + selected = [self._engineering_owned(r, "edge") for r in edges] + if not selected or len({int(e.GetBody().Tag) for e in selected}) != 1: + raise NXToolError("NX_INVALID_ARGUMENT", "Select edges of one owned body") + target = selected[0].GetBody() + b = self._work_part().Features.CreateChamferBuilder(None) + try: + b.Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4 + b.SmartCollector = self._engineering_collector(selected, "Edge") + b.Option = b.ChamferOption.SymmetricOffsets + b.FirstOffsetExp.RightHandSide = str(offset) + result = b.CommitFeature() + finally: + b.Destroy() + return self._engineering_result(result, [target]) + + def _hole(self, diameter, depth, x, y, z, body=None, direction=None): + import NXOpen.GeometricUtilities + + diameter, depth = finite(diameter, "diameter", True), finite(depth, "depth", True) + location = [finite(v, "location") for v in [x, y, z]] + targets = ( + [self._engineering_owned(body, "body")] if body else list(self._work_part().Bodies) + ) + if len(targets) != 1 or not targets[0].IsSolidBody: + raise NXToolError("NX_AMBIGUOUS_TARGET", "Specify one owned solid body") + axis = unit_normal(direction if direction is not None else [0, 0, 1]) + b = self._work_part().Features.CreateCylinderBuilder(None) + try: + b.Origin = self.nxopen.Point3d(*location) + b.Direction = self.nxopen.Vector3d(*axis) + b.Diameter.RightHandSide = str(diameter) + b.Height.RightHandSide = str(depth) + b.BooleanOption.Type = NXOpen.GeometricUtilities.BooleanOperation.BooleanType.Subtract + b.BooleanOption.SetTargetBodies(targets) + result = b.CommitFeature() + finally: + b.Destroy() + out = self._engineering_result(result, targets) + out.update( + diameter=diameter, + depth=depth, + location=location, + direction=axis, + native_feature="cylindrical subtract", + ) + return out + + def _sweep(self, section, guide, boolean="none", targets=None): + if boolean not in {"none", "unite", "subtract", "intersect"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported boolean operation") + if (boolean == "none") != (targets is None): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Boolean sweep requires explicit targets; none forbids targets", + ) + selected = [self._engineering_owned(t, "body") for t in targets or []] + if boolean != "none" and len(selected) != 1: + raise NXToolError("NX_INVALID_ARGUMENT", "Select exactly one boolean target") + a, b = self._engineering_owned(section, "sketch"), self._engineering_owned(guide, "sketch") + if a == b: + raise NXToolError("NX_INVALID_ARGUMENT", "Section and guide must be different sketches") + builder = self._work_part().Features.CreateSweptBuilder(None) + try: + builder.G0Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4 + builder.G1Tolerance = 0.1 + builder.SectionList.Append(self._engineering_section(a)) + builder.GuideList.Append(self._engineering_section(b)) + feature = builder.CommitFeature() + finally: + builder.Destroy() + out = self._engineering_result(feature) + if boolean != "none": + out = self._boolean(boolean, targets + [r["id"] for r in out["bodies"]]) + return out + + def _mate_component(self, component, mate_type, references=None, offset=0.0): + offset = finite(offset, "offset") + if mate_type not in {"touch", "align", "orient", "center", "align_angle"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported mate type") + if not references or len(references) != 2: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Provide moving and target occurrence face/edge references" + ) + target_geom = self._resolve(references[1], {"face", "edge"}) + if not target_geom.IsOccurrence: + raise NXToolError("NX_INVALID_ARGUMENT", "Target geometry must be an occurrence") + target = self._reference( + target_geom.OwningComponent, "component", self._work_part(), "Component" + )["id"] + kind = { + "touch": "touch", + "align": "touch", + "orient": "parallel", + "center": "concentric", + "align_angle": "angle", + }[mate_type] + if offset and kind not in {"touch", "angle"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Offset applies only to touch/align or align_angle" + ) + if kind == "touch" and offset: + kind = "distance" + return self._assembly_constraint( + kind, + component, + references[0], + target, + references[1], + offset if kind in {"distance", "angle"} else None, + "opposite" if mate_type == "touch" else "same" if mate_type == "align" else "infer", + ) + + def _mirror_body(self, body, plane): + from nx_mcp.hardened import IDENTITY + + matrices = { + "XY": IDENTITY, + "XZ": [[1, 0, 0], [0, 0, -1], [0, 1, 0]], + "YZ": [[0, 0, 1], [1, 0, 0], [0, 1, 0]], + } + if plane not in matrices: + raise NXToolError( + "NX_INVALID_ARGUMENT", "plane must be XY, XZ or YZ through the work-part origin" + ) + target = self._engineering_owned(body, "body") + part = self._work_part() + datum = part.Datums.CreateFixedDatumPlane( + self.nxopen.Point3d(0.0, 0.0, 0.0), self._nx_matrix(matrices[plane]) + ) + b = part.Features.CreateMirrorBodyBuilder(None) + try: + b.MirrorBodyCollector.ReplaceRules( + self._engineering_collector([target], "Body").GetRules(), False + ) + b.Plane.Value = datum + b.DeleteSourceBody = False + result = b.CommitFeature() + finally: + b.Destroy() + return self._engineering_result(result) + + def _drawing_object(self, ref, kind): + if ref.startswith("obj_"): + return self._resolve(ref, {kind}) + pool = ( + self._work_part().DrawingSheets + if kind == "drawing_sheet" + else self._work_part().DraftingViews + ) + matches = [o for o in pool if o.Name.casefold() == ref.casefold()] + if len(matches) != 1: + raise NXToolError( + "NX_NOT_FOUND", "Use a typed reference to an existing drawing sheet/view" + ) + return matches[0] + + def _create_drawing(self, name="Sheet1", size="A3", scale=1.0, units="mm"): + if units not in {"mm", "in"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Drawing units must be mm or in") + dimensions = { + "A0": (1189, 841), + "A1": (841, 594), + "A2": (594, 420), + "A3": (420, 297), + "A4": (297, 210), + } + if ( + size not in dimensions + or not name.strip() + or any(s.Name.casefold() == name.casefold() for s in self._work_part().DrawingSheets) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Choose A0–A4 and a unique nonempty sheet name" + ) + scale = finite(scale, "scale", True) + b = self._work_part().DraftingDrawingSheets.CreateDraftingDrawingSheetBuilder(None) + try: + b.Option = b.SheetOption.CustomSize + b.Units = b.SheetUnits.Metric if units == "mm" else b.SheetUnits.English + b.Length, b.Height = [ + float(v) / (25.4 if units == "in" else 1) for v in dimensions[size] + ] + b.Name = name + b.ScaleNumerator = scale + b.ScaleDenominator = 1.0 + b.ProjectionAngle = b.SheetProjectionAngle.First + sheet = b.Commit() + finally: + b.Destroy() + sheet.Open() + sheet.SetParameters( + sheet.Height, sheet.Length, scale, 1.0, sheet.Units, sheet.ProjectionAngle + ) + actual_scale = list(sheet.GetScale()) + if not math.isclose(actual_scale[0] / actual_scale[1], scale, abs_tol=1e-10): + raise NXToolError("NX_VERIFICATION_FAILED", "Native sheet scale did not persist") + return { + "object": self._reference(sheet, "drawing_sheet", self._work_part(), "Drawing sheet"), + "sheet_name": sheet.Name, + "size": size, + "dimensions_mm": list(dimensions[size]), + "dimensions": [sheet.Length, sheet.Height], + "scale": actual_scale[0] / actual_scale[1], + "scale_ratio": actual_scale, + "projection": "first_angle", + "units": units, + } + + def _configure_base_view(self, builder, sheet): + import NXOpen.Preferences as P + + numerator, denominator = sheet.GetScale() + builder.Scale.ScaleType = builder.Scale.Type.Ratio + builder.Scale.Numerator = numerator + builder.Scale.Denominator = denominator + hidden = builder.Style.ViewStyleHiddenLines + hidden.HiddenLine = True + hidden.Font = P.Font.Dashed + hidden.SelfHidden = True + builder.Style.ViewStyleVisibleLines.VisibleFont = P.Font.Solid + + def _add_base_view(self, drawing, body, view, position=None): + names = { + "top": "Top", + "front": "Front", + "back": "Back", + "right": "Right", + "left": "Left", + "bottom": "Bottom", + "isometric": "Isometric", + } + if view not in names: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported named model view") + target = self._engineering_owned(body, "body") + part = self._work_part() + if len(list(part.Bodies)) != 1 or list(self._walk_components(part)): + raise NXToolError( + "NX_UNSUPPORTED_SCOPE", + "Base-view body selection currently requires a single-body part", + ) + sheet = self._drawing_object(drawing, "drawing_sheet") + point = [100.0, 100.0] if position is None else [finite(v, "position") for v in position] + if len(point) != 2: + raise NXToolError( + "NX_INVALID_ARGUMENT", "position must be two sheet coordinates in sheet units" + ) + sheet.Open() + b = part.DraftingViews.CreateBaseViewBuilder(None) + try: + self._configure_base_view(b, sheet) + b.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view]) + b.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point)) + result = b.Commit() + finally: + b.Destroy() + self._drawing_construction_visibility(result, False) + self._place_drawing_view(result, sheet, point) + return { + "object": self._reference(result, "drawing_view", part, "Base view"), + "view_name": result.Name, + "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"), + "body": self._reference(target, "body", part, "Body"), + "orientation": view, + **self._drawing_coordinates(sheet, point), + } + + def _export_drawing_pdf(self, path): + import hashlib + + file = self.workspace.ensure_inside(path) + if file.suffix.lower() != ".pdf" or file.exists(): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Choose a new .pdf path; existing files are never overwritten", + details={"mutation_outcome": "not_started"}, + ) + from nx_mcp.assembly_loading import require_loaded + + require_loaded(self, self._work_part()) + sheets = list(self._work_part().DrawingSheets) + if not sheets: + raise NXToolError("NX_NO_DRAWING", "Create a drawing sheet before PDF export") + file.parent.mkdir(parents=True, exist_ok=True) + with self._drawing_save_context(self._work_part(), force_display=True): + # Opening each sheet refreshes its display/CGM presentation before plotting. + # PDF export is a non-model operation and has no active modeling undo mark. + views = [view for sheet in sheets for view in sheet.GetDraftingViews()] + if views: + self._work_part().DraftingViews.UpdateViews(views) + for sheet in sheets: + sheet.Open() + b = self._work_part().PlotManager.CreatePrintPdfbuilder() + try: + b.Filename = str(file) + b.Action = b.ActionOption.Native + b.Size = b.SizeOption.FullScale + b.Units = b.UnitsOption.Metric + b.OutputText = b.OutputTextOption.Text + b.RasterImages = True + b.ShadedGeometry = False + b.ImageResolution = b.ImageResolutionOption.High + b.Colors = b.Color.AsDisplayed + b.SourceBuilder.SetSheets(sheets) + b.Commit() + data = file.read_bytes() + if not data.startswith(b"%PDF-"): + raise NXToolError("NX_EXPORT_FAILED", "Native exporter did not produce a PDF") + except Exception: + file.unlink(missing_ok=True) + raise + finally: + b.Destroy() + return { + "path": str(file), + "artifact_path": str(file.relative_to(self.workspace.root)), + "sheet_count": len(sheets), + "sheets": [s.Name for s in sheets], + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "units": "mm", + "scale": "full_sheet_scale", + "output_settings": { + "raster_images": True, + "shaded_as_wireframe": False, + "image_resolution": "high", + "colors": "as_displayed", + "views_updated": True, + }, + "warnings": [], + } + + def _add_projection_view(self, base_view, direction, spacing=60.0): + offsets = {"right": (1, 0), "left": (-1, 0), "top": (0, 1), "bottom": (0, -1)} + if direction not in offsets: + raise NXToolError("NX_INVALID_ARGUMENT", "direction must be right, left, top or bottom") + spacing = finite(spacing, "spacing", True) + view = self._drawing_object(base_view, "drawing_view") + center = view.GetDrawingReferencePoint() + dx, dy = offsets[direction] + sheet = self._view_sheet(view) + target = [center.X + dx * spacing, center.Y + dy * spacing] + point = self._sheet_point3d(sheet, target) + b = self._work_part().DraftingViews.CreateProjectedViewBuilder(None) + try: + b.Parent.View.Value = view + b.Placement.AlignmentMethod = ( + b.Placement.Method.Horizontal if dx else b.Placement.Method.Vertical + ) + b.Placement.AlignmentOption = b.Placement.Option.ToView + b.Placement.AlignmentView.Value = view + b.Placement.Associative = True + b.Placement.Placement.SetValue(None, None, point) + result = b.Commit() + finally: + b.Destroy() + self._place_drawing_view(result, sheet, target, axes=(0,) if dx else (1,)) + return { + "object": self._reference(result, "drawing_view", self._work_part(), "Projected view"), + "view_name": result.Name, + "base_view": base_view, + **self._drawing_coordinates( + sheet, [result.GetDrawingReferencePoint().X, result.GetDrawingReferencePoint().Y] + ), + "alignment": "native associative alignment; the constrained reference coordinate can differ from the parent", + "direction": direction, + "spacing": spacing, + "spacing_mm": spacing + * (25.4 if self._sheet_units(self._view_sheet(view)) == "in" else 1), + "sheet_units": self._sheet_units(self._view_sheet(view)), + } + + def _add_dimension( + self, view, object1, object2=None, dim_type="aligned", origin=None, dimension=None + ): + methods = {"aligned": "PointToPoint", "horizontal": "Horizontal", "vertical": "Vertical"} + if dim_type not in methods: + raise NXToolError( + "NX_UNSUPPORTED_ARGUMENT", "Use aligned, horizontal or vertical linear dimensions" + ) + drawing_view = self._drawing_object(view, "drawing_view") + a = self._drawing_dimension_edge(object1) + b = self._drawing_dimension_edge(object2) if object2 else a + pa = a.GetVertices()[0] + pb = b.GetVertices()[1] if object2 is None else b.GetVertices()[0] + point = [100.0, 80.0] if origin is None else [finite(v, "origin") for v in origin] + if len(point) != 2: + raise NXToolError("NX_INVALID_ARGUMENT", "origin must be [x,y] in sheet units") + existing = self._engineering_owned(dimension, "dimension") if dimension else None + builder = self._work_part().Dimensions.CreateLinearDimensionBuilder(existing) + try: + snap = self.nxopen.InferSnapType.SnapType + empty = self.nxopen.Point3d(0.0, 0.0, 0.0) + builder.FirstAssociativity.SetValue(snap.Start, a, drawing_view, pa, None, None, empty) + builder.SecondAssociativity.SetValue( + snap.End if object2 is None else snap.Start, b, drawing_view, pb, None, None, empty + ) + builder.Measurement.Method = getattr( + builder.Measurement.MeasurementMethod, methods[dim_type] + ) + builder.Origin.OriginPoint = self._sheet_point3d(self._view_sheet(drawing_view), point) + result = builder.Commit() + finally: + builder.Destroy() + return { + "object": self._reference(result, "dimension", self._work_part(), "Drawing dimension"), + "dimension_name": result.Name, + "view": view, + "dim_type": dim_type, + "measured_value": result.ComputedSize, + **self._drawing_coordinates(self._view_sheet(drawing_view), point, "origin"), + "units": self._units(), + "association": "edge start/end" if object2 is None else "edge start points", + "edited": dimension is not None, + } diff --git a/src/nx_mcp/experimental.py b/src/nx_mcp/experimental.py index f0f5100..adf8224 100644 --- a/src/nx_mcp/experimental.py +++ b/src/nx_mcp/experimental.py @@ -2,17 +2,16 @@ from __future__ import annotations -import asyncio import importlib import inspect import json import sys -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.exceptions import ToolError as MCPToolError +if TYPE_CHECKING: + from mcp.server.fastmcp import FastMCP from nx_mcp.response import ToolError, ToolResult from nx_mcp.runtime import NXToolError @@ -41,8 +40,8 @@ } -def load_legacy_handlers() -> dict[str, Callable[..., Awaitable[Any]]]: - handlers: dict[str, Callable[..., Awaitable[Any]]] = {} +def load_legacy_handlers() -> dict[str, Callable[..., Coroutine[Any, Any, Any]]]: + handlers: dict[str, Callable[..., Coroutine[Any, Any, Any]]] = {} registry_snapshot = dict(ToolRegistry._tools) newly_loaded: list[str] = [] try: @@ -61,12 +60,6 @@ def load_legacy_handlers() -> dict[str, Callable[..., Awaitable[Any]]]: return handlers -async def _run_legacy_handler( - handler: Callable[..., Awaitable[Any]], params: dict[str, Any] -) -> Any: - return await handler(**params) - - def _secure_params( method: str, params: dict[str, Any], @@ -110,6 +103,8 @@ def add_experimental_tools( *, enable_journal: bool, ) -> None: + from mcp.server.fastmcp.exceptions import ToolError as MCPToolError + for name, handler in load_legacy_handlers().items(): if name in certified_names or (name in JOURNAL_TOOL_NAMES and not enable_journal): continue @@ -158,7 +153,17 @@ def execute_legacy( if handler is None: raise NXToolError("NX_TOOL_NOT_FOUND", f"Unsupported bridge command: {method}") secured = _secure_params(method, params, workspace, already_resolved=True) - result: Any = asyncio.run(_run_legacy_handler(handler, secured)) + coroutine = handler(**secured) + try: + coroutine.send(None) + except StopIteration as completed: + result: Any = completed.value + else: + coroutine.close() + raise NXToolError( + "NX_EXPERIMENTAL_ASYNC_UNSUPPORTED", + "Experimental NX handlers must complete without suspending.", + ) if isinstance(result, ToolError): raise NXToolError( result.error_code, diff --git a/src/nx_mcp/exploded_views.py b/src/nx_mcp/exploded_views.py new file mode 100644 index 0000000..fadf699 --- /dev/null +++ b/src/nx_mcp/exploded_views.py @@ -0,0 +1,481 @@ +"""Native named explosions and associative assembly drawing views on NX 2606.""" + +from __future__ import annotations + +from contextlib import contextmanager + +from nx_mcp.authoring import finite, page +from nx_mcp.engineering import EngineeringMixin +from nx_mcp.runtime import NXToolError + + +class ExplodedViewsMixin: + @contextmanager + def _drawing_save_context(self, part, force_display=False): + """Display native sheets for CGM-preserving saves, then restore the view.""" + sheets = list(getattr(part, "DrawingSheets", [])) + if not sheets or (not part.SaveOptions.DrawingCgmData and not force_display): + yield + return + work, display = self.session.Parts.Work, self.session.Parts.Display + if self.session.ActiveSketch: + raise NXToolError( + "NX_SKETCH_ACTIVE", "Finish the sketch before saving drawing preview data" + ) + original_sheet = part.DrawingSheets.CurrentDrawingSheet + changed_part = work != part or display != part + try: + if changed_part: + self._activate_part(self._reference(part, "part", part, "Part")["id"], True, True) + (original_sheet or sheets[0]).Open() + yield + finally: + try: + if original_sheet is None: + part.Drafting.ExitDraftingApplication() + elif part.DrawingSheets.CurrentDrawingSheet != original_sheet: + original_sheet.Open() + if changed_part: + if display: + self._activate_part( + self._reference(display, "part", display, "Part")["id"], False, True + ) + if work: + self._activate_part( + self._reference(work, "part", work, "Part")["id"], True, False + ) + except Exception as error: + raise NXToolError( + "NX_SAVE_VIEW_RESTORE_FAILED", + "Save presentation could not be restored", + details={"mutation_outcome": "partial", "restore_error": str(error)}, + ) from error + + def _save_component_drawing_previews(self, part): + seen = set() + for component, _ in reversed(self._walk_components(part)): + prototype = component.Prototype + if int(prototype.Tag) in seen: + continue + seen.add(int(prototype.Tag)) + if prototype.IsModified and list(getattr(prototype, "DrawingSheets", [])): + with self._drawing_save_context(prototype): + status = prototype.Save( + self.nxopen.BasePart.SaveComponents.FalseValue, + self.nxopen.BasePart.CloseAfterSave.FalseValue, + ) + if status: + status.Dispose() + + @staticmethod + def _modeling_views(part): + views = getattr(part, "ModelingViews", []) + return list(views) if hasattr(views, "__iter__") else [] + + def _explosions(self, part): + return list(getattr(part.ComponentAssembly, "Explosions", [])) + + def _explosion(self, reference): + part = self._work_part() + explosion = self._resolve(reference, {"explosion"}) + if explosion.OwningPart != part: + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Explosion must belong to the work part") + return explosion + + def _explosion_context(self): + part = self._work_part() + if self.session.Parts.Display != part: + raise NXToolError("NX_PART_CONTEXT", "Activate the same work and display part") + if self.session.ActiveSketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the active sketch first") + return part + + @staticmethod + def _explosion_uf(): + import NXOpen.UF + + return NXOpen.UF.UFSession.GetUFSession().Assem + + @staticmethod + def _exploded_tree(explosion): + result = {} + + def walk(parent, path, suppressed=False): + for child in parent.GetChildren(): + component = child.GetComponent() + child_path = path + [component.Name] + hidden = suppressed or bool(component.IsSuppressed) + result[int(component.Tag)] = (child, component, child_path, hidden) + walk(child, child_path, hidden) + + walk(explosion.RootComponent, []) + return result + + def _explosion_views(self, explosion): + uf = self._explosion_uf() + part = self._work_part() + result = [] + for kind, views in [ + ("modeling", self._modeling_views(part)), + ("drawing", part.DraftingViews), + ]: + for view in views: + if int(uf.AskViewExplosion(view.Tag) or 0) == int(explosion.Tag): + result.append( + { + "name": view.Name, + "kind": kind, + "object": self._reference( + view, + "drawing_view" if kind == "drawing" else "modeling_view", + part, + "View", + ), + } + ) + return result + + def _explosion_record(self, explosion): + return { + "object": self._reference(explosion, "explosion", self._work_part(), "Explosion"), + "name": explosion.Name, + "views": self._explosion_views(explosion), + } + + def _exploded_record(self, entry): + from nx_mcp.hardened import rows, xyz + + child, component, path, suppressed = entry + position, rotation = child.GetPosition() + assembled_position, assembled_rotation = component.GetPosition() + return { + "component": self._reference(component, "component", self._work_part(), "Component"), + "occurrence_path": path, + "translation": xyz(position), + "rotation_matrix": rows(rotation), + "assembled_translation": xyz(assembled_position), + "assembled_rotation_matrix": rows(assembled_rotation), + "suppressed": suppressed, + } + + def _list_explosions(self): + return { + "explosions": [self._explosion_record(x) for x in self._explosions(self._work_part())], + "coordinate_frame": "assembly", + "units": self._units(), + } + + def _explosion_info(self, explosion, offset=0, limit=50): + ex = self._explosion(explosion) + values = [self._exploded_record(e) for e in self._exploded_tree(ex).values()] + return { + **self._explosion_record(ex), + **page(values, offset, limit), + "coordinate_frame": "assembly", + "units": self._units(), + "matrix_convention": "row-major, p_assembly = R * p_component + translation", + "geometry_measurements": "ordinary body, clearance and mass tools use assembled geometry", + } + + def _create_explosion(self, name): + part = self._explosion_context() + if ( + not isinstance(name, str) + or not name.strip() + or name != name.strip() + or len(name) > 132 + or any(ord(c) < 32 or c in "/\\" for c in name) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use a nonempty NX name, at most 132 characters" + ) + collection = part.ComponentAssembly.Explosions + self._require_api(collection, "Create") + if any(x.Name.casefold() == name.casefold() for x in collection): + raise NXToolError("NX_NAME_CONFLICT", "Explosion name already exists; use its typed ID") + if not self._walk_components(part): + raise NXToolError("NX_NO_COMPONENTS", "Create an explosion in an assembly part") + ex = collection.Create(name) + return {**self._explosion_record(ex), "component_count": len(self._exploded_tree(ex))} + + def _edit_explosion(self, explosion, placements=None, reset_components=None): + from nx_mcp.hardened import matmul, matvec, rows, transpose, vector, xyz + + self._explosion_context() + ex = self._explosion(explosion) + uf = self._explosion_uf() + self._require_api(uf, "UnexplodeComponent", "ExplodeComponent") + placements = [] if placements is None else placements + resets = [] if reset_components is None else reset_components + if ( + not isinstance(placements, list) + or not isinstance(resets, list) + or not 1 <= len(placements) + len(resets) <= 1000 + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Provide 1–1000 placements/resets") + tree = self._exploded_tree(ex) + selected = set() + + def resolve(reference): + if not isinstance(reference, str): + raise NXToolError("NX_INVALID_ARGUMENT", "Component references must be strings") + component = self._resolve(reference, {"component"}) + tag = int(component.Tag) + if tag not in tree or tree[tag][3]: + raise NXToolError( + "NX_UNSUPPORTED_SCOPE", "Select an unsuppressed occurrence in this explosion" + ) + if tag in selected: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Each component may appear only once per request" + ) + selected.add(tag) + return tag + + reset_tags = [resolve(r) for r in resets] + pending = [] + for item in placements: + if ( + not isinstance(item, dict) + or set(item) - {"component", "translation", "rotation_matrix"} + or not {"component", "translation"} <= set(item) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Placement requires component and translation; optional rotation_matrix only", + ) + tag = resolve(item["component"]) + raw_position = item["translation"] + raw_rotation = item.get("rotation_matrix") + if ( + not isinstance(raw_position, list) + or len(raw_position) != 3 + or any(type(v) not in (int, float) for v in raw_position) + or ( + "rotation_matrix" in item + and ( + not isinstance(raw_rotation, list) + or len(raw_rotation) != 3 + or any( + not isinstance(row, list) + or len(row) != 3 + or any(type(v) not in (int, float) for v in row) + for row in raw_rotation + ) + ) + ) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Use numeric 3-vectors and 3x3 matrices") + position = vector(raw_position, "translation") + rotation = ( + self._validate_rotation(item["rotation_matrix"]) + if "rotation_matrix" in item + else rows(tree[tag][0].GetPosition()[1]) + ) + pending.append((tag, position, rotation)) + before = {tag: self._exploded_record(entry) for tag, entry in tree.items()} + for tag in sorted(reset_tags, key=lambda t: len(tree[t][2])): + uf.UnexplodeComponent(ex.Tag, tree[tag][1].Tag) + for tag, position, rotation in sorted(pending, key=lambda p: len(tree[p[0]][2])): + component = tree[tag][1] + # NX stores a component-local post-transform. Reset before measuring + # the inherited parent pose so assigning an absolute pose is repeatable. + uf.UnexplodeComponent(ex.Tag, component.Tag) + child = self._exploded_tree(ex)[tag][0] + base_position, base_rotation = child.GetPosition() + inverse = transpose(rows(base_rotation)) + delta_rotation = matmul(inverse, rotation) + delta_position = matvec( + inverse, [position[i] - xyz(base_position)[i] for i in range(3)] + ) + matrix = [delta_rotation[i] + [delta_position[i]] for i in range(3)] + [ + [0.0, 0.0, 0.0, 1.0] + ] + uf.ExplodeComponent(ex.Tag, component.Tag, matrix) + after = { + tag: self._exploded_record(entry) for tag, entry in self._exploded_tree(ex).items() + } + for tag, position, rotation in pending: + actual = after[tag] + if any( + abs(a - b) > 1e-7 for a, b in zip(actual["translation"], position, strict=True) + ) or any( + abs(actual["rotation_matrix"][i][j] - rotation[i][j]) > 1e-7 + for i in range(3) + for j in range(3) + ): + raise NXToolError( + "NX_EXPLOSION_POSITION_MISMATCH", + "Native exploded pose differs from the requested absolute pose", + details={"actual": actual}, + ) + if set(before) != set(after) or any( + before[t][k] != after[t][k] + for t in before + for k in ("assembled_translation", "assembled_rotation_matrix") + ): + raise NXToolError( + "NX_EXPLOSION_ASSEMBLY_CHANGED", + "Explosion unexpectedly changed assembled placements", + ) + self._refresh_explosion_traces(ex) + self._regenerate_explosion_display() + views = [ + v + for v in self._work_part().DraftingViews + if int(uf.AskViewExplosion(v.Tag) or 0) == int(ex.Tag) + ] + if views: + self._work_part().DraftingViews.UpdateViews(views) + affected = sum( + before[t]["translation"] != after[t]["translation"] + or before[t]["rotation_matrix"] != after[t]["rotation_matrix"] + for t in before + ) + return { + **self._explosion_record(ex), + "components": [after[t] for t in sorted(selected)], + "affected_component_count": affected, + "assembled_placements_unchanged": True, + "updated_drawing_views": [v.Name for v in views], + "coordinate_frame": "assembly", + "units": self._units(), + "modified": [self._reference(ex, "explosion", self._work_part(), "Explosion")], + } + + def _regenerate_explosion_display(self): + if not self.session.IsBatch: + import NXOpen.UF as U + + display = U.UFSession.GetUFSession().Disp + self._require_api(display, "RegenerateDisplay") + display.RegenerateDisplay() + + def _show_explosion(self, explosion=None, drawing_view=None, model_view=None): + if drawing_view and model_view: + raise NXToolError("NX_INVALID_ARGUMENT", "Select drawing_view or model_view, not both") + part = self._explosion_context() + ex = self._explosion(explosion) if explosion is not None else None + uf = self._explosion_uf() + self._require_api(uf, "SetViewExplosion", "AskViewExplosion") + if drawing_view: + view = self._drawing_object(drawing_view, "drawing_view") + if view.OwningPart != part: + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Drawing view must belong to work part" + ) + elif model_view: + view = self._resolve(model_view, {"modeling_view"}) + if view.OwningPart != part: + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Model view must belong to work part") + else: + if list(part.DrawingSheets): + part.Drafting.ExitDraftingApplication() + view = part.ModelingViews.WorkView + if ex is not None: + self._refresh_explosion_traces(ex) + uf.SetViewExplosion(view.Tag, ex.Tag if ex else 0) + self._regenerate_explosion_display() + if drawing_view: + part.DraftingViews.UpdateViews([view]) + elif model_view is None: + view.Fit() + if int(uf.AskViewExplosion(view.Tag) or 0) != (int(ex.Tag) if ex else 0): + raise NXToolError( + "NX_EXPLOSION_VIEW_MISMATCH", "Native view did not accept the explosion" + ) + return { + "explosion": self._reference(ex, "explosion", part, "Explosion") if ex else None, + "view_name": view.Name, + "view_kind": "drawing" if drawing_view else "modeling", + "assembled_placements_unchanged": True, + } + + def _delete_explosion(self, explosion): + self._explosion_context() + ex = self._explosion(explosion) + views = self._explosion_views(ex) + if views: + raise NXToolError( + "NX_EXPLOSION_IN_USE", + "Detach this explosion from its views before deletion", + details={"views": views}, + ) + ref = self._reference(ex, "explosion", self._work_part(), "Explosion") + ex.Delete() + return {"deleted": [ref]} + + def _add_base_view( + self, drawing, body=None, view="isometric", position=None, scope="body", explosion=None + ): + if scope == "body": + if body is None or explosion is not None: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Body scope requires body and forbids explosion" + ) + return EngineeringMixin._add_base_view(self, drawing, body, view, position) + if scope != "assembly" or body is not None: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use scope=assembly without body for an assembly view" + ) + part = self._explosion_context() + ex = self._explosion(explosion) if explosion is not None else None + if not self._walk_components(part): + raise NXToolError("NX_NO_COMPONENTS", "Assembly view requires component geometry") + names = { + n: n.title() for n in ("top", "front", "back", "right", "left", "bottom", "isometric") + } + if view not in names: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported model view") + point = [100.0, 100.0] if position is None else [finite(v, "position") for v in position] + if len(point) != 2: + raise NXToolError( + "NX_INVALID_ARGUMENT", "position must contain two sheet coordinates in sheet units" + ) + sheet = self._drawing_object(drawing, "drawing_sheet") + if sheet.OwningPart != part: + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Drawing sheet must belong to work part") + uf = self._explosion_uf() + self._require_api(uf, "SetViewExplosion", "AskViewExplosion") + sheet.Open() + builder = part.DraftingViews.CreateBaseViewBuilder(None) + try: + self._configure_base_view(builder, sheet) + builder.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view]) + builder.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point)) + result = builder.Commit() + finally: + builder.Destroy() + self._drawing_construction_visibility(result, False) + self._place_drawing_view(result, sheet, point) + uf.SetViewExplosion(result.Tag, ex.Tag if ex else 0) + part.DraftingViews.UpdateViews([result]) + if int(uf.AskViewExplosion(result.Tag) or 0) != (int(ex.Tag) if ex else 0): + raise NXToolError( + "NX_EXPLOSION_VIEW_MISMATCH", "Drawing does not reference requested explosion" + ) + return { + "object": self._reference(result, "drawing_view", part, "Assembly base view"), + "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"), + "scope": "assembly", + "orientation": view, + **self._drawing_coordinates(sheet, point), + "explosion": self._reference(ex, "explosion", part, "Explosion") if ex else None, + } + + def _add_projection_view(self, base_view, direction, spacing=60.0): + if not self._explosions(self._work_part()): + return EngineeringMixin._add_projection_view(self, base_view, direction, spacing) + parent = self._drawing_object(base_view, "drawing_view") + uf = self._explosion_uf() + tag = uf.AskViewExplosion(parent.Tag) or 0 + result = EngineeringMixin._add_projection_view(self, base_view, direction, spacing) + view = self._resolve(result["object"]["id"], {"drawing_view"}) + uf.SetViewExplosion(view.Tag, tag) + self._work_part().DraftingViews.UpdateViews([view]) + if int(uf.AskViewExplosion(view.Tag) or 0) != int(tag): + raise NXToolError( + "NX_EXPLOSION_VIEW_MISMATCH", "Projected view did not retain its parent's explosion" + ) + result["exploded"] = bool(tag) + return result diff --git a/src/nx_mcp/freeform.py b/src/nx_mcp/freeform.py new file mode 100644 index 0000000..728b2cb --- /dev/null +++ b/src/nx_mcp/freeform.py @@ -0,0 +1,468 @@ +"""Freeform and direct-face features using NX's associative native builders.""" + +from __future__ import annotations + +from nx_mcp.authoring import finite +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import unit_normal + + +def points3(values, minimum=1, maximum=1000): + if not isinstance(values, list) or not minimum <= len(values) <= maximum: + raise NXToolError("NX_INVALID_ARGUMENT", f"Expected {minimum}..{maximum} points") + result = [] + for value in values: + if not isinstance(value, list) or len(value) != 3: + raise NXToolError("NX_INVALID_ARGUMENT", "Each point must contain x, y, z") + result.append([finite(v, "coordinate") for v in value]) + return result + + +class FreeformMixin: + def _freeform_refs(self, refs, kind): + if not isinstance(refs, list) or not 1 <= len(refs) <= 1000 or len(set(refs)) != len(refs): + raise NXToolError("NX_INVALID_ARGUMENT", "Select 1..1000 distinct objects") + return [self._engineering_owned(r, kind) for r in refs] + + def _freeform_builder(self, name, feature=None): + part = self._work_part() + self._require_api(part.Features, name) + factory = getattr(part.Features, name) + obj = self._engineering_owned(feature, "feature") if feature else None + return factory(obj) + + def _freeform_commit(self, builder): + if not builder.Validate(): + raise NXToolError("NX_INVALID_GEOMETRY", "NX rejected the feature inputs") + feature = builder.CommitFeature() + result = self._engineering_result(feature) + result["feature_type"] = feature.FeatureType + return result + + def _spline(self, points, degree=3, method="through_points", periodic=False, feature=None): + import NXOpen.Features as F + + values = points3(points, 2) + if isinstance(degree, bool) or not isinstance(degree, int) or not 1 <= degree <= 7: + raise NXToolError("NX_INVALID_ARGUMENT", "degree must be an integer from 1 to 7") + if len(values) <= degree or method not in {"through_points", "poles"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Need more points than degree and a supported method" + ) + b = self._freeform_builder("CreateStudioSplineBuilderEx", feature) + try: + b.IsAssociative = True + b.HasPlaneConstraint = False + b.Degree = degree + b.IsPeriodic = periodic + b.Type = getattr( + F.StudioSplineBuilderEx.Types, + "ThroughPoints" if method == "through_points" else "ByPoles", + ) + manager = b.ConstraintManager + manager.Clear() + for coords in values: + c = manager.CreateGeometricConstraintData() + c.Point = self._work_part().Points.CreatePoint(self.nxopen.Point3d(*coords)) + manager.Append(c) + result = self._freeform_commit(b) + spline = b.Curve + result["curve"] = self._reference(spline, "curve", self._work_part(), "Spline") + result["degree"] = spline.Order - 1 + result["periodic"] = spline.Periodic + return result + finally: + b.Destroy() + + def _curve_section(self, refs): + part = self._work_part() + objects = self._freeform_refs(refs, "curve") + section = part.Sections.CreateSection(0.001, 0.001, 0.5) + section.SetAllowedEntityTypes(self.nxopen.Section.AllowTypes.OnlyCurves) + rule = part.ScRuleFactory.CreateRuleCurveDumb(objects) + section.AddToSection( + [rule], + objects[0], + None, + None, + self.nxopen.Point3d(0.0, 0.0, 0.0), + self.nxopen.Section.Mode.Create, + False, + ) + return section + + def _surface_mesh(self, primary, cross, tolerance=0.001): + import NXOpen.Features as F + + tolerance = finite(tolerance, "tolerance", True) + for sections in (primary, cross): + if not isinstance(sections, list) or not 2 <= len(sections) <= 100: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Need 2..100 sections in each mesh direction" + ) + for refs in sections: + self._freeform_refs(refs, "curve") + b = self._freeform_builder("CreateThroughCurveMeshBuilder") + try: + b.BodyPreference = F.ThroughCurveMeshBuilder.BodyPreferenceTypes.Sheet + b.PositionTolerance = tolerance + b.IntersectionTolerance = tolerance + for items, target in [(primary, b.PrimaryCurvesList), (cross, b.CrossCurvesList)]: + for refs in items: + target.Append(self._curve_section(refs)) + return self._freeform_commit(b) + finally: + b.Destroy() + + def _bridge_surface(self, first, second, continuity="G0", reverse_second=False): + import NXOpen.Features as F + import NXOpen.GeometricUtilities as G + + if continuity not in {"G0", "G1", "G2"}: + raise NXToolError("NX_INVALID_ARGUMENT", "continuity must be G0, G1, or G2") + edges = [self._engineering_owned(r, "edge") for r in (first, second)] + b = self._freeform_builder("CreateBridgeSurfaceBuilder") + try: + b.FirstEndObjectType = F.BridgeSurfaceBuilder.EndObjectType.Edge + b.SecondEndObjectType = F.BridgeSurfaceBuilder.EndObjectType.Edge + b.FirstEdgeSelection.Value = edges[0] + b.SecondEdgeSelection.Value = edges[1] + b.FirstEdgeContinuity.ContinuityType = getattr(G.Continuity.ContinuityTypes, continuity) + b.SecondEdgeContinuity.ContinuityType = getattr( + G.Continuity.ContinuityTypes, continuity + ) + b.IsSecondEdgeReversed = reverse_second + b.IsFirstEdgeLimitEndToEnd = True + b.IsSecondEdgeLimitEndToEnd = True + return self._freeform_commit(b) + finally: + b.Destroy() + + def _sew(self, target, tools, tolerance=0.001, solid=False): + import NXOpen.Features as F + + targets = self._freeform_refs([target], "body") + others = self._freeform_refs(tools, "body") + if target in tools: + raise NXToolError("NX_INVALID_ARGUMENT", "Target cannot also be a tool") + tolerance = finite(tolerance, "tolerance", True) + b = self._freeform_builder("CreateSewBuilder") + try: + b.Type = F.SewBuilder.Types.Sheet + b.BodyPreference = getattr( + F.SewBuilder.BodyPreferenceTypes, "Solid" if solid else "Sheet" + ) + b.Tolerance = tolerance + for collector, bodies in [ + (b.TargetBodiesCollector, targets), + (b.ToolBodiesCollector, others), + ]: + collector.ReplaceRules( + [self._work_part().ScRuleFactory.CreateRuleBodyDumb(bodies)], False + ) + result = self._freeform_commit(b) + unsewn = b.GetUnsewnBodies() + if unsewn: + raise NXToolError( + "NX_INCOMPLETE_SEW", + "Some input bodies could not be sewn; operation rolled back", + ) + if solid and any( + not self._resolve(x["id"], {"body"}).IsSolidBody for x in result["bodies"] + ): + raise NXToolError( + "NX_INCOMPLETE_SEW", "NX produced a sheet instead of the requested solid" + ) + return result + finally: + b.Destroy() + + def _thicken(self, faces, first_offset, second_offset=0.0): + objects = self._freeform_refs(faces, "face") + first = finite(first_offset, "first_offset") + second = finite(second_offset, "second_offset") + if first == second: + raise NXToolError("NX_INVALID_ARGUMENT", "Offsets must differ") + b = self._freeform_builder("CreateThickenBuilder") + try: + b.FaceCollector.ReplaceRules( + [self._work_part().ScRuleFactory.CreateRuleFaceDumb(objects)], False + ) + b.Tolerance = 0.001 + b.FirstOffset.RightHandSide = str(first) + b.SecondOffset.RightHandSide = str(second) + return self._freeform_commit(b) + finally: + b.Destroy() + + def _edit_faces(self, faces, action, distance=None, direction=None, replacement=None): + import NXOpen.Features as F + + objects = self._freeform_refs(faces, "face") + factories = { + "move": "CreateAdmMoveFaceBuilder", + "offset": "CreateOffsetFaceBuilder", + "replace": "CreateReplaceFaceBuilder", + "heal": "CreateDeleteFaceBuilder", + } + if action not in factories: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported face edit") + if ( + (distance is not None) != (action in {"move", "offset"}) + or (direction is not None) != (action == "move") + or (replacement is not None) != (action == "replace") + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "move requires distance/direction; offset distance; replace replacement; heal no extra parameters", + ) + if distance is not None: + distance = finite(distance, "distance") + if direction is not None: + direction = unit_normal(direction) + replacing = self._engineering_owned(replacement, "face") if replacement else None + b = self._freeform_builder(factories[action]) + try: + rule = self._work_part().ScRuleFactory.CreateRuleFaceDumb(objects) + if action == "move": + import NXOpen.GeometricUtilities as G + + b.FaceToMove.FaceCollector.ReplaceRules([rule], False) + b.Motion.Option = G.ModlMotion.Options.Distance + b.Motion.DistanceVector = self._engineering_direction(direction) + b.Motion.DistanceValue.RightHandSide = str(distance) + elif action == "offset": + b.FaceCollector.ReplaceRules([rule], False) + b.Distance.RightHandSide = str(abs(distance)) + b.Direction = distance < 0 + elif action == "replace": + b.ReplaceFaces.ReplaceRules([rule], False) + b.ReplacementFaces.ReplaceRules( + [self._work_part().ScRuleFactory.CreateRuleFaceDumb([replacing])], False + ) + else: + b.Type = F.DeleteFaceBuilder.SelectTypes.Face + b.FaceCollector.ReplaceRules([rule], False) + b.Heal = True + b.AllowPartialDelete = False + result = self._freeform_commit(b) + health = self._model_health() + if not health["healthy"]: + raise NXToolError( + "NX_INVALID_GEOMETRY", + "Face edit left native geometry errors", + details={"health": health}, + ) + result["repair_diagnostics"] = { + "action": action, + "source_faces": faces, + "health": health, + } + return result + except Exception as error: + if isinstance(error, NXToolError): + error.details.update(action=action, source_faces=faces) + raise + raise NXToolError( + "NX_FACE_EDIT_FAILED", + str(error), + nx_code=getattr(error, "ErrorCode", None), + details={"action": action, "source_faces": faces}, + suggestion="Inspect native diagnostics and explicitly refine the face selection; reacquire IDs after rollback.", + ) from error + finally: + b.Destroy() + + def _curve_analysis(self, curve, samples=21): + import math + + import NXOpen.UF as U + + from nx_mcp.hardened import cross, dot + + if type(samples) is not int or not 2 <= samples <= 1000: + raise NXToolError("NX_INVALID_ARGUMENT", "samples must be 2..1000") + obj = self._resolve(curve, {"curve"}) + if obj.IsOccurrence or obj.OwningPart != self._work_part(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select owned work-part geometry") + uf = U.UFSession.GetUFSession() + values = [] + for i in range(samples): + t = i / (samples - 1) + data = uf.ModlGeneral.EvaluateCurve(obj.Tag, t, 2) + point, first, second = data[:3], data[3:6], data[6:9] + speed = math.sqrt(dot(first, first)) + if speed <= 1e-12: + values.append( + { + "parameter": t, + "point": point, + "singular": True, + "curvature": None, + "tangent": None, + } + ) + continue + product = cross(first, second) + curvature = math.sqrt(dot(product, product)) / speed**3 + values.append( + { + "parameter": t, + "point": point, + "singular": False, + "curvature": curvature, + "radius": 1 / curvature if curvature > 1e-12 else None, + "tangent": [v / speed for v in first], + } + ) + result = { + "curve": self._reference( + obj, + "curve", + self._work_part(), + "Curve", + ), + "samples": values, + "sample_count": samples, + "parameterization": "normalized_native_parameter_0_to_1", + "units": self._units(), + "curvature_units": "1/" + self._units(), + "coordinate_frame": "work_part", + "method": "native first and second derivatives; sampled, not global extrema", + } + if hasattr(obj, "Get3DPoles"): + from nx_mcp.hardened import xyz + + result["spline"] = { + "degree": obj.Order - 1, + "periodic": obj.Periodic, + "knots": list(obj.GetKnots()), + "poles": [xyz(p) for p in obj.Get3DPoles()], + } + return result + + def _surface_continuity( + self, + first, + second, + position_tolerance=0.001, + angle_tolerance=0.1, + curvature_tolerance=0.01, + samples=21, + ): + import math + + import NXOpen.UF as U + + from nx_mcp.surface_math import continuity_difference, shape_operator + + if type(samples) is not int or not 2 <= samples <= 200: + raise NXToolError("NX_INVALID_ARGUMENT", "samples must be 2..200 per edge") + edges = [self._engineering_owned(r, "edge") for r in (first, second)] + adjacent = [list(edge.GetFaces()) for edge in edges] + if any(len(faces) != 1 for faces in adjacent): + raise NXToolError( + "NX_AMBIGUOUS_GEOMETRY", "Select boundary edges with exactly one adjacent face" + ) + tolerances = [ + finite(v, "tolerance", True) + for v in [position_tolerance, angle_tolerance, curvature_tolerance] + ] + uf = U.UFSession.GetUFSession() + self._require_api(U.UFConstants, "UF_MODL_EVAL_DERIV2") + reports = [] + for side in range(2): + source, target = edges[side], edges[1 - side] + face_a, face_b = adjacent[side][0], adjacent[1 - side][0] + evaluator = uf.Eval.Initialize2(source.Tag) + limits = uf.Eval.AskLimits(evaluator) + for i in range(samples): + t = i / (samples - 1) + point = uf.Eval.EvaluateUnitVectors( + evaluator, limits[0] + t * (limits[1] - limits[0]) + )[0] + distance, _, closest, _ = uf.Modeling.AskMinimumDist3( + 2, 0, target.Tag, 1, point, 0, [0.0] * 3 + ) + record = { + "source_edge": side, + "parameter": t, + "point": list(point), + "closest_point": list(closest), + "gap": distance, + } + try: + operators = [] + for face, coords in [(face_a, point), (face_b, closest)]: + uv, _ = uf.Modeling.AskFaceParm(face.Tag, coords) + data = uf.Modeling.EvaluateFace( + face.Tag, U.UFConstants.UF_MODL_EVAL_DERIV2, uv + ) + operators.append( + shape_operator( + data.SrfDu, data.SrfDv, data.SrfD2u, data.SrfDudv, data.SrfD2v + ) + ) + angle, curvature = continuity_difference(*operators) + if not all(math.isfinite(v) for v in [distance, angle, curvature]): + raise NXToolError("NX_SINGULAR_SURFACE", "Nonfinite differential geometry") + record.update( + { + "normal_angle_degrees": angle, + "curvature_difference": curvature, + "status": "measured", + } + ) + except NXToolError as error: + record.update({"status": "unresolved", "reason": str(error)}) + reports.append(record) + valid = [r for r in reports if r["status"] == "measured"] + max_gap = max(r["gap"] for r in reports) + max_angle = max((r["normal_angle_degrees"] for r in valid), default=None) + max_curvature = max((r["curvature_difference"] for r in valid), default=None) + g0 = max_gap <= tolerances[0] + complete = len(valid) == len(reports) + g1 = g0 and max_angle <= tolerances[1] if complete else None + g2 = g1 and max_curvature <= tolerances[2] if complete else None + return { + "checks": {"G0": g0, "G1": g1, "G2": g2}, + "maximum_gap": max_gap, + "maximum_normal_angle_degrees": max_angle, + "maximum_curvature_difference": max_curvature, + "samples": reports, + "sample_count": len(reports), + "unresolved_count": len(reports) - len(valid), + "tolerances": { + "position": tolerances[0], + "angle_degrees": tolerances[1], + "curvature": tolerances[2], + }, + "units": self._units(), + "curvature_units": "1/" + self._units(), + "method": "Bidirectional normalized-parameter edge samples; native closest points and surface derivative tensors. G2 compares orientation-aligned 3D shape operators (Frobenius norm). Sampled checks, not a global continuity certificate.", + } + + def _trim_sheet(self, body, boundaries, region_point, keep=True): + import NXOpen.Features as F + + target = self._engineering_owned(body, "body") + if target.IsSolidBody: + raise NXToolError("NX_NOT_SHEET", "Select a sheet body") + self._freeform_refs(boundaries, "curve") + point = points3([region_point])[0] + b = self._freeform_builder("CreateTrimsheetBuilder") + try: + b.TargetBodies.Add(target) + b.BoundaryObjects.Add(self._curve_section(boundaries)) + b.KeepDiscardMethod = ( + F.TrimSheetBuilder.KeepDiscardOption.Keep + if keep + else F.TrimSheetBuilder.KeepDiscardOption.Discard + ) + b.Tolerance = 0.001 + b.OutputExactGeometry = True + p = self._work_part().Points.CreatePoint(self.nxopen.Point3d(*point)) + b.Regions.Append(self._work_part().CreateRegionPoint(p, target)) + return self._freeform_commit(b) + finally: + b.Destroy() diff --git a/src/nx_mcp/freeform_server.py b/src/nx_mcp/freeform_server.py new file mode 100644 index 0000000..a2bb758 --- /dev/null +++ b/src/nx_mcp/freeform_server.py @@ -0,0 +1,71 @@ +"""Intent-oriented contracts for freeform geometry and imported face editing.""" + +from __future__ import annotations + +from typing import Literal + +READ_ONLY: set[str] = set() +NON_MODEL: set[str] = set() + + +def nx_spline( + points: list[list[float]], + degree: int = 3, + method: Literal["through_points", "poles"] = "through_points", + periodic: bool = False, + feature: str | None = None, +): + """Create or edit an associative native 3D Studio Spline using work-part coordinates and units. Supply 2..1000 points, more than degree (1..7). through_points interpolates the points; poles uses a control polygon. feature is an existing Studio Spline feature ID to replace its defining points. No sketch plane is imposed. Returns feature and curve IDs, actual degree and periodicity. Editing replaces defining-point constraints; downstream geometry updates under the operation rollback mark.""" + + +def nx_surface_mesh(primary: list[list[str]], cross: list[list[str]], tolerance: float = 0.001): + """Create a native associative Through Curve Mesh sheet from intersecting primary/cross curve sections. Each direction requires 2..100 ordered sections, each a list of work-part curve IDs. Curves must form a compatible intersecting network; tolerance uses part units. Returns every resulting body. Existing nx_loft remains available for through-sketch sections.""" + + +def nx_bridge_surface( + first: str, + second: str, + continuity: Literal["G0", "G1", "G2"] = "G0", + reverse_second: bool = False, +): + """Bridge two work-part sheet edges with an associative native surface. G0 means position, G1 tangent and G2 curvature continuity against adjacent surfaces. Uses full edge ranges; reverse_second changes edge parameter alignment. Requested continuity is a builder constraint, not an independent quality certification.""" + + +def nx_sew(target: str, tools: list[str], tolerance: float = 0.001, solid: bool = False): + """Sew work-part sheet bodies associatively with native NX tolerances in part units. target and tools are distinct body IDs. Reject and roll back incomplete sewing. solid=True additionally requires an actual closed solid result; NX's fallback sheet is rejected. Inputs become feature parents; return all result bodies.""" + + +def nx_thicken(faces: list[str], first_offset: float, second_offset: float = 0.0): + """Thicken selected work-part sheet faces into a native solid feature between two different signed normal offsets in part units. Returns all result bodies. Face normals determine offset direction. Uses native exact offset behavior; failures roll back.""" + + +def nx_edit_faces( + faces: list[str], + action: Literal["move", "offset", "replace", "heal"], + distance: float | None = None, + direction: list[float] | None = None, + replacement: str | None = None, +): + """Edit selected work-part faces, including imported solids, through native features. move requires signed distance and a work-part direction vector; offset requires signed normal distance; replace requires a replacement face ID; heal deletes selected faces and extends neighbors to close the gap. Other arguments are rejected. No partial delete is allowed. Reacquire topology IDs after edits and inspect model health. Lengths use part units.""" + + +def nx_trim_sheet(body: str, boundaries: list[str], region_point: list[float], keep: bool = True): + """Trim a work-part sheet with curve boundaries supplied as a native section. region_point=[x,y,z] identifies the region to keep (or discard when keep=False); all coordinates use work-part units. Curves must divide the target into valid regions. Exact native trim, associative feature, rollback on invalid boundaries.""" + + +def nx_curve_analysis(curve: str, samples: int = 21): + """Read native curve derivatives at 2..1000 equally spaced normalized parameters. Accepts a work-part curve ID. Returns positions, tangents, curvature (1/part-unit), radius, singular samples, and spline knots/poles when applicable. Sampling is not uniform arc length and does not certify global extrema.""" + + +def nx_surface_continuity( + first: str, + second: str, + position_tolerance: float = 0.001, + angle_tolerance: float = 0.1, + curvature_tolerance: float = 0.01, + samples: int = 21, +): + """Sample G0/G1/G2 continuity between two work-part boundary edges with one adjacent face each. Returns bidirectional native closest-point gaps, normal angles and curvature-tensor differences. Position tolerance uses part units, angle degrees, curvature 1/part-unit. 2..200 samples per edge; G2 uses the orientation-aligned shape-operator Frobenius norm. Unresolved singular samples prevent a G1/G2 pass. Sampled checks are not a global continuity certificate.""" + + +READ_ONLY.update({"nx_curve_analysis", "nx_surface_continuity"}) diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py new file mode 100644 index 0000000..72234d2 --- /dev/null +++ b/src/nx_mcp/hardened.py @@ -0,0 +1,1923 @@ +"""NX 2606 integration: explicit frames, guarded mutations and inspectable results. + +NXOpen is accessed only by the existing main-thread dispatcher. +""" + +from __future__ import annotations + +import inspect +import math +import uuid +from pathlib import Path + +from nx_mcp import ( + assembly_documentation_server, + documentation_editing_server, + freeform_server, + manufacturing_server, + sheet_metal_server, +) +from nx_mcp.advanced_authoring import AdvancedAuthoringMixin +from nx_mcp.annotation_updates import AnnotationUpdatesMixin +from nx_mcp.assembly_documentation import AssemblyDocumentationMixin +from nx_mcp.authoring import AuthoringMixin +from nx_mcp.authoring_server import NON_MODEL as AUTHORING_NON_MODEL +from nx_mcp.authoring_server import READ_ONLY as AUTHORING_READ_ONLY +from nx_mcp.documentation_editing import DocumentationEditingMixin +from nx_mcp.drawing_preferences import DrawingPreferencesMixin +from nx_mcp.engineering import EngineeringMixin +from nx_mcp.exploded_views import ExplodedViewsMixin +from nx_mcp.freeform import FreeformMixin +from nx_mcp.inspection import InspectionMixin +from nx_mcp.inventory import compact_reference, page +from nx_mcp.legacy_repairs import LegacyRepairsMixin +from nx_mcp.manufacturing import ManufacturingMixin +from nx_mcp.nx_bridge import NXOpenExecutor +from nx_mcp.planar_dxf import PlanarDxfMixin +from nx_mcp.recovery import OperationStore, timestamp +from nx_mcp.reference_geometry import ReferenceGeometryMixin +from nx_mcp.release_engineering import ReleaseEngineeringMixin +from nx_mcp.review_tools import ReviewToolsMixin +from nx_mcp.runtime import NXToolError +from nx_mcp.sheet_metal import SheetMetalMixin +from nx_mcp.thread_standards import ThreadStandardsMixin +from nx_mcp.visual_tools import VisualToolsMixin + +READ_ONLY = { + "nx_flat_pattern_orientation_edges", + "nx_list_reference_sets", + "nx_list_datums", + "nx_display_info", + "nx_list_sections", + "nx_sketch_diagnostics", + "nx_view_info", + "nx_check_interference", + "nx_check_clearance", + "nx_status", + "nx_list_sketches", + "nx_list_features", + "nx_list_bodies", + "nx_list_components", + "nx_list_open_parts", + "nx_get_bounding_box", + "nx_measure_volume", + "nx_measure_distance", + "nx_measure_angle", + "nx_get_feature_info", + "nx_sketch_info", + "nx_list_topology", + "nx_checkpoint_state", + "nx_capabilities", + "nx_operation_status", +} +# Files, session lifecycle, and undo itself cannot be reversed by a model undo mark. +NON_MODEL = { + "nx_copy_project", + "nx_highlight_collisions", + "nx_clear_highlights", + "nx_create_part", + "nx_open_part", + "nx_activate_part", + "nx_close_part", + "nx_save_part", + "nx_save_as", + "nx_export_step", + "nx_screenshot", + "nx_render_view", + "nx_export_drawing_pdf", + "nx_undo", + "nx_checkpoint", + "nx_rollback", +} + + +def vector(value, name="vector"): + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise NXToolError("NX_INVALID_ARGUMENT", name + " must contain three numbers") + result = [float(v) for v in value] + if not all(math.isfinite(v) for v in result): + raise NXToolError("NX_INVALID_ARGUMENT", name + " must be finite") + return result + + +def dot(a, b): + return sum(x * y for x, y in zip(a, b, strict=False)) + + +def cross(a, b): + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + + +def xyz(p): + return [p.X, p.Y, p.Z] + + +def rows(m): + return [[m.Xx, m.Yx, m.Zx], [m.Xy, m.Yy, m.Zy], [m.Xz, m.Yz, m.Zz]] + + +def matvec(m, p): + return [dot(row, p) for row in m] + + +def matmul(a, b): + return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)] + + +def transpose(m): + return [list(v) for v in zip(*m, strict=False)] + + +def add(a, b): + return [x + y for x, y in zip(a, b, strict=False)] + + +IDENTITY = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + + +READ_ONLY.update( + AUTHORING_READ_ONLY + | sheet_metal_server.READ_ONLY + | freeform_server.READ_ONLY + | manufacturing_server.READ_ONLY + | assembly_documentation_server.READ_ONLY + | documentation_editing_server.READ_ONLY +) +NON_MODEL.update( + AUTHORING_NON_MODEL + | sheet_metal_server.NON_MODEL + | freeform_server.NON_MODEL + | manufacturing_server.NON_MODEL + | assembly_documentation_server.NON_MODEL + | documentation_editing_server.NON_MODEL +) + + +class HardenedExecutor( + LegacyRepairsMixin, + PlanarDxfMixin, + DrawingPreferencesMixin, + ReleaseEngineeringMixin, + ReferenceGeometryMixin, + DocumentationEditingMixin, + AnnotationUpdatesMixin, + ThreadStandardsMixin, + FreeformMixin, + ManufacturingMixin, + AssemblyDocumentationMixin, + SheetMetalMixin, + ExplodedViewsMixin, + EngineeringMixin, + AdvancedAuthoringMixin, + AuthoringMixin, + ReviewToolsMixin, + VisualToolsMixin, + InspectionMixin, + NXOpenExecutor, +): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.session_id = uuid.uuid4().hex + self.objects.session_id = self.session_id + self._part_generations = {} + self._history = [] + self._checkpoints = {} + self.store = OperationStore(self.workspace.root) + self.store.recover(self.session_id) + self._current_operation = None + for module in [freeform_server, manufacturing_server, assembly_documentation_server]: + for name in vars(module): + if name.startswith("nx_"): + self._handlers[name] = getattr(self, "_" + name[3:]) + for name, fn in vars(documentation_editing_server).items(): + if name.startswith("nx_") and inspect.isfunction(fn): + self._handlers[name] = getattr(self, "_" + name[3:]) + self._handlers.update( + { + "nx_flat_pattern_orientation_edges": self._flat_pattern_orientation_edges, + "nx_list_reference_sets": self._list_reference_sets, + "nx_create_reference_set": self._create_reference_set, + "nx_set_component_reference_set": self._set_component_reference_set, + "nx_list_datums": self._list_datums, + "nx_set_datum_visibility": self._set_datum_visibility, + "nx_resolve_geometry": self._resolve_geometry, + "nx_sheet_metal_schema": self._sheet_metal_schema, + "nx_create_path_sketch": self._create_path_sketch, + "nx_add_flat_pattern_view": self._add_flat_pattern_view, + "nx_sheet_metal_annotation": self._sheet_metal_annotation, + "nx_sheet_metal_context": self._sheet_metal_context, + "nx_sheet_metal_feature": self._sheet_metal_feature, + "nx_sheet_metal_info": self._sheet_metal_info, + "nx_sheet_metal_defaults": self._sheet_metal_defaults, + "nx_set_sheet_metal_defaults": self._set_sheet_metal_defaults, + "nx_export_flat_pattern": self._export_flat_pattern, + "nx_create_explosion": self._create_explosion, + "nx_list_explosions": self._list_explosions, + "nx_explosion_info": self._explosion_info, + "nx_edit_explosion": self._edit_explosion, + "nx_show_explosion": self._show_explosion, + "nx_delete_explosion": self._delete_explosion, + "nx_shell": self._shell, + "nx_set_material": self._set_material, + "nx_render_view": self._render_view, + "nx_mirror_body": self._mirror_body, + "nx_create_drawing": self._create_drawing, + "nx_add_base_view": self._add_base_view, + "nx_add_projection_view": self._add_projection_view, + "nx_add_dimension": self._add_dimension, + "nx_export_drawing_pdf": self._export_drawing_pdf, + "nx_blend": self._blend, + "nx_chamfer": self._chamfer, + "nx_sweep": self._sweep, + "nx_mate_component": self._mate_component, + "nx_list_assembly_constraints": self._list_assembly_constraints, + "nx_assembly_constraint": self._assembly_constraint, + "nx_edit_assembly_constraint": self._edit_assembly_constraint, + "nx_material_info": self._material_info, + "nx_sketch_angle": self._sketch_angle, + "nx_sketch_tangent": self._sketch_tangent, + "nx_sketch_symmetry": self._sketch_symmetry, + "nx_sketch_trim_extend": self._sketch_trim_extend, + "nx_component_array": self._component_array, + "nx_draft": self._draft, + "nx_transform_bodies": self._transform_bodies, + "nx_mass_properties": self._mass_properties, + "nx_copy_project": self._copy_project, + "nx_loft": self._loft, + "nx_sketch_primitive": self._sketch_primitive, + "nx_recognize_holes": self._recognize_holes, + "nx_native_component_pattern": self._native_component_pattern, + "nx_edit_component_pattern": self._edit_component_pattern, + "nx_list_component_patterns": self._list_component_patterns, + "nx_sketch_dimension": self._sketch_dimension, + "nx_sketch_relation": self._sketch_relation, + "nx_sketch_conflicts": self._sketch_conflicts, + "nx_feature_parameters": self._feature_parameters, + "nx_set_feature_parameters": self._set_feature_parameters, + "nx_view_info": self._view_info, + "nx_find_geometry": self._find_geometry, + "nx_highlight_objects": self._highlight_objects, + "nx_list_expressions": self._list_expressions, + "nx_set_expression": self._set_expression, + "nx_bind_parameter": self._bind_parameter, + "nx_model_health": self._model_health, + "nx_rebuild_model": self._rebuild_model, + "nx_edit_sketch": self._edit_sketch, + "nx_component_action": self._component_action, + "nx_pattern_components": self._pattern_components, + "nx_set_camera": self._set_camera, + "nx_save_presentation": self._save_presentation, + "nx_restore_presentation": self._restore_presentation, + "nx_inspection_report": self._inspection_report, + "nx_model_summary": self._model_summary, + "nx_preview_change": self._preview_change, + "nx_finish_preview": self._finish_preview, + "nx_display_info": self._display_info, + "nx_set_display": self._set_display, + "nx_set_visibility": self._set_visibility, + "nx_restore_display": self._restore_display, + "nx_highlight_collisions": self._highlight_collisions, + "nx_clear_highlights": self._clear_highlights, + "nx_list_sections": self._list_sections, + "nx_section_view": self._section_view, + "nx_section_control": self._section_control, + "nx_sketch_diagnostics": self._sketch_diagnostics, + "nx_check_interference": self._check_interference, + "nx_check_clearance": self._check_clearance, + "nx_activate_part": self._activate_part, + "nx_sketch_info": self._sketch_info, + "nx_edit_feature": self._edit_feature, + "nx_measure_distance": self._measure_distance, + "nx_pattern": self._pattern, + "nx_import_geometry": self._import_geometry, + "nx_checkpoint": self._checkpoint, + "nx_checkpoint_state": self._checkpoint_state, + "nx_rollback": self._rollback_checkpoint, + "nx_operation_status": lambda operation_id: self.store.get(operation_id), + "nx_list_topology": self._list_topology, + "nx_set_component_transform": self._set_component_transform, + "nx_batch": self._batch, + "nx_capabilities": self._capabilities, + "nx_save_as": self._save_as, + "nx_rename_object": self._rename_object, + "nx_delete_feature": self._delete_feature, + "nx_measure_angle": self._measure_angle, + "nx_sketch_constraint": self._sketch_constraint, + } + ) + + def _part_id(self, part): + tag = int(part.Tag) + if tag not in self._part_generations: + self._part_generations[tag] = uuid.uuid4().hex + return "part_" + self.session_id + "_" + self._part_generations[tag] + + def _reference(self, value, kind, part, fallback): + ref = super()._reference(value, kind, part, fallback) + ref.update( + session_id=self.session_id, + generation_id=self._part_generations[int(part.Tag)], + owner_part_path=part.FullPath, + journal_id=str(getattr(value, "JournalIdentifier", "")), + display_name=str(getattr(value, "Name", "")), + ) + return ref + + @staticmethod + def _name(value, fallback): + return str( + getattr(value, "Name", "") or getattr(value, "JournalIdentifier", "") or fallback + ) + + def _units(self): + return ( + "mm" + if self._work_part().PartUnits == self.nxopen.BasePart.Units.Millimeters + else "inch" + ) + + def execute(self, method, params): + params = dict(params) + supplied_id = params.pop("operation_id", None) if method != "nx_operation_status" else None + mutable = method not in READ_ONLY + op_id = supplied_id or ("op_" + uuid.uuid4().hex) + handler = self._handlers.get(method) + if handler is None: + if not self.enable_experimental: + raise NXToolError("NX_TOOL_NOT_FOUND", method) + from nx_mcp.experimental import execute_legacy, load_legacy_handlers + + legacy = load_legacy_handlers().get(method) + if legacy is None: + raise NXToolError("NX_TOOL_NOT_FOUND", method) + inspect.signature(legacy).bind(**params) + + def handler(**p): + return execute_legacy(method, p, self.workspace, enable_journal=self.enable_journal) + else: + try: + inspect.signature(handler).bind(**params) + except TypeError as e: + raise NXToolError("NX_INVALID_ARGUMENT", str(e)) from e + record = None + mark = None + part = self._work_part(required=False) + part_id = self._part_id(part) if part else None + if mutable: + try: + fingerprint = self.store.fingerprint(method, params) + except (ValueError, TypeError) as error: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Arguments must be finite JSON values" + ) from error + existing = self.store.get(op_id) + if "fingerprint" in existing: + if existing["fingerprint"] != fingerprint: + raise NXToolError( + "NX_IDEMPOTENCY_CONFLICT", + "operation_id was already used with different arguments", + details={"operation_id": op_id}, + ) + if existing["state"] == "committed": + result = dict(existing["result"]) + result["replayed"] = True + if existing.get("reverted_by"): + result["warnings"] = result.get("warnings", []) + [ + "This operation was subsequently reverted by " + + existing["reverted_by"] + + "; replay does not recreate geometry." + ] + if existing["session_id"] != self.session_id: + result["warnings"] = result.get("warnings", []) + [ + "Receipt is from an earlier NX session; reacquire object references." + ] + return result + raise NXToolError( + "NX_OPERATION_" + existing["state"].upper(), + "Request was already recorded; inspect nx_operation_status before proceeding", + details=existing, + ) + record = { + "operation_id": op_id, + "method": method, + "fingerprint": fingerprint, + "session_id": self.session_id, + "part_id": part_id, + "state": "running", + "mutation_outcome": "unknown", + "started_at": timestamp(), + } + self.store.put(record) + before = {} + previous = self._current_operation + self._current_operation = op_id + try: + if method == "nx_restore_display": + self._validate_display_restore(params["restore_id"]) + if ( + mutable + and method not in NON_MODEL + and not (method == "nx_import_geometry" and params.get("target") == "new_part") + and part + ): + before = self._snapshot(part) + if ( + mutable + and method not in NON_MODEL + and not (method == "nx_import_geometry" and params.get("target") == "new_part") + ): + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Visible, "NX MCP: " + method + ) + self._active_mark = mark + result = handler(**params) + if ( + mark is not None + and part is not None + and method + not in {"nx_refresh_annotations", "nx_sheet_metal_annotation", "nx_bend_table"} + ): + refreshed = self._refresh_annotations() + if refreshed["updated_count"]: + result["refreshed_annotations"] = refreshed["updated"] + if result.get("status") == "error": + raise NXToolError( + result.get("code", result.get("error_code", "NX_OPERATION_FAILED")), + result.get("message", "Operation failed"), + ) + receipt_metadata = ( + {key: result.get(key) for key in ("operation_id", "session_id", "mutation_outcome")} + if method == "nx_operation_status" + else None + ) + result = { + "status": "success", + **result, + "operation_id": op_id, + "session_id": self.session_id, + "mutation_outcome": "committed" if mutable else "not_applicable", + "warnings": result.get("warnings", []), + } + if receipt_metadata is not None: + result.update(receipt_metadata) + result.update(query_operation_id=op_id, query_session_id=self.session_id) + if part: + result.setdefault( + "units", self._units() if self._work_part(required=False) else None + ) + if mark is not None: + after = self._snapshot(part) if part else {} + result["changes"] = { + "created": [v for k, v in after.items() if k not in before], + "deleted": [v for k, v in before.items() if k not in after], + "modified": result.get("modified"), + "modified_tracking": "explicit only; null means not fully tracked", + } + self._invalidate_deleted( + before, + after, + invalidate_topology=method + not in { + "nx_create_explosion", + "nx_edit_explosion", + "nx_show_explosion", + "nx_delete_explosion", + "nx_set_camera", + "nx_restore_presentation", + "nx_set_display", + "nx_set_visibility", + "nx_set_datum_visibility", + "nx_create_reference_set", + "nx_set_component_reference_set", + "nx_restore_display", + "nx_section_view", + "nx_section_control", + "nx_set_view", + "nx_fit_view", + }, + ) + self._history.append( + {"mark": mark, "part_id": part_id, "operation_id": op_id, "method": method} + ) + except Exception as error: + if mutable: + self._review_epoch = getattr(self, "_review_epoch", 0) + 1 + outcome = "not_started" if mark is None else "partial" + if mark is not None: + try: + self.session.UndoToMark(mark, None) + self.objects.invalidate_part(part_id) + self.session.DeleteUndoMark(mark, None) + outcome = "rolled_back" + except Exception as rollback_error: + error = NXToolError( + "NX_ROLLBACK_FAILED", + str(rollback_error), + details={"operation_error": str(error)}, + ) + elif mutable and (method in NON_MODEL or method == "nx_import_geometry"): + outcome = "unknown" + err = ( + error + if isinstance(error, NXToolError) + else NXToolError( + "NX_API_ERROR", str(error), nx_code=getattr(error, "ErrorCode", None) + ) + ) + if mark is None: + # Inspections may create temporary native geometry. Preserve a + # handler's explicit cleanup outcome when no outer rollback ran. + outcome = err.details.get("mutation_outcome", outcome) + err.details.update(operation_id=op_id, mutation_outcome=outcome) + if record: + record.update( + state="failed", + mutation_outcome=outcome, + error=err.as_dict(), + finished_at=timestamp(), + ) + self.store.put(record) + raise err from error + finally: + self._current_operation = previous + if mutable and method != "nx_preview_change": + self._review_epoch = getattr(self, "_review_epoch", 0) + 1 + if record: + record.update( + state="committed", + mutation_outcome="committed", + result=result, + finished_at=timestamp(), + ) + # Receipt persistence is outside the rollback block: a persistence failure must + # never undo an already reported commit. A running receipt becomes unknown on restart. + self.store.put(record) + return result + + def _resolve(self, ref, kinds=None, part=None): + part = part or self._work_part() + if ref.startswith("obj_"): + try: + value = self.objects.resolve(ref, part_id=self._part_id(part)) + except NXToolError as error: + if error.code == "NX_OBJECT_NOT_FOUND": + raise NXToolError( + "NX_OBJECT_STALE", "Reference is not live in this NX session" + ) from error + raise + entry = self.objects._objects[ref] + if kinds and entry.reference.kind not in kinds: + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Unsupported object kind") + return value + pools = { + "feature": list(part.Features), + "body": list(part.Bodies), + "sketch": list(part.Sketches), + "component": [c for c, _ in self._walk_components(part)], + "section": list(part.DynamicSections), + } + candidates = [] + for kind, values in pools.items(): + if kinds and kind not in kinds: + continue + for v in values: + if ref.casefold() in { + self._name(v, "").casefold(), + str(getattr(v, "JournalIdentifier", "")).casefold(), + } and all(int(c.Tag) != int(v.Tag) for c in candidates): + candidates.append(v) + if len(candidates) != 1: + raise NXToolError( + "NX_AMBIGUOUS_REFERENCE" if candidates else "NX_NOT_FOUND", + "Use an opaque ID; name did not resolve uniquely: " + ref, + ) + return candidates[0] + + def _resolve_body(self, body, part): + return self._resolve(body, {"body"}, part) + + def _find_sketch(self, name): + return self._resolve(name, {"sketch"}) + + def _open_part(self, path, work=True, display=True, load_components=False): + source = self.workspace.ensure_inside(path) + loaded = next( + ( + p + for p in self.session.Parts + if str(Path(p.FullPath).resolve()).casefold() == str(source).casefold() + ), + None, + ) + already_loaded = loaded is not None + if loaded is None: + if not source.is_file(): + raise NXToolError("NX_FILE_NOT_FOUND", str(source)) + loaded, status = self.session.Parts.OpenBase(str(source)) + if status: + status.Dispose() + result = self._activate_part( + self._reference(loaded, "part", loaded, "Part")["id"], work, display + ) + if load_components: + from nx_mcp.assembly_loading import load_components as load + + result["component_loading"] = load(self, loaded) + result.update( + already_loaded=already_loaded, + path=str(source), + message="Reused loaded part" if already_loaded else "Opened part", + ) + return result + + def _activate_part(self, part, work=True, display=True): + if part.startswith("obj_"): + target = self.objects.resolve(part, expected_kind="part") + else: + matches = [ + p + for p in self.session.Parts + if part.casefold() in {p.FullPath.casefold(), p.Name.casefold()} + ] + if len(matches) != 1: + raise NXToolError("NX_NOT_FOUND", "Loaded part must resolve uniquely") + target = matches[0] + if display: + _, status = self.session.Parts.SetDisplay(target, False, False) + if status: + status.Dispose() + if work: + self.session.Parts.SetWork(target) + return { + "part": self._reference(target, "part", target, "Part"), + "work": self.session.Parts.Work == target, + "display": self.session.Parts.Display == target, + "message": "Activated loaded part" if work or display else "Resolved loaded part", + } + + def _save_part(self): + from nx_mcp.save_audit import snapshot, verify + + part = self._work_part() + try: + before = snapshot(self.session) + except Exception as error: + raise NXToolError( + "NX_SAVE_PREFLIGHT_FAILED", str(error), details={"mutation_outcome": "not_started"} + ) from error + native_errors = [] + save_error = None + save_called = False + native_returned = False + try: + with self._drawing_save_context(part): + save_called = True + status = part.Save( + self.nxopen.BasePart.SaveComponents.FalseValue, + self.nxopen.BasePart.CloseAfterSave.FalseValue, + ) + native_returned = True + try: + if status: + for i in range(status.NumberUnsavedParts): + native_errors.append( + {"path": status.GetPart(i).FullPath, "nx_code": status.GetStatus(i)} + ) + for i in range(status.NumberUnsavedObjects): + native_errors.append( + {"object_index": i, "nx_code": status.GetObjectStatus(i)} + ) + finally: + if status: + status.Dispose() + except Exception as error: + if not save_called: + if isinstance(error, NXToolError): + error.details["mutation_outcome"] = "not_started" + raise + save_error = error + if not native_returned: + native_errors.append({"message": str(error)}) + try: + audit = verify(before, snapshot(self.session), int(part.Tag), native_errors) + except NXToolError as error: + if save_error: + error.details["cause_code"] = getattr(save_error, "code", None) + raise + except Exception as error: + raise NXToolError( + "NX_SAVE_VERIFICATION_FAILED", + str(error), + details={"mutation_outcome": "partial", "path": part.FullPath}, + ) from error + if save_error: + if isinstance(save_error, NXToolError): + save_error.details.update(audit, mutation_outcome="partial") + raise save_error + raise NXToolError( + "NX_SAVE_FAILED", str(save_error), details={**audit, "mutation_outcome": "partial"} + ) from save_error + state = self._checkpoint_state() + return { + "message": "Saved and verified work-part file; native NX save may invalidate undo marks", + "path": part.FullPath, + "recovery": state, + **audit, + "warnings": [ + "NX v2606 save invalidates native undo/checkpoints. Establish a new checkpoint before further edits.", + "Verification covers loaded part files and flags, not external linked files or concurrent external writers.", + ], + } + + def _save_as(self, path): + part = self._work_part() + dest = self.workspace.ensure_inside(path) + if dest.exists(): + raise NXToolError("NX_FILE_EXISTS", "Save-as does not overwrite existing files") + dest.parent.mkdir(parents=True, exist_ok=True) + with self._drawing_save_context(part): + status = part.SaveAs(str(dest)) + if status and hasattr(status, "Dispose"): + status.Dispose() + return { + "path": str(dest), + "part": self._reference(part, "part", part, "Part"), + "message": "Saved as", + } + + def _close_part(self, save=True, part=None): + from nx_mcp.assembly_loading import dependent_assemblies + + target = self.objects.resolve(part, expected_kind="part") if part else self._work_part() + parents = dependent_assemblies(self, target) + if parents: + raise NXToolError( + "NX_PART_IN_USE", + "Close the loaded parent assemblies before closing this prototype", + details={"parent_assemblies": parents, "mutation_outcome": "not_started"}, + ) + # NX may unload unused prototypes even with CloseWholeTree.FalseValue. + # Capture references before Close; querying an unloaded NX proxy can fail. + loaded = {int(p.Tag): self._reference(p, "part", p, "Part") for p in self.session.Parts} + if save: + with self._drawing_save_context(target): + status = target.Save( + self.nxopen.BasePart.SaveComponents.FalseValue, + self.nxopen.BasePart.CloseAfterSave.FalseValue, + ) + if status and hasattr(status, "Dispose"): + status.Dispose() + target.Close( + self.nxopen.BasePart.CloseWholeTree.FalseValue, + self.nxopen.BasePart.CloseModified.CloseModified, + None, + ) + remaining = {int(p.Tag) for p in self.session.Parts} + closed = [ref for tag, ref in loaded.items() if tag not in remaining] + closed_ids = {ref["part_id"] for ref in closed} + for tag, ref in loaded.items(): + if tag not in remaining: + self.objects.invalidate_part(ref["part_id"]) + self._part_generations.pop(tag, None) + self._history = [h for h in self._history if h["part_id"] not in closed_ids] + self._checkpoints = { + k: v for k, v in self._checkpoints.items() if v["part_id"] not in closed_ids + } + return { + "message": "Closed part; NX may also unload unused assembly prototypes", + "closed_parts": closed, + "closed_count": len(closed), + "remaining_count": len(remaining), + "warnings": ["Re-list open parts before closing another assembly dependency."], + } + + def _list_open_parts( + self, + compact=False, + path_prefix=None, + modified=None, + active_only=False, + offset=0, + limit=None, + ): + page([], offset, limit) + rows = [] + for p in self.session.Parts: + active = p == self.session.Parts.Work or p == self.session.Parts.Display + if path_prefix is not None and not p.FullPath.casefold().replace("\\", "/").startswith( + path_prefix.casefold().replace("\\", "/") + ): + continue + if modified is not None and bool(p.IsModified) != modified: + continue + if active_only and not active: + continue + ref = self._reference(p, "part", p, "Part") + rows.append( + { + "part": compact_reference(ref) if compact else ref, + "name": p.Name, + "path": p.FullPath, + "work": p == self.session.Parts.Work, + "display": p == self.session.Parts.Display, + "modified": bool(p.IsModified), + } + ) + selected, metadata = page(rows, offset, limit) + return {"parts": selected, **metadata} + + def _sketch_frame(self, sketch): + m = sketch.Orientation.Element + return { + "origin": xyz(sketch.Origin), + "x_axis": [m.Xx, m.Xy, m.Xz], + "y_axis": [m.Yx, m.Yy, m.Yz], + "normal": [m.Zx, m.Zy, m.Zz], + "coordinate_frame": "part", + } + + def _create_sketch(self, plane="XY", name=None, origin=None, x_axis=None, y_axis=None): + bases = { + "XY": ([1, 0, 0], [0, 1, 0]), + "XZ": ([1, 0, 0], [0, 0, 1]), + "YZ": ([0, 1, 0], [0, 0, 1]), + } + if plane not in bases: + raise NXToolError("NX_INVALID_ARGUMENT", "plane must be XY, XZ or YZ") + if (x_axis is None) != (y_axis is None): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply both basis axes") + x = vector(x_axis if x_axis is not None else bases[plane][0]) + y = vector(y_axis if y_axis is not None else bases[plane][1]) + o = vector(origin or [0, 0, 0]) + if abs(dot(x, x) - 1) > 1e-8 or abs(dot(y, y) - 1) > 1e-8 or abs(dot(x, y)) > 1e-8: + raise NXToolError("NX_INVALID_ARGUMENT", "Sketch basis must be orthonormal") + n = cross(x, y) + part = self._work_part() + matrix = self._nx_matrix(transpose([x, y, n])) + csys = part.CoordinateSystems.CreateCoordinateSystem(self.nxopen.Point3d(*o), matrix, False) + builder = part.Sketches.CreateSketchInPlaceBuilder2(self.nxopen.Sketch.Null) + try: + builder.Csystem = csys + sketch = builder.Commit() + finally: + builder.Destroy() + if name: + sketch.SetName(name) + actual = self._sketch_frame(sketch) + if any( + abs(a - b) > 1e-7 + for k, v in [("origin", o), ("x_axis", x), ("y_axis", y), ("normal", n)] + for a, b in zip(actual[k], v, strict=False) + ): + raise NXToolError( + "NX_FRAME_MISMATCH", + "NX sketch frame differs from requested frame", + details={"actual": actual}, + ) + sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue) + return { + "object": self._reference(sketch, "sketch", part, "Sketch"), + "frame": actual, + "message": "Created sketch", + } + + def _point_on_sketch(self, sketch, p): + f = self._sketch_frame(sketch) + return self.nxopen.Point3d( + *[ + f["origin"][i] + float(p["x"]) * f["x_axis"][i] + float(p["y"]) * f["y_axis"][i] + for i in range(3) + ] + ) + + def _create_sketch_line(self, sketch, part, start, end): + if self.session.ActiveSketch != sketch: + raise NXToolError( + "NX_SKETCH_NOT_ACTIVE", "Activate the owning sketch before adding geometry" + ) + curve = part.Curves.CreateLine( + self._point_on_sketch(sketch, start), self._point_on_sketch(sketch, end) + ) + sketch.AddGeometry(curve, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints) + return self._reference(curve, "curve", part, "Line") + + def _sketch_arc_legacy(self, cx, cy, radius, start_angle, end_angle, sketch_id=None): + sketch = self._resolve(sketch_id, {"sketch"}) if sketch_id else self.session.ActiveSketch + if sketch is None or self.session.ActiveSketch != sketch: + raise NXToolError("NX_SKETCH_NOT_ACTIVE", "An explicit active sketch is required") + if not math.isfinite(radius) or radius <= 0 or not 0 < end_angle - start_angle <= 360: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Require positive radius and 0 < end-start <= 360 degrees" + ) + part = self._work_part() + f = self._sketch_frame(sketch) + arc = part.Curves.CreateArc( + self._point_on_sketch(sketch, {"x": cx, "y": cy}), + self.nxopen.Vector3d(*f["x_axis"]), + self.nxopen.Vector3d(*f["y_axis"]), + float(radius), + math.radians(start_angle), + math.radians(end_angle), + ) + sketch.AddGeometry(arc, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints) + return { + "object": self._reference(arc, "curve", part, "Arc"), + "frame": f, + "message": "Created sketch arc", + } + + def _sketch_info(self, sketch_id): + sketch = self._resolve(sketch_id, {"sketch"}) + part = self._work_part() + geometry = [] + for c in sketch.GetAllGeometry(): + row = {"object": self._reference(c, "curve", part, "Curve"), "type": type(c).__name__} + if hasattr(c, "StartPoint"): + row.update(start=xyz(c.StartPoint), end=xyz(c.EndPoint)) + if hasattr(c, "CenterPoint"): + row.update(center=xyz(c.CenterPoint), radius=c.Radius) + geometry.append(row) + return { + "object": self._reference(sketch, "sketch", part, "Sketch"), + "frame": self._sketch_frame(sketch), + "curves": geometry, + "curve_count": len(geometry), + } + + def _simple_extrude(self, sketch_id, distance, reverse=False): + if not math.isfinite(distance): + raise NXToolError("NX_INVALID_ARGUMENT", "distance must be finite") + result = NXOpenExecutor._extrude(self, sketch_id, distance, reverse) + feature = self.objects.resolve(result["feature"]["id"]) + bodies = list(feature.GetBodies()) + result.update( + bodies=[self._reference(b, "body", self._work_part(), "Body") for b in bodies], + body_count=len(bodies), + ) + result["created"] = [result["feature"]] + result["bodies"] + result["modified"] = [] + result["deleted"] = [] + return result + + def _get_feature_info(self, name): + f = self._resolve(name, {"feature"}) + part = self._work_part() + return { + "feature": self._reference(f, "feature", part, "Feature"), + "name": self._name(f, "Feature"), + "type": f.FeatureType, + "expressions": [ + {"name": e.Name, "formula": e.RightHandSide, "value": e.Value} + for e in f.GetExpressions() + ], + "parents": [self._reference(v, "feature", part, "Feature") for v in f.GetParents()], + "children": [self._reference(v, "feature", part, "Feature") for v in f.GetChildren()], + } + + def _edit_feature(self, name, params): + f = self._resolve(name, {"feature"}) + part = self._work_part() + kind = f.FeatureType.upper().replace(" ", "_") + allowed = ( + {"distance"} + if kind == "EXTRUDE" + else {"count", "spacing"} + if kind in {"PATTERN_FEATURE", "PATTERN"} + else set() + ) + if not params or set(params) - allowed: + raise NXToolError( + "NX_UNSUPPORTED_EDIT", + "Supported edits: EXTRUDE distance; PATTERN_FEATURE count/spacing", + ) + if any( + not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0 + for v in params.values() + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Edit values must be finite and positive") + if "count" in params and (int(params["count"]) != params["count"] or params["count"] < 2): + raise NXToolError("NX_INVALID_ARGUMENT", "count must be integer >= 2") + if kind == "EXTRUDE": + builder = part.Features.CreateExtrudeBuilder(f) + try: + builder.Limits.EndExtend.Value.RightHandSide = str(params["distance"]) + builder.CommitFeature() + finally: + builder.Destroy() + else: + builder = part.Features.CreatePatternFeatureBuilder(f) + try: + spacing = builder.PatternService.RectangularDefinition.XSpacing + if "count" in params: + spacing.NCopies.RightHandSide = str(int(params["count"])) + if "spacing" in params: + spacing.PitchDistance.RightHandSide = str(params["spacing"]) + builder.CommitFeature() + finally: + builder.Destroy() + errors = self.session.UpdateManager.DoUpdate(self._active_mark) + if errors: + raise NXToolError("NX_UPDATE_FAILED", str(errors) + " update errors") + result = self._get_feature_info(self._reference(f, "feature", part, "Feature")["id"]) + result.update(modified=[result["feature"]], created=[], deleted=[]) + return result + + def _checkpoint(self, label="checkpoint"): + part = self._work_part() + key = "cp_" + uuid.uuid4().hex + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Visible, "NX MCP checkpoint: " + label + ) + self._checkpoints[key] = { + "mark": mark, + "part_id": self._part_id(part), + "index": len(self._history), + "label": label, + } + return { + "checkpoint_id": key, + "message": "In-session checkpoint created; disk saves are not rolled back", + } + + def _checkpoint_state(self): + return { + "checkpoints": [ + {k: v for k, v in c.items() if k != "mark"} + | { + "checkpoint_id": key, + "available": self.session.DoesUndoMarkExist(c["mark"], None), + } + for key, c in self._checkpoints.items() + ], + "undo_depth": sum( + self.session.DoesUndoMarkExist(h["mark"], None) for h in self._history + ), + "save_semantics": "NX v2606 save removes native marks. Expired checkpoints cannot be rolled back; create a new checkpoint after save.", + "retention": "Current NX process only; cross-part rollback is rejected if it would undo another part.", + } + + def _rollback_checkpoint(self, checkpoint_id): + if checkpoint_id not in self._checkpoints: + raise NXToolError("NX_CHECKPOINT_STALE", "Checkpoint is not in this session") + cp = self._checkpoints[checkpoint_id] + if not self.session.DoesUndoMarkExist(cp["mark"], None): + raise NXToolError( + "NX_CHECKPOINT_STALE", + "Native NX checkpoint expired, commonly at save or part activation", + ) + pid = self._part_id(self._work_part()) + if cp["part_id"] != pid or any(h["part_id"] != pid for h in self._history[cp["index"] :]): + raise NXToolError( + "NX_CROSS_PART_ROLLBACK", + "Rollback would affect a different part; activate the checkpoint part or reconcile intervening changes", + ) + self.session.UndoToMark(cp["mark"], None) + self.objects.invalidate_part(pid) + self._record_reverted(self._history[cp["index"] :], checkpoint_id) + self._history = self._history[: cp["index"]] + self._checkpoints = {k: v for k, v in self._checkpoints.items() if v["index"] < cp["index"]} + return { + "message": "Rolled back checkpoint; reacquire object references", + "checkpoint_id": checkpoint_id, + } + + def _undo(self): + if not self._history or not self.session.DoesUndoMarkExist(self._history[-1]["mark"], None): + raise NXToolError( + "NX_UNDO_UNAVAILABLE", + "No native undo mark remains; save and part lifecycle changes can expire history", + ) + last = self._history[-1] + pid = self._part_id(self._work_part()) + if last["part_id"] != pid: + raise NXToolError( + "NX_CROSS_PART_ROLLBACK", "Activate the part owning the latest mutation" + ) + self.session.UndoToMark(last["mark"], None) + self._record_reverted([last], self._current_operation) + self._history.pop() + self.objects.invalidate_part(pid) + self._checkpoints = { + k: v for k, v in self._checkpoints.items() if v["index"] <= len(self._history) + } + return { + "message": "Undone; reacquire object references", + "undone_operation_id": last["operation_id"], + } + + def _nx_matrix(self, m): + matrix = self.nxopen.Matrix3x3() + for i, prefix in enumerate("XYZ"): + for j, suffix in enumerate("xyz"): + setattr(matrix, prefix + suffix, m[j][i]) + return matrix + + def _walk_components(self, part): + root = part.ComponentAssembly.RootComponent + + def walk(parent, path): + for c in parent.GetChildren(): + p = path + [self._name(c, "Component")] + yield c, p + yield from walk(c, p) + + return list(walk(root, [])) if root else [] + + def _list_components( + self, + compact=False, + include_transforms=True, + name_contains=None, + suppressed=None, + offset=0, + limit=None, + ): + from nx_mcp.assembly_loading import component_info + + page([], offset, limit) + part = self._work_part() + candidates = [ + (c, path) + for c, path in self._walk_components(part) + if (name_contains is None or name_contains.casefold() in c.Name.casefold()) + and (suppressed is None or bool(c.IsSuppressed) == suppressed) + ] + selected, metadata = page(candidates, offset, limit) + result = [] + for c, path in selected: + ref = self._reference(c, "component", part, "Component") + ref["occurrence_path"] = path + row = { + "object": compact_reference(ref) if compact else ref, + "name": c.Name, + **component_info(c), + "depth": len(path) - 1, + "suppressed": bool(c.IsSuppressed), + "reference_set": c.ReferenceSet, + } + if include_transforms: + p, m = c.GetPosition() + row.update(translation=xyz(p), rotation_matrix=rows(m), coordinate_frame="assembly") + if not compact: + row["rotation"] = [m.Xx, m.Xy, m.Xz, m.Yx, m.Yy, m.Yz, m.Zx, m.Zy, m.Zz] + result.append(row) + return { + "components": result, + **metadata, + "matrix_convention": "rotation_matrix is row-major; p_assembly = R p_local + translation. Legacy rotation lists axis vectors.", + } + + def _list_topology(self, body, face=None, include_adjacency=False, compact=False): + b = self._resolve(body, {"body"}) + part = self._work_part() + faces = list(b.GetFaces()) + if face is not None: + selected = self._resolve(face, {"face"}) + if selected not in faces: + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "face must belong to body") + faces = [selected] + edges = ( + list({int(e.Tag): e for f in faces for e in f.GetEdges()}.values()) + if face + else list(b.GetEdges()) + ) + + def ref(value, kind): + r = self._reference(value, kind, part, kind.title()) + return compact_reference(r) if compact else r + + result = { + "body": ref(b, "body"), + "faces": [ref(f, "face") for f in faces], + "edges": [ref(e, "edge") for e in edges], + "solid": b.IsSolidBody, + "face_count": len(faces), + "edge_count": len(edges), + } + if include_adjacency: + result["face_edges"] = [ + { + "face": ref(f, "face")["id"], + "edges": [ref(e, "edge")["id"] for e in f.GetEdges()], + } + for f in faces + ] + result["edge_faces"] = [ + { + "edge": ref(e, "edge")["id"], + "faces": [ref(f, "face")["id"] for f in e.GetFaces()], + } + for e in edges + ] + return result + + def _rename_object(self, object_id, name): + if not name or len(name) > 132: + raise NXToolError("NX_INVALID_ARGUMENT", "Name must contain 1–132 characters") + value = self._resolve(object_id) + value.SetName(name) + ref = self.objects._objects[object_id].reference + self.objects.invalidate_part(ref.part_id) + return { + "object": self._reference(value, ref.kind, self._work_part(), name), + "message": "Renamed; references refreshed", + } + + def _geometry(self, ref=None, scope="auto"): + part = self._work_part() + if scope not in {"auto", "part", "assembly"}: + raise NXToolError("NX_INVALID_ARGUMENT", "scope must be auto, part or assembly") + values = [] + if ref: + obj = self._resolve(ref, {"body", "face", "edge", "component", "feature"}) + if hasattr(obj, "FindOccurrence"): + selected = [obj] + [ + c for c, _ in self._walk_components(part) if self._descendant(c, obj) + ] + for c in selected: + values.extend(self._occurrence_bodies(c)) + elif hasattr(obj, "GetBodies") and not hasattr(obj, "IsSolidBody"): + values = list(obj.GetBodies()) + else: + values = [obj] + else: + values = list(part.Bodies) + if scope == "assembly" or (scope == "auto" and part.ComponentAssembly.RootComponent): + for c, _ in self._walk_components(part): + values.extend(self._occurrence_bodies(c)) + if not values: + raise NXToolError( + "NX_NO_TARGET_BODY", + "No included geometry; inspect suppression, loading and reference sets", + ) + return values + + @staticmethod + def _descendant(c, parent): + while c.Parent: + c = c.Parent + if c == parent: + return True + return False + + def _occurrence_bodies(self, c): + p = c + while p: + if p.IsSuppressed: + return [] + p = p.Parent + bodies = [] + for b in c.Prototype.Bodies: + occurrence = c.FindOccurrence(b) + if occurrence is not None: + bodies.append(occurrence) + return bodies + + def _get_bounding_box(self, body=None, scope="auto", precision="conservative"): + import NXOpen.UF + + part = self._work_part() + uf = NXOpen.UF.UFSession.GetUFSession() + objects = self._geometry(body, scope) + if precision not in {"conservative", "exact"}: + raise NXToolError("NX_INVALID_ARGUMENT", "precision must be conservative or exact") + if precision == "exact": + wcs = rows(part.WCS.CoordinateSystem.Orientation.Element) + if any(abs(wcs[i][j] - IDENTITY[i][j]) > 1e-8 for i in range(3) for j in range(3)): + raise NXToolError( + "NX_UNSUPPORTED_FRAME", + "Exact absolute bounds currently require axis-aligned WCS", + ) + result = [] + for item in objects: + if precision == "exact": + low, directions, lengths = uf.ModlGeneral.AskBoundingBoxExact(item.Tag, 0) + if len(directions) == 9: + directions = [directions[i : i + 3] for i in range(0, 9, 3)] + corners = [ + [ + low[i] + + sum(directions[j][i] * lengths[j] * ((mask >> j) & 1) for j in range(3)) + for i in range(3) + ] + for mask in range(8) + ] + box = [min(p[i] for p in corners) for i in range(3)] + [ + max(p[i] for p in corners) for i in range(3) + ] + else: + box = list(uf.ModlGeneral.AskBoundingBox(item.Tag)) + result.append( + { + "body": self._reference(item, "body", part, "Body"), + "box": box, + "solid": bool(item.IsSolidBody), + } + ) + low = [min(row["box"][i] for row in result) for i in range(3)] + high = [max(row["box"][i + 3] for row in result) for i in range(3)] + return { + "min": low, + "max": high, + "dimensions": [b - a for a, b in zip(low, high, strict=False)], + "units": self._units(), + "coordinate_frame": "work_part", + "bounds_type": precision, + "bodies": result, + "body_count": len(result), + "scope": scope, + "message": "Native UF " + + precision + + " bounds of included body occurrences; suppression and reference sets honored", + } + + def _measure_distance(self, obj1, obj2): + a = self._geometry(obj1) + b = self._geometry(obj2) + best = None + for x in a: + for y in b: + dist, p1, p2, accuracy = self.session.Measurement.GetMinimumDistance(x, y) + if best is None or dist < best["distance"]: + best = { + "distance": dist, + "closest_points": [xyz(p1), xyz(p2)], + "accuracy": None, + "accuracy_note": "No validated numerical error bound is exposed by this NX binding", + "resolved_tags": [int(x.Tag), int(y.Tag)], + } + return { + **best, + "units": self._units(), + "coordinate_frame": "work_part", + "references": [obj1, obj2], + "method": "NX Measurement.GetMinimumDistance", + "pair_count": len(a) * len(b), + "warnings": ["Zero distance alone does not distinguish contact from penetration."], + } + + def _pattern(self, features, pattern_type="linear", direction="X", spacing=10, count=2): + import NXOpen.GeometricUtilities + + if pattern_type != "linear": + raise NXToolError( + "NX_UNSUPPORTED_ARGUMENT", "Only native linear feature patterns are implemented" + ) + if ( + type(count) is not int + or count < 2 + or count > 1000 + or not math.isfinite(spacing) + or spacing <= 0 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Require 2–1000 total instances and positive finite pitch" + ) + vectors = { + "X": [1, 0, 0], + "Y": [0, 1, 0], + "Z": [0, 0, 1], + "-X": [-1, 0, 0], + "-Y": [0, -1, 0], + "-Z": [0, 0, -1], + } + if direction not in vectors: + raise NXToolError("NX_INVALID_ARGUMENT", "Invalid principal direction") + seeds = [self._resolve(f, {"feature"}) for f in features] + if not seeds: + raise NXToolError("NX_INVALID_ARGUMENT", "At least one feature is required") + part = self._work_part() + builder = part.Features.CreatePatternFeatureBuilder(self.nxopen.Features.Feature.Null) + try: + builder.FeatureList.Add(seeds) + builder.PatternMethod = ( + self.nxopen.Features.PatternFeatureBuilder.PatternMethodOptions.Simple + ) + builder.PatternService.PatternType = ( + NXOpen.GeometricUtilities.PatternDefinition.PatternEnum.Linear + ) + definition = builder.PatternService.RectangularDefinition + definition.XDirection = part.Directions.CreateDirection( + self.nxopen.Point3d(0.0, 0.0, 0.0), + self.nxopen.Vector3d(*[float(v) for v in vectors[direction]]), + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + definition.XSpacing.NCopies.RightHandSide = str(count) + definition.XSpacing.PitchDistance.RightHandSide = str(spacing) + definition.YSpacing.NCopies.RightHandSide = "1" + feature = builder.CommitFeature() + finally: + builder.Destroy() + return { + "feature": self._reference(feature, "feature", part, "Pattern"), + "feature_type": feature.FeatureType, + "count": count, + "count_includes_seed": True, + "spacing": spacing, + "direction": direction, + "bodies": [self._reference(b, "body", part, "Body") for b in feature.GetBodies()], + } + + def _import_geometry(self, path, flatten=False, target="work_part", output_path=None): + # A new-part import owns every part it creates and restores the prior selection on failure. + before = {int(p.Tag) for p in self.session.Parts} + work, display = self.session.Parts.Work, self.session.Parts.Display + try: + return self._import_geometry_inner(path, flatten, target, output_path) + except Exception as error: + if target == "new_part": + created = [p for p in self.session.Parts if int(p.Tag) not in before] + cleanup_errors = [] + for part in reversed(created): + try: + ref = self._reference(part, "part", part, "Import part") + self._close_part(part=ref["id"], save=False) + except Exception as cleanup: + cleanup_errors.append(str(cleanup)) + try: + if display is not None: + self._activate_part( + self._reference(display, "part", display, "Part")["id"], + work=False, + display=True, + ) + if work is not None: + self._activate_part( + self._reference(work, "part", work, "Part")["id"], + work=True, + display=False, + ) + except Exception as cleanup: + cleanup_errors.append(str(cleanup)) + err = ( + error + if isinstance(error, NXToolError) + else NXToolError( + "NX_API_ERROR", str(error), nx_code=getattr(error, "ErrorCode", None) + ) + ) + err.details.update( + mutation_outcome="partial" + if cleanup_errors + else "rolled_back" + if created + else "not_started", + cleanup_errors=cleanup_errors, + ) + if err is error: + raise + raise err from error + raise + + def _import_diagnostics(self, import_dir, settings_file, part): + return { + "translator": "Step214Importer", + "settings_file": str(settings_file), + "staging_directory": str(import_dir), + "output_search_paths": [str(import_dir), str(part.FullPath)], + "translator_files": [ + { + "path": str(f), + "size": f.stat().st_size, + "text": f.read_text(errors="replace")[-16000:] + if f.suffix.lower() in {".log", ".err", ".txt"} + else None, + } + for f in sorted(import_dir.iterdir()) + if f.is_file() and f.name != "input.step" + ], + } + + def _import_geometry_inner(self, path, flatten=False, target="work_part", output_path=None): + import re + import shutil + + source = self.workspace.ensure_inside(path) + if source.suffix.lower() not in {".step", ".stp"}: + raise NXToolError("NX_UNSUPPORTED_ARGUMENT", "Only STEP import is implemented") + if not source.is_file(): + raise NXToolError("NX_FILE_NOT_FOUND", str(source)) + if target not in {"work_part", "new_part"}: + raise NXToolError("NX_INVALID_ARGUMENT", "target must be work_part or new_part") + if (target == "new_part") != bool(output_path): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Supply output_path exactly when target is new_part" + ) + output = self.workspace.ensure_inside(output_path) if output_path else None + if output and output.exists(): + raise NXToolError("NX_FILE_EXISTS", "Import destination already exists") + text = source.read_text(errors="replace") + framing = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL).strip("\ufeff \t\r\n") + if not re.match(r"ISO-10303-21\s*;", framing, re.IGNORECASE) or not re.search( + r"END-ISO-10303-21\s*;\s*$", framing, re.IGNORECASE + ): + raise NXToolError( + "NX_INVALID_STEP", + "STEP exchange-file opening or closing marker is missing; file may be truncated", + suggestion="Obtain a complete STEP file. No translator was started.", + ) + product_names = set() + for pair in re.findall( + r"PRODUCT\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'", text, re.IGNORECASE + ): + product_names.update(Path(v.replace("''", "'")).stem.casefold() for v in pair) + conflicts = [ + p.FullPath + for p in self.session.Parts + if Path(p.FullPath).stem.casefold() in product_names + ] + if conflicts and not flatten and "NEXT_ASSEMBLY_USAGE_OCCURRENCE" in text.upper(): + raise NXToolError( + "NX_IMPORT_NAME_CONFLICT", + "STEP prototype names are already loaded. Close those saved source parts explicitly or use a fresh NX session.", + details={"loaded_parts": conflicts}, + ) + # Use the verified WorkPart importer for both modes. NX 2606's NewPart + # translator mode returned no output in testing; normal part creation is explicit. + if output: + self._create_part(str(output), units="mm") + part = self._work_part() + before = {int(b.Tag) for b in part.Bodies} + component_before = {int(c.Tag) for c, _ in self._walk_components(part)} + import_dir = self.workspace.root / "imports" / self._current_operation + import_dir.mkdir(parents=True, exist_ok=False) + staged = import_dir / "input.step" + shutil.copyfile(source, staged) + builder = self.session.DexManager.CreateStep214Importer() + try: + settings_file = str( + Path( + __import__("os").environ.get( + "UGII_BASE_DIR", r"C:\Program Files\Siemens\Designcenter2606" + ) + ) + / "STEP214UG" + / "step214ug.def" + ) + builder.SettingsFile = settings_file + builder.InputFile = str(staged) + builder.ImportTo = self.nxopen.Step214Importer.ImportToOption.WorkPart + builder.FileOpenFlag = False + builder.FlattenAssembly = flatten + builder.SimplifyGeometry = False + builder.ObjectTypes.Solids = True + builder.ObjectTypes.Surfaces = True + builder.ObjectTypes.Curves = True + builder.ProcessHoldFlag = True + try: + builder.Commit() + except Exception as error: + raise NXToolError( + "NX_IMPORT_FAILED", + str(error), + nx_code=getattr(error, "ErrorCode", None), + details=self._import_diagnostics(import_dir, settings_file, part), + ) from error + finally: + builder.Destroy() + bodies = [ + self._reference(b, "body", part, "Body") + for b in part.Bodies + if int(b.Tag) not in before + ] + components = self._list_components() + added_components = [ + c for c, _ in self._walk_components(part) if int(c.Tag) not in component_before + ] + if not bodies and not added_components: + raise NXToolError( + "NX_IMPORT_NO_OUTPUT", + "Translator returned without imported bodies or occurrences", + details=self._import_diagnostics(import_dir, settings_file, part), + ) + return { + "path": str(source), + "staging_directory": str(import_dir), + "diagnostics": self._import_diagnostics(import_dir, settings_file, part), + "bodies": bodies, + "body_count": len(bodies), + "components": components, + "created_component_count": len(added_components), + "target": target, + "part": self._reference(part, "part", part, "Part"), + "units": self._units(), + "translator": "NX v2606 Step214Importer WorkPart", + "flatten": flatten, + "warnings": [ + "Translator-generated prototypes are staged in the returned import directory. Save to persist them; native undo does not delete translator artifacts." + ], + } + + def _add_component(self, part_path, name=None, translation=None, rotation_matrix=None): + part = self._work_part() + path = self.workspace.ensure_inside(part_path) + t = vector(translation or [0, 0, 0]) + r = self._validate_rotation(rotation_matrix or IDENTITY) + c, status = part.ComponentAssembly.AddComponent( + str(path), + "Entire Part", + name or path.stem, + self.nxopen.Point3d(*t), + self._nx_matrix(r), + -1, + ) + if status: + status.Dispose() + return { + "component": c.Name, + "object": self._reference(c, "component", part, "Component"), + "path": str(path), + "translation": t, + "rotation_matrix": r, + "coordinate_frame": "work_part", + "message": "Added and placed component", + } + + @staticmethod + def _validate_rotation(matrix): + if len(matrix) != 3: + raise NXToolError("NX_INVALID_ARGUMENT", "rotation_matrix must be 3x3") + m = [vector(r) for r in matrix] + if ( + any( + abs(dot(m[i], m[j]) - (1 if i == j else 0)) > 1e-8 + for i in range(3) + for j in range(3) + ) + or abs(dot(m[0], cross(m[1], m[2])) - 1) > 1e-8 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "rotation_matrix must be a right-handed orthonormal matrix" + ) + return m + + def _set_component_transform(self, component, translation, rotation_matrix): + c = self._resolve(component, {"component"}) + part = self._work_part() + if c.Parent != part.ComponentAssembly.RootComponent: + raise NXToolError( + "NX_UNSUPPORTED_ARGUMENT", + "Absolute placement currently supports immediate children; activate their owning subassembly", + ) + t = vector(translation) + r = self._validate_rotation(rotation_matrix) + p, m = c.GetPosition() + delta = matmul(r, transpose(rows(m))) + # NX MoveComponent rotates orientation about the component origin and adds translation. + shift = [a - b for a, b in zip(t, xyz(p), strict=False)] + part.ComponentAssembly.MoveComponent( + c, self.nxopen.Vector3d(*shift), self._nx_matrix(delta) + ) + actual_p, actual_m = c.GetPosition() + if any(abs(a - b) > 1e-7 for a, b in zip(xyz(actual_p), t, strict=False)) or any( + abs(rows(actual_m)[i][j] - r[i][j]) > 1e-7 for i in range(3) for j in range(3) + ): + raise NXToolError( + "NX_PLACEMENT_MISMATCH", "Placement read-back differs; transaction will roll back" + ) + return { + "object": self._reference(c, "component", part, "Component"), + "translation": xyz(actual_p), + "rotation_matrix": rows(actual_m), + "coordinate_frame": "work_part", + } + + def _reposition_component(self, component, dx=0, dy=0, dz=0, rx=0, ry=0, rz=0): + c = self._resolve(component, {"component"}) + p, m = c.GetPosition() + a, b, d = [math.radians(v) for v in vector([rx, ry, rz])] + sx, cx, sy, cy, sz, cz = ( + math.sin(a), + math.cos(a), + math.sin(b), + math.cos(b), + math.sin(d), + math.cos(d), + ) + r = [ + [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx], + [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx], + [-sy, cy * sx, cy * cx], + ] + return self._set_component_transform( + component, add(xyz(p), vector([dx, dy, dz])), matmul(r, rows(m)) + ) + + def _batch(self, operations): + if not 1 <= len(operations) <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Batch requires 1–100 operations") + allowed = { + "nx_sketch_line", + "nx_sketch_rectangle", + "nx_sketch_arc", + "nx_add_component", + "nx_set_component_transform", + "nx_reposition_component", + } + # Preflight is structural and validates all signatures before any NX mutation. + for op in operations: + if set(op) != {"method", "params"} or op["method"] not in allowed: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Batch supports sketch curves and assembly placement only", + ) + inspect.signature(self._handlers[op["method"]]).bind(**op["params"]) + results = [] + key = self._current_operation + for i, op in enumerate(operations): + if self.store.path(key).with_suffix(".cancel").exists(): + raise NXToolError( + "NX_CANCELLED", + "Cancelled between operations", + details={"completed_before_rollback": i}, + ) + result = self._handlers[op["method"]](**op["params"]) + results.append({"index": i, "method": op["method"], "result": result}) + record = self.store.get(key) + record.update(progress={"completed": i + 1, "total": len(operations)}) + self.store.put(record) + return { + "results": results, + "count": len(results), + "atomic": True, + "message": "Batch committed on NX journal thread", + } + + def _capabilities(self, tool=None, prefix=None): + import json + + manifest = json.loads(Path(__file__).with_name("capability_manifest.json").read_text()) + part = self._work_part(required=False) + manifest.update( + session_id=self.session_id, + actual_nx_version=self.nx_version, + execution=( + "serialized NX batch journal thread" + if self.session.IsBatch + else "serialized NX UI thread; visible model viewport" + ), + interactive=not self.session.IsBatch, + api_detection={ + "native_display_modification": hasattr( + self.session.DisplayManager, "NewDisplayModification" + ), + "dynamic_sections": bool(part and hasattr(part, "DynamicSections")), + "sketch_solver_status": hasattr(self.nxopen.Sketch, "CalculateStatus"), + "step_import": hasattr(self.session.DexManager, "CreateStep214Importer"), + "native_pattern": bool( + part and hasattr(part.Features, "CreatePatternFeatureBuilder") + ), + "minimum_distance": hasattr(self.session.Measurement, "GetMinimumDistance"), + "native_component_pattern": bool( + part and hasattr(part.ComponentAssembly, "CreateComponentPatternBuilder") + ), + "sketch_dimensions": hasattr(self.nxopen.Sketch, "CreateDimension"), + }, + coordinate_conventions={ + "lengths": "work-part units unless explicitly named mm3", + "angles": "degrees", + "rotation_order": "Rz * Ry * Rx", + "matrix": "row-major 3x3; p_parent = R*p_local + t", + "legacy_rotation_list": "NX axis vectors X,Y,Z, not row-major", + "sketch_XZ_normal": [0, -1, 0], + }, + ) + if self.session.IsBatch: + for name in ("nx_screenshot", "nx_render_view", "nx_ui_control"): + manifest["tools"][name].update( + status="unavailable", scope="Requires the interactive NX host" + ) + if self.nx_version != "v2606": + for entry in manifest["tools"].values(): + entry.update(status="experimental", scope="This NX version has not been tested") + if tool is not None and prefix is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "Use tool or prefix, not both") + total = len(manifest["tools"]) + if tool is not None: + if tool not in manifest["tools"]: + raise NXToolError("NX_NOT_FOUND", "Tool is not in the capability manifest") + manifest["tools"] = {tool: manifest["tools"][tool]} + elif prefix is not None: + manifest["tools"] = {k: v for k, v in manifest["tools"].items() if k.startswith(prefix)} + manifest.update(tool_count=len(manifest["tools"]), total_tool_count=total, units=None) + return manifest + + def _finish_sketch(self, sketch_id): + result = super()._finish_sketch(sketch_id) + sketch = self.objects.resolve(sketch_id) + result["object"] = self._reference(sketch, "sketch", self._work_part(), "Sketch") + return result + + def _snapshot(self, part): + groups = [ + ("explosion", self._explosions(part)), + ("modeling_view", self._modeling_views(part)), + ("assembly_constraint", self._assembly_constraints(part)), + ("drawing_sheet", getattr(part, "DrawingSheets", [])), + ("drawing_view", getattr(part, "DraftingViews", [])), + ("dimension", getattr(part, "Dimensions", [])), + ("annotation", self._documentation_annotations(part)), + ("traceline", getattr(part, "Tracelines", [])), + ( + "component_pattern", + self._component_patterns(part), + ), + ("expression", getattr(part, "Expressions", [])), + ("body", part.Bodies), + ("feature", part.Features), + ("curve", part.Curves), + ("point", getattr(part, "Points", [])), + ("sketch", part.Sketches), + ("section", getattr(part, "DynamicSections", [])), + ("component", [c for c, _ in self._walk_components(part)]), + ] + return { + (kind, int(v.Tag)): self._reference(v, kind, part, kind.title()) + for kind, values in groups + for v in values + } + + def _invalidate_deleted(self, before, after, invalidate_topology=True): + removed = {v["id"] for k, v in before.items() if k not in after} + for key, entry in list(self.objects._objects.items()): + if key in removed or (invalidate_topology and entry.reference.kind in {"face", "edge"}): + self.objects._objects.pop(key, None) + self.objects._stale_ids.add(key) + self.objects._identities = { + k: v for k, v in self.objects._identities.items() if v in self.objects._objects + } + + def _record_reverted(self, history, by): + for item in history: + record = self.store.get(item["operation_id"]) + if "fingerprint" in record: + record["reverted_by"] = by + self.store.put(record) + + def _measure_volume(self, body=None, scope="auto"): + part = self._work_part() + bodies = self._geometry(body, scope) + units = [ + part.UnitCollection.FindObject(n) + for n in ["SquareMilliMeter", "CubicMilliMeter", "Kilogram", "MilliMeter", "Newton"] + ] + result = [] + for b in bodies: + if not b.IsSolidBody: + raise NXToolError("NX_NOT_SOLID", "Volume requires solid bodies") + props = part.MeasureManager.NewMassProperties(units, 0.999, [b]) + try: + props.InformationUnit = self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter + result.append( + { + "body": self._reference(b, "body", part, "Body"), + "volume_mm3": float(props.Volume), + } + ) + finally: + props.Dispose() + return { + "bodies": result, + "body_count": len(result), + "volume_mm3": sum(r["volume_mm3"] for r in result), + "volume_units": "mm^3", + "semantics": "sum_of_included_bodies", + "scope": scope, + "warnings": [ + "Overlapping bodies are counted separately; this is not union volume or a mass estimate." + ], + } + + def _export_step(self, path): + import hashlib + + result = super()._export_step(path) + file = Path(result["path"]) + result.update( + size=file.stat().st_size, + sha256=hashlib.sha256(file.read_bytes()).hexdigest(), + units=self._units(), + components=self._list_components()["count"], + options={ + "translator": "StepCreator", + "schema": "AP214", + "solids": True, + "surfaces": True, + "curves": False, + "layers": "1-256", + }, + validation="Nonempty file with solid BREP records; geometry equivalence requires round-trip validation", + warnings=["Export saves the part; native NX undo/checkpoints can expire."], + ) + return result + + def _screenshot( + self, + path=None, + width=1600, + height=1000, + background="white", + style="shaded_with_edges", + fit=False, + ): + return self._capture_view(path, width, height, background, style, fit) diff --git a/src/nx_mcp/http_surface.py b/src/nx_mcp/http_surface.py new file mode 100644 index 0000000..47087a4 --- /dev/null +++ b/src/nx_mcp/http_surface.py @@ -0,0 +1,58 @@ +"""Serve compatible and compact profiles against one serial NX bridge.""" + +from contextlib import AsyncExitStack, asynccontextmanager + +from mcp.server.transport_security import TransportSecuritySettings +from starlette.applications import Starlette +from starlette.routing import Mount + +from nx_mcp.server import create_server + + +def create_app(bridge, workspace=None, host="192.168.52.10", port=8765): + servers = [create_server(bridge, workspace, surface=profile) for profile in ("full", "agent")] + for server in servers: + server.settings.streamable_http_path = "/mcp" + server.settings.json_response = True + server.settings.stateless_http = True + server.settings.transport_security = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[f"{address}:{port}" for address in (host, "127.0.0.1", "localhost")], + allowed_origins=[ + f"http://{address}:{port}" for address in (host, "127.0.0.1", "localhost") + ], + ) + apps = [server.streamable_http_app() for server in servers] + + @asynccontextmanager + async def lifespan(app): + async with AsyncExitStack() as stack: + for server in servers: + await stack.enter_async_context(server.session_manager.run()) + yield + + return Starlette( + routes=[Mount("/agent", app=apps[1]), Mount("/", app=apps[0])], lifespan=lifespan + ) + + +def main(): + import os + from pathlib import Path + + import uvicorn + + from nx_mcp.bridge import DescriptorBridgeClient + + descriptor = Path( + os.environ.get( + "NX_MCP_BRIDGE_DESCRIPTOR", + str(Path(os.environ["LOCALAPPDATA"]) / "nx-mcp" / "bridge.json"), + ) + ) + app = create_app(DescriptorBridgeClient(descriptor, timeout=120)) + uvicorn.run(app, host="0.0.0.0", port=8765) + + +if __name__ == "__main__": + main() diff --git a/src/nx_mcp/inspection.py b/src/nx_mcp/inspection.py new file mode 100644 index 0000000..b7a913e --- /dev/null +++ b/src/nx_mcp/inspection.py @@ -0,0 +1,341 @@ +"""Native geometry inspection and viewport export, verified against installed NX APIs.""" + +from __future__ import annotations + +import hashlib +import itertools +import math +import struct +import uuid + +from nx_mcp.runtime import NXToolError + + +def envelope_gap(a, b): + return math.sqrt(sum(max(a[i] - b[i + 3], b[i] - a[i + 3], 0) ** 2 for i in range(3))) + + +class InspectionMixin: + def _view_info(self): + from nx_mcp.hardened import rows, xyz + + part = self.session.Parts.Display + if part is None: + raise NXToolError("NX_NO_DISPLAY_PART", "Open a display part first") + view = part.ModelingViews.WorkView + return { + "part": self._reference(part, "part", part, "Display part"), + "name": view.Name, + "rotation": rows(view.Matrix), + "origin": xyz(view.Origin), + "absolute_origin": xyz(view.AbsoluteOrigin), + "scale": view.Scale, + "coordinate_frame": "display_part", + "matrix_layout": "row-major 3x3; columns are NX view axes", + "rendering_style": next( + ( + name + for name, member in [ + ("shaded", self.nxopen.View.RenderingStyleType.Shaded), + ( + "studio", + getattr(self.nxopen.View.RenderingStyleType, "Studio", object()), + ), + ("shaded_with_edges", self.nxopen.View.RenderingStyleType.ShadedWithEdges), + ("wireframe", self.nxopen.View.RenderingStyleType.StaticWireframe), + ] + if member == view.RenderingStyle + ), + "nx_style_" + str(view.RenderingStyle), + ), + "interactive": not self.session.IsBatch, + } + + def _capture_view( + self, + path=None, + width=1600, + height=1000, + background="white", + style="shaded_with_edges", + fit=False, + ): + import NXOpen.Gateway + + if self.session.IsBatch: + raise NXToolError( + "NX_VIEWPORT_UNAVAILABLE", + "Viewport export requires the interactive NX bridge; no desktop capture fallback", + ) + if ( + type(width) is not int + or type(height) is not int + or not (128 <= width <= 4096 and 128 <= height <= 4096) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "width and height must be 128–4096 pixels") + if background not in {"white", "original", "transparent"} or style not in { + "current", + "shaded", + "shaded_with_edges", + "wireframe", + }: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported background or rendering style") + file = ( + self.workspace.ensure_inside(path) + if path + else self.workspace.root / "captures" / ("nx-" + uuid.uuid4().hex + ".png") + ) + if file.suffix.lower() != ".png": + raise NXToolError("NX_INVALID_ARGUMENT", "Viewport export requires a .png path") + if file.exists(): + raise NXToolError("NX_FILE_EXISTS", "Choose a new capture path") + file.parent.mkdir(parents=True, exist_ok=True) + part = self.session.Parts.Display + if not part: + raise NXToolError("NX_NO_DISPLAY_PART", "Open a display part first") + view = part.ModelingViews.WorkView + old_style = view.RenderingStyle + builder = None + try: + styles = { + "shaded": "Shaded", + "shaded_with_edges": "ShadedWithEdges", + "wireframe": "StaticWireframe", + } + if style != "current": + view.RenderingStyle = getattr(self.nxopen.View.RenderingStyleType, styles[style]) + if fit: + view.Fit() + view.UpdateDisplay() + camera = self._view_info() + builder = part.Views.CreateImageExportBuilder() + builder.FileName = str(file) + builder.FileFormat = NXOpen.Gateway.ImageExportBuilder.FileFormats.Png + builder.RegionMode = False + builder.DeviceWidth = width + builder.DeviceHeight = height + backgrounds = NXOpen.Gateway.ImageExportBuilder.BackgroundOptions + builder.BackgroundOption = { + "white": backgrounds.CustomColor, + "original": backgrounds.Original, + "transparent": backgrounds.Transparent, + }[background] + if background == "white": + builder.SetCustomBackgroundColor([1.0, 1.0, 1.0]) + builder.EnhanceEdges = True + builder.Commit() + finally: + try: + if builder: + builder.Destroy() + finally: + view.RenderingStyle = old_style + data = file.read_bytes() + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": + raise NXToolError("NX_CAPTURE_FAILED", "NX did not produce a valid PNG") + resolution = list(struct.unpack(">II", data[16:24])) + return { + "path": str(file), + "artifact_path": str(file.relative_to(self.workspace.root)), + "capture_kind": "nx_model_viewport", + "model_preview": True, + "resolution": resolution, + "requested_resolution": [width, height], + "camera": camera, + "background": background, + "rendering": "NX viewport rasterization", + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "warnings": ( + [] + if resolution == [width, height] + else ["NX returned a different resolution than requested"] + ), + } + + def _interference_pair(self, a, b): + import NXOpen.GeometricAnalysis + + part = self._work_part() + baseline = ([int(x.Tag) for x in part.Bodies], [int(x.Tag) for x in part.Features]) + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP temporary interference" + ) + builder = None + try: + builder = part.AnalysisManager.CreateSimpleInterferenceObject() + builder.InterferenceType = ( + NXOpen.GeometricAnalysis.SimpleInterference.InterferenceMethod.InterferenceSolid + ) + builder.FirstBody.Value = a + builder.SecondBody.Value = b + result = builder.PerformCheck() + enum = NXOpen.GeometricAnalysis.SimpleInterference.Result + volume = 0.0 + if result == enum.InterferenceExists: + classification = "penetration" + units = [ + part.UnitCollection.FindObject(n) + for n in [ + "SquareMilliMeter", + "CubicMilliMeter", + "Kilogram", + "MilliMeter", + "Newton", + ] + ] + for body in builder.GetInterferenceResults(): + if not body.IsSolidBody: + continue + props = part.MeasureManager.NewMassProperties(units, 0.999, [body]) + try: + props.InformationUnit = ( + self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter + ) + volume += float(props.Volume) + finally: + props.Dispose() + elif result == enum.OnlyEdgesOrFacesInterfere: + classification = "contact" + elif result == enum.NoInterference: + classification = "clear" + else: + raise NXToolError( + "NX_INTERFERENCE_UNRESOLVED", "Native NX could not classify this pair" + ) + return {"classification": classification, "interference_volume_mm3": volume} + finally: + try: + if builder: + try: + builder.Reset() + finally: + builder.Destroy() + finally: + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + after = ([int(x.Tag) for x in part.Bodies], [int(x.Tag) for x in part.Features]) + if after != baseline: + raise RuntimeError("Temporary interference geometry remains") + except Exception as exc: + raise NXToolError( + "NX_ROLLBACK_FAILED", + "Interference cleanup failed: " + str(exc), + details={"mutation_outcome": "partial"}, + ) from exc + + def _check_clearance( + self, objects=None, minimum_clearance=0.0, max_pairs=1000, include_clear=False + ): + + if ( + not math.isfinite(minimum_clearance) + or minimum_clearance < 0 + or type(max_pairs) is not int + or not 1 <= max_pairs <= 10000 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Require nonnegative finite clearance and 1–10000 max_pairs" + ) + if objects is not None and (not isinstance(objects, list) or len(objects) < 2): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Specify at least two references or omit objects for the assembly", + ) + # Flatten selected groups, deduplicate occurrences, then inspect each distinct body pair once. + bodies = ( + list({int(b.Tag): b for ref in objects for b in self._geometry(ref)}.values()) + if objects + else self._geometry(scope="assembly") + ) + if any(not b.IsSolidBody for b in bodies): + raise NXToolError("NX_NOT_SOLID", "Clearance requires solid bodies") + return self._analyze_pairs( + bodies, + list(itertools.combinations(bodies, 2)), + minimum_clearance, + max_pairs, + include_clear, + ) + + def _analyze_pairs(self, bodies, pairs, minimum_clearance, max_pairs, include_clear): + import NXOpen.UF + + from nx_mcp.hardened import xyz + + n = len(pairs) + if n > max_pairs: + raise NXToolError( + "NX_PAIR_LIMIT", + f"{n} pairs exceed max_pairs={max_pairs}; select smaller groups or raise the explicit limit", + ) + part = self._work_part() + uf = NXOpen.UF.UFSession.GetUFSession() + boxes = {int(b.Tag): list(uf.ModlGeneral.AskBoundingBox(b.Tag)) for b in bodies} + reports = [] + skipped = 0 + counts = {"penetration": 0, "contact": 0, "below_clearance": 0, "clear": 0} + for a, b in pairs: + gap = envelope_gap(boxes[int(a.Tag)], boxes[int(b.Tag)]) + if gap > minimum_clearance + 1e-7 and not include_clear: + skipped += 1 + counts["clear"] += 1 + continue + distance, p1, p2, accuracy = self.session.Measurement.GetMinimumDistance(a, b) + hit = ( + self._interference_pair(a, b) + if distance <= 1e-7 + else {"classification": "clear", "interference_volume_mm3": 0.0} + ) + if hit["classification"] == "clear" and distance < minimum_clearance: + hit["classification"] = "below_clearance" + counts[hit["classification"]] += 1 + if hit["classification"] != "clear" or include_clear: + reports.append( + { + **hit, + "objects": [ + self._reference(x, "body", part, "Body occurrence") for x in [a, b] + ], + "distance": distance, + "closest_points": [xyz(p1), xyz(p2)], + "accuracy": None, + "accuracy_note": "No validated numerical error bound is exposed by this NX binding", + "method": "NX minimum distance + native solid interference" + if distance <= 1e-7 + else "NX minimum distance", + } + ) + return { + "pairs": reports, + "counts": counts, + "body_count": len(bodies), + "pair_count": n, + "reported_pair_count": len(reports), + "broad_phase_clear_pairs": skipped, + "minimum_clearance": minimum_clearance, + "units": self._units(), + "volume_units": "mm^3", + "coordinate_frame": "work_part", + "complete": True, + "semantics": "Pairwise body occurrence checks; interference volumes are not a geometric union", + "warnings": [ + "Broad-phase clear pairs use conservative bounding separation; reported distances use native geometry." + ], + } + + def _check_interference(self, obj1, obj2): + a = self._geometry(obj1) + b = self._geometry(obj2) + if any(not x.IsSolidBody for x in a + b): + raise NXToolError("NX_NOT_SOLID", "Interference requires solid bodies") + unique = { + tuple(sorted((int(x.Tag), int(y.Tag)))): (x, y) for x in a for y in b if x.Tag != y.Tag + } + if not unique: + raise NXToolError("NX_INVALID_ARGUMENT", "References resolve only to the same body") + bodies = list({int(x.Tag): x for x in a + b}.values()) + result = self._analyze_pairs(bodies, list(unique.values()), 0.0, 10000, True) + result["selected_references"] = [obj1, obj2] + return result diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py new file mode 100644 index 0000000..6abe993 --- /dev/null +++ b/src/nx_mcp/integration_server.py @@ -0,0 +1,1086 @@ +"""Uniform MCP envelopes and workspace-scoped artifacts for the NX 2606 bridge.""" + +from __future__ import annotations + +import base64 +import hashlib +import inspect +import json +import os +import struct +import uuid +from typing import Annotated, Literal + +from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations +from pydantic import BaseModel, ConfigDict, Field + +from nx_mcp import ( + assembly_documentation_server, + authoring_server, + documentation_editing_server, + freeform_server, + manufacturing_server, + sheet_metal_server, +) +from nx_mcp.output_schemas import output_schema +from nx_mcp.recovery import OperationStore +from nx_mcp.runtime import NXToolError +from nx_mcp.workspace import WorkspaceViolation + + +def nx_list_open_parts( + compact: bool = False, + path_prefix: str | None = None, + modified: bool | None = None, + active_only: bool = False, + offset: int = 0, + limit: int | None = None, +): + """List loaded parts. Defaults preserve full inventory. compact retains opaque identity; path_prefix matches an absolute NX-host path (for example D:/CAD/NX_MCP_WORKSPACE/validation/), case-insensitively with either slash style; workspace-relative prefixes do not match. active_only selects work/display parts. Filters precede paging; count is returned rows, total_count is matching rows. Read-only; never saves parts.""" + + +def nx_list_components( + compact: bool = False, + include_transforms: bool = True, + name_contains: str | None = None, + suppressed: bool | None = None, + offset: int = 0, + limit: int | None = None, +): + """List recursive component occurrences without loading prototypes. load_state reports fully_loaded, partially_loaded or unloaded; part_path can be null if NX cannot resolve it. Unloaded subassemblies may hide descendants. Use nx_open_part(load_components=true) for explicit recovery. compact retains occurrence paths and omits repeated identity metadata and legacy rotation. include_transforms=False omits pose fields. Name filtering is case-insensitive. Filters precede paging; total_count is matching rows. Defaults preserve existing full results.""" + + +def nx_flat_pattern_orientation_edges(upward_face: str): + """List current straight boundary edges of an owned planar sheet-metal web face, with endpoints and adjacent face IDs. Pass a returned edge ID as flat_pattern.x_axis_edge. Geometric eligibility only; NX validates the final feature. Does not mutate or invalidate references.""" + + +def nx_list_reference_sets(): + """List work-part custom reference sets, exact direct members and automatic-add setting. Built-in Entire Part and Empty are listed separately.""" + + +def nx_create_reference_set(name: str, objects: list[str]): + """Create a custom reference set with explicit owned body/curve/datum/direct-component IDs. Use only solid body IDs to exclude prototype datums from assembly drawings. No automatic component membership; duplicate names are rejected. Changes the work part; save it to persist.""" + + +def nx_set_component_reference_set(components: list[str], name: str): + """Assign an exact existing prototype reference-set name to direct children of the work assembly. Activate a nested owning assembly before editing its children. Preflights every target; does not change prototype membership or component poses. Returns previous/current assignments. Save the assembly to persist.""" + + +def nx_list_datums(): + """List work-part datum planes, axes and coordinate systems with IDs and blanked state. Does not recurse into component prototypes.""" + + +def nx_set_datum_visibility(visible: bool = False): + """Show/hide all owned datums and coordinate systems in the work/display part. Returns restore_id for nx_restore_display. To exclude component prototype datums from drawings, assign a body-only reference set instead. Changes can persist when saved.""" + + +def nx_boolean( + boolean_type: Literal["unite", "subtract", "intersect"], + targets: Annotated[list[str], Field(min_length=2)], +): + pass + + +def nx_display_info(objects: list[str], count_only: bool = False): + pass + + +def nx_set_display( + objects: list[str], + color_index: int | None = None, + transparency: int | None = None, + color: Literal[ + "red", "green", "blue", "yellow", "cyan", "magenta", "orange", "white", "black", "gray" + ] + | None = None, +): + pass + + +def nx_set_visibility(objects: list[str], mode: Literal["show", "hide", "isolate"] = "show"): + pass + + +def nx_restore_display(restore_id: str): + pass + + +def nx_highlight_collisions(obj1: str, obj2: str, include_contact: bool = False): + pass + + +def nx_clear_highlights(): + pass + + +def nx_list_sections(): + pass + + +def nx_section_view( + origin: list[float], + normal: list[float], + section: str | None = None, + name: str = "MCP section", + cap: bool = True, +): + pass + + +def nx_section_control(section: str, action: Literal["enable", "disable", "delete"]): + pass + + +def nx_sketch_diagnostics(sketch_id: str): + pass + + +def nx_ui_control(mode: Literal["status", "manual", "agent"] = "status"): + pass + + +def nx_view_info(): + pass + + +def nx_screenshot( + path: str | None = None, + width: int = 1600, + height: int = 1000, + background: Literal["white", "original", "transparent"] = "white", + style: Literal["current", "shaded", "shaded_with_edges", "wireframe"] = "shaded_with_edges", + fit: bool = False, +): + pass + + +def nx_check_interference(obj1: str, obj2: str): + pass + + +def nx_check_clearance( + objects: list[str] | None = None, + minimum_clearance: float = 0.0, + max_pairs: int = 1000, + include_clear: bool = False, +): + pass + + +# Signature-only definitions are used to publish the actual bridge arguments. +def nx_create_sketch( + plane: Literal["XY", "XZ", "YZ"] = "XY", + name: str | None = None, + origin: list[float] | None = None, + x_axis: list[float] | None = None, + y_axis: list[float] | None = None, +): + pass + + +def nx_open_part(path: str, work: bool = True, display: bool = True, load_components: bool = False): + pass + + +def nx_activate_part(part: str, work: bool = True, display: bool = True): + pass + + +def nx_close_part(save: bool = True, part: str | None = None): + pass + + +def nx_sketch_constraint( + constraint_type: Literal[ + "horizontal", + "vertical", + "fix", + "fixed", + "parallel", + "perpendicular", + "equal_length", + "equal_radius", + "concentric", + "tangent", + "coincident", + "distance", + "length", + "radius", + "diameter", + "angle", + ], + targets: list[str], + value: float | None = None, +): + """Apply a supported native constraint to curves owned by one sketch.""" + + +def nx_sketch_info(sketch_id: str): + pass + + +def nx_sketch_arc( + cx: float, + cy: float, + radius: float, + start_angle: float, + end_angle: float, + sketch_id: str | None = None, +): + pass + + +def nx_edit_feature(name: str, params: dict[str, float]): + pass + + +def nx_pattern( + features: list[str], + pattern_type: Literal["linear"] = "linear", + direction: Literal["X", "Y", "Z", "-X", "-Y", "-Z"] = "X", + spacing: float = 10, + count: int = 2, +): + pass + + +def nx_import_geometry( + path: str, + flatten: bool = False, + target: Literal["work_part", "new_part"] = "work_part", + output_path: str | None = None, +): + pass + + +def nx_get_bounding_box( + body: str | None = None, + scope: Literal["auto", "part", "assembly"] = "auto", + precision: Literal["conservative", "exact"] = "conservative", +): + pass + + +def nx_checkpoint(label: str = "checkpoint"): + pass + + +def nx_checkpoint_state(): + pass + + +def nx_rollback(checkpoint_id: str): + pass + + +def nx_operation_status(operation_id: str): + pass + + +def nx_cancel_operation(operation_id: str): + pass + + +def nx_list_topology( + body: str, face: str | None = None, include_adjacency: bool = False, compact: bool = False +): + pass + + +def nx_measure_volume(body: str | None = None, scope: Literal["auto", "part", "assembly"] = "auto"): + pass + + +def nx_package_assembly(path: str): + pass + + +def nx_add_component( + part_path: str, + name: str | None = None, + translation: list[float] | None = None, + rotation_matrix: list[list[float]] | None = None, +): + pass + + +def nx_set_component_transform( + component: str, translation: list[float], rotation_matrix: list[list[float]] +): + pass + + +def nx_read_result( + result_id: str, + field: str = "", + offset: Annotated[int, Field(ge=0)] = 0, + limit: Annotated[int, Field(ge=1, le=100)] = 20, +): + """Read a stored oversized bridge result without repeating its operation. field is a JSON pointer; arrays/strings are paged. Nested omissions remain explicit. Snapshot expiry does not delete operation receipts.""" + + +class BatchParameters(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class SketchPoint(BatchParameters): + x: float + y: float + + +class SegmentParameters(BatchParameters): + sketch_id: str + start: SketchPoint + end: SketchPoint + + +class RectangleParameters(BatchParameters): + sketch_id: str + corner1: SketchPoint + corner2: SketchPoint + + +class ArcParameters(BatchParameters): + cx: float + cy: float + radius: Annotated[float, Field(gt=0)] + start_angle: float + end_angle: float + sketch_id: str | None = None + + +Vector3 = Annotated[list[float], Field(min_length=3, max_length=3)] +Matrix3 = Annotated[list[Vector3], Field(min_length=3, max_length=3)] + + +class ComponentParameters(BatchParameters): + part_path: str + name: str | None = None + translation: Vector3 | None = None + rotation_matrix: Matrix3 | None = None + + +class TransformParameters(BatchParameters): + component: str + translation: Vector3 + rotation_matrix: Matrix3 + + +class RepositionParameters(BatchParameters): + component: str + dx: float = 0 + dy: float = 0 + dz: float = 0 + rx: float = 0 + ry: float = 0 + rz: float = 0 + + +class SegmentOperation(BatchParameters): + method: Literal["nx_sketch_line"] + params: SegmentParameters + + +class RectangleOperation(BatchParameters): + method: Literal["nx_sketch_rectangle"] + params: RectangleParameters + + +class ArcOperation(BatchParameters): + method: Literal["nx_sketch_arc"] + params: ArcParameters + + +class ComponentOperation(BatchParameters): + method: Literal["nx_add_component"] + params: ComponentParameters + + +class TransformOperation(BatchParameters): + method: Literal["nx_set_component_transform"] + params: TransformParameters + + +class RepositionOperation(BatchParameters): + method: Literal["nx_reposition_component"] + params: RepositionParameters + + +BatchOperation = Annotated[ + SegmentOperation + | RectangleOperation + | ArcOperation + | ComponentOperation + | TransformOperation + | RepositionOperation, + Field(discriminator="method"), +] + + +def nx_batch(operations: Annotated[list[BatchOperation], Field(min_length=1, max_length=100)]): + pass + + +def nx_capabilities(tool: str | None = None, prefix: str | None = None): + pass + + +def nx_rename_object(object_id: str, name: str): + pass + + +def nx_revolve( + sketch_name: Annotated[str, Field(min_length=1)], + angle: float = 360, + axis: Literal["X", "Y", "Z", "-X", "-Y", "-Z"] = "Z", + boolean: Literal["none", "unite", "subtract", "intersect"] = "none", + axis_origin: Vector3 | None = None, + axis_direction: Vector3 | None = None, +): + pass + + +def nx_workspace_info(): + pass + + +def nx_create_directory(path: str): + pass + + +def nx_workspace_list( + path: str = ".", + offset: Annotated[int, Field(ge=0)] = 0, + limit: Annotated[int, Field(ge=1, le=1000)] = 100, + prefix: str = "", +): + pass + + +def nx_download_file( + path: str, + offset: Annotated[int, Field(ge=0)] = 0, + length: Annotated[int, Field(ge=1, le=262144)] = 262144, + delivery: Literal["base64", "image", "metadata"] = "base64", +): + pass + + +def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, offset: int = 0): + pass + + +DESCRIPTIONS = { + "nx_export_step": "Export the active work part as STEP inside the workspace. Saves the part before translation; native undo marks and checkpoints can expire. Use a disposable copy for review-only exports when source saves are unwanted. Returns path, size, SHA-256, units, component count, translator options and validation scope.", + "nx_boolean": "Boolean solid bodies: unite, subtract or intersect. targets[0] is the target body; targets[1:] are tool bodies. Native cube subtraction and volume checks are scoped in nx_capabilities(tool='nx_boolean'); not general certification.", + "nx_revolve": "Requires sketch_name (finished sketch ID/name). Revolve about a principal axis through the part origin, or supply both axis_origin=[x,y,z] and nonzero axis_direction in work-part coordinates. Custom axes require leaving axis at its default Z. Angles are degrees, lengths in work-part units; boolean is none/unite/subtract/intersect. Inspect nx_capabilities(tool='nx_revolve') for tested scope.", + "nx_workspace_list": "List a workspace directory with prefix filtering and pagination (offset>=0, limit=1..1000, default 100). Returns entries/count for this page, total_count and next_offset. File entries include size/SHA-256. Use nx_download_file(delivery='metadata') to inspect one file.", + "nx_workspace_info": "Discover the NX host workspace root and path rules. Paths refer to the NX machine, not the MCP client's filesystem. No session-wide current directory is changed.", + "nx_create_directory": "Create a directory and missing parents inside the NX workspace. Accepts workspace-relative or in-workspace absolute host paths. Idempotent: an existing directory succeeds; an existing file fails. Returns actual path and created status.", + "nx_create_part": "Create a new NX part at an explicit workspace-relative or absolute in-workspace NX-host path, e.g. projects/controller/parts/base.prt. Missing parent folders are created. Units: mm or inch. Use unique part basenames for simultaneously loaded NX parts.", + "nx_delete_feature": "Delete a work-part feature by typed ID or unambiguous name using native update/undo. Dependent geometry may be deleted; inspect changes.deleted and reacquire topology afterward.", + "nx_measure_angle": "Measure 0..180 degrees between typed work-part line, straight-edge or planar-face references. Uses line start/end, edge vertex order or outward face normals. Curved entities and component occurrences are unsupported; directions are not an oriented dihedral angle.", + "nx_sketch_constraint": "Apply a constraint to owned curve IDs in one sketch. Types: horizontal, vertical, fix/fixed, parallel, perpendicular, equal_length, equal_radius, concentric, tangent, coincident, distance/length, radius, diameter, angle. Only dimensions require value in part units or degrees. Coincident means start-to-start; use nx_sketch_relation for explicit endpoints. Midpoint is not supported.", + "nx_save_as": "Save the active work part to a new .prt path inside the NX workspace, creating missing parent folders. Accepts relative or absolute NX-host paths. Existing files are never overwritten. Save As changes the work part's filename; it does not move an entire assembly dependency tree.", + "nx_display_info": "Inspect color-table indices, blank state and face transparency for body, component, feature, face or curve references. Components expand to loaded occurrence geometry.", + "nx_set_display": "Set an NX color index (1–216) or named color, and/or transparency (0 opaque, 100 transparent). Component/feature targets expand to bodies and faces, with a cap of 10000 unique objects. Preflight with nx_display_info(count_only=true); split larger selections after checking counts. Occurrence overrides do not recolor prototypes. Returns restore_id; restore in reverse order. Changes can persist on save.", + "nx_set_visibility": "Show, hide or isolate body/component geometry. Isolation preserves a restorable snapshot and includes ancestor components. Reference curves and datum geometry are not isolated. Explicit show/hide also accepts curves. Returns restore_id.", + "nx_restore_display": "Restore explicit appearance/visibility attributes using a same-session restore_id, in reverse order. All references are preflighted; manual handoff, rollback or close can make snapshots stale. Does not reset a part modified flag or remove inherited occurrence overrides.", + "nx_highlight_collisions": "Measure native solid interference and highlight the involved body occurrences using NX selection highlighting. Replaces previous MCP highlights. Contacts are optional; clear pairs are never highlighted. Returns measured pairs and entity references. No persistent recoloring.", + "nx_clear_highlights": "Remove only highlights created by MCP. Geometry, persistent colors and visibility are unchanged.", + "nx_list_sections": "Inspect native dynamic sections and the active view clipping toggle in the display/work part.", + "nx_section_view": "Create or edit a native single-plane section in visible NX. origin is in display-part units; normal is normalized in display-part coordinates. Solids are unchanged. Specify section ID to edit an existing active section. NX v2606 retains dot(point-origin, normal) <= 0; reversing normal reverses the retained side. Returns actual plane geometry.", + "nx_section_control": "Enable, disable or delete the specified native section. Disabling turns off clipping when that section is active. Deletion removes the section object, not model solids.", + "nx_sketch_diagnostics": "Evaluate native solver status and remaining DOF for the entire sketch; enumerate persistent constraints and their curve links. Temporarily activates an inactive sketch and restores the prior state. Rejects another active sketch. Temporarily evaluates the entire sketch and restores the work-region state. Does not infer a minimal conflict set or automatically constrain geometry.", + "nx_ui_control": "Inspect UI activity without queueing behind native work, or switch control modes. Agent mode reserves NX input even when idle. Status is a timestamped snapshot, not proof that a native call is responsive. Manual handoff invalidates references/checkpoints. Pause/stop cannot interrupt a native call. Finish NX dialogs before resuming.", + "nx_view_info": "Return the displayed model view, camera matrix, scale, rendering style, and interactive state.", + "nx_screenshot": "Export the actual interactive NX viewport as PNG and return an inline MCP image. Advisory 128–4096 pixel dimensions (NX can use the actual device size; response reports both), background, shaded/wireframe style and fit. No desktop capture. Paths are workspace-relative; omit for a unique capture path.", + "nx_check_interference": "Check native solid interference between two body/component references, including nested occurrence geometry. Return penetration/contact/clear, closest points and pairwise interference volumes in mm^3. Temporary solids are rolled back.", + "nx_check_clearance": "Check distinct solid body occurrences in selected groups or the full assembly. minimum_clearance uses part units. Conservative bounds prune clear pairs; reported distances and interference use native geometry. Pair limits are preflighted. Volumes are pairwise, not union volume.", + "nx_create_sketch": "Create an active sketch with explicit part-space origin and orthonormal basis. XY: X,Y,+Z; XZ: X,Z,-Y; YZ: Y,Z,+X. Curve coordinates use the returned local basis. Lengths in work-part units.", + "nx_sketch_info": "Read the actual sketch origin, basis, normal and owned curve coordinates in part space. IDs preferred.", + "nx_sketch_arc": "Add an arc to the active owning sketch using local coordinates; radius in work-part units and angles in degrees. Full circle: start=0,end=360. Pass sketch_id explicitly.", + "nx_edit_feature": "Edit native EXTRUDE distance or linear PATTERN_FEATURE count/spacing. Unsupported parameters fail and roll back. IDs or unique case-insensitive names/journal IDs accepted.", + "nx_pattern": "Create an associative native linear feature pattern. Count includes seed; 16 instances at pitch 16.5 of a 14-wide seed span 261.5. Work-part units.", + "nx_import_geometry": "Import STEP through installed NX Step214Importer into the work part for solids, or target=new_part with a new output_path for assemblies; flatten=false preserves structure. Reports new directly-owned bodies and resulting components. Translator files are not undone.", + "nx_get_bounding_box": "Native UF bounds; precision selects conservative or exact (exact requires axis-aligned WCS). auto includes recursive assembly geometry when present; part includes directly owned bodies; assembly includes both. Coordinates and units are work-part absolute.", + "nx_activate_part": "Activate an already loaded part by ID or unique path/name without closing other parts. Display activation also changes work part under NX rules.", + "nx_open_part": "Optional load_components=true fully loads unsuppressed occurrence prototypes, including an already loaded parent; restores load preferences and preserves current reference sets. Accept a workspace-relative or absolute in-workspace NX-host path. Open or reuse a loaded workspace .prt and activate it; work/display flags are explicit. Does not recreate loaded parts.", + "nx_close_part": "Closing a prototype referenced by a loaded assembly is rejected before saving; close parent assemblies first. Close the specified loaded part (ID), or current work part; save defaults true. NX may unload unused assembly prototypes. Returns all closed part references/counts; re-list open parts between closes.", + "nx_checkpoint": "Create an in-session model undo checkpoint. NX v2606 saves expire native marks; create a new checkpoint after save. Restart/close also invalidates checkpoints.", + "nx_checkpoint_state": "Inspect available checkpoint IDs and retained model-operation history. Read-only calls retain marks. Native NX save can expire them; availability is checked against NX.", + "nx_rollback": "Rollback to an in-session checkpoint. Rejects rollback across mutations to unrelated parts. Reacquire object IDs afterward; save explicitly to persist.", + "nx_operation_status": "Read durable request state without waiting on the NX thread: running, committed, failed, unknown. Unknown never authorizes blind retry.", + "nx_cancel_operation": "Request cooperative cancellation of a running batch between child operations. Cannot interrupt a single NXOpen call; inspect final outcome.", + "nx_batch": "Execute 1–100 sketch-curve/add-component/placement operations serially on the NX thread under one rollback mark. Structural preflight, progress, cancellation; all-or-rollback for model changes. Supply one stable operation_id for safe retry.", + "nx_add_component": "Add a .prt occurrence with initial translation and right-handed row-major 3x3 rotation. Work-part coordinates; p_parent=R*p_local+t. Returns typed occurrence.", + "nx_set_component_transform": "Assign absolute translation and row-major rotation to an immediate child. Read-back verified; repeating the same placement is idempotent. Activate owning subassembly for nested placement.", + "nx_reposition_component": "Relative translation and rotation of immediate child in work-part coordinates. Degrees, Rz*Ry*Rx. Use a stable operation_id for retry; use nx_set_component_transform for absolute placement.", + "nx_measure_distance": "Measure minimum BREP distance for body, face, edge, feature-body or component pairs, including nested occurrences. Returns closest points, accuracy and work-part units. Zero does not prove interference.", + "nx_list_topology": "Enumerate body topology. Optional face restricts results to that face and its boundary edges; include_adjacency returns face_edges and edge_faces ID mappings. compact retains minimal typed IDs. Use current upward-face boundary edges for flat-pattern orientation rather than guessing a former outer edge. Reacquire after edits/rollback/close.", + "nx_rename_object": "Rename a referenced object and return its actual NX-normalized display name. Reacquire references afterward.", + "nx_download_file": "Read a workspace file. delivery=image returns an existing PNG inline as MCP image content (max 8 MiB), without base64 in text. delivery=metadata returns size/SHA-256 only. Default base64 returns chunks: offset>=0, length=1..262144, bytes_returned/next_offset/eof. Image/metadata modes require default chunk arguments. Paths are on the NX host.", + "nx_upload_file": "Upload .prt/.step/.stp/.png/.json/.zip/.txt/.pdf chunks (max 256 KiB) into a new workspace file. Requires final SHA-256 and total size, sequential offsets. Repeated identical chunks are safe; existing differing files are never overwritten.", + "nx_package_assembly": "Package the saved active assembly and all loaded prototype dependencies into a new workspace ZIP with a SHA-256 manifest. Refuses unsaved referenced parts and files outside the workspace.", + "nx_capabilities": "Inspect NX-version-specific tested scope. Use tool=exact_name or prefix=nx_sheet to limit response size; omit both for all tools. API detection is separate from native test evidence. Batch NX has no model viewport.", +} + +READ_ONLY = { + "nx_flat_pattern_orientation_edges", + "nx_list_reference_sets", + "nx_list_datums", + "nx_display_info", + "nx_list_sections", + "nx_sketch_diagnostics", + "nx_view_info", + "nx_check_interference", + "nx_check_clearance", + "nx_ui_control", + "nx_status", + "nx_list_sketches", + "nx_list_features", + "nx_list_bodies", + "nx_list_components", + "nx_list_open_parts", + "nx_get_bounding_box", + "nx_measure_volume", + "nx_measure_distance", + "nx_measure_angle", + "nx_get_feature_info", + "nx_sketch_info", + "nx_list_topology", + "nx_checkpoint_state", + "nx_capabilities", + "nx_operation_status", + "nx_workspace_info", + "nx_workspace_list", + "nx_read_result", + "nx_download_file", +} +READ_ONLY.update( + authoring_server.READ_ONLY + | sheet_metal_server.READ_ONLY + | freeform_server.READ_ONLY + | manufacturing_server.READ_ONLY + | assembly_documentation_server.READ_ONLY + | documentation_editing_server.READ_ONLY +) +DESCRIPTIONS.update( + { + name: obj.__doc__ or name + for name, obj in { + **vars(authoring_server), + **vars(sheet_metal_server), + **vars(freeform_server), + **vars(manufacturing_server), + **vars(assembly_documentation_server), + **vars(documentation_editing_server), + }.items() + if name.startswith("nx_") and inspect.isfunction(obj) + } +) + +SIDE = { + "nx_create_directory", + "nx_workspace_info", + "nx_workspace_list", + "nx_read_result", + "nx_download_file", + "nx_upload_file", + "nx_operation_status", + "nx_cancel_operation", +} +PATHS = { + "nx_export_planar_dxf": "path", + "nx_export_explosion_animation": "path", + "nx_export_flat_pattern": "path", + "nx_set_sheet_metal_defaults": "bend_table", + "nx_render_view": "path", + "nx_copy_project": "path", + "nx_component_action": "part_path", + "nx_save_presentation": "path", + "nx_restore_presentation": "path", + "nx_inspection_report": "path", + "nx_create_part": "path", + "nx_open_part": "path", + "nx_export_step": "path", + "nx_add_component": "part_path", + "nx_import_geometry": "path", + "nx_screenshot": "path", + "nx_save_as": "path", + "nx_export_drawing_pdf": "path", +} + + +class IntegrationEnvelope(BaseModel): + """Common output contract; each tool's result fields remain extensible.""" + + model_config = ConfigDict(extra="allow") + status: Literal["success", "error"] + warnings: list[str] + units: str | None + operation_id: str | None = None + mutation_outcome: str | None = None + + +def envelope(payload, error=False): + if payload.get("status") not in {None, "success", "error"}: + payload = {**payload, "validation_status": payload["status"], "status": "success"} + payload = {"status": "error" if error else "success", "warnings": [], "units": None, **payload} + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(payload, ensure_ascii=False))], + structuredContent=payload, + isError=error, + ) + + +def configure(mcp, bridge, workspace): + if workspace is None: + return + existing = dict(mcp._tool_manager._tools) + definitions = { + name: obj + for name, obj in globals().items() + if name.startswith("nx_") and inspect.isfunction(obj) + } + definitions.update( + { + name: obj + for name, obj in { + **vars(authoring_server), + **vars(sheet_metal_server), + **vars(freeform_server), + **vars(manufacturing_server), + **vars(assembly_documentation_server), + **vars(documentation_editing_server), + }.items() + if name.startswith("nx_") and inspect.isfunction(obj) + } + ) + names = set(existing) | set(definitions) + for name in names: + old = existing.get(name) + fn = definitions.get(name) or old.fn + sig = inspect.signature(fn) + # Resolve postponed annotations in the original callable's module. + from typing import get_type_hints + + hints = get_type_hints(fn, include_extras=True) + parameters = [ + p.replace(annotation=hints.get(p.name, p.annotation)) for p in sig.parameters.values() + ] + if name not in READ_ONLY and name not in SIDE and name != "nx_package_assembly": + parameters.append( + inspect.Parameter( + "operation_id", + inspect.Parameter.KEYWORD_ONLY, + default=None, + annotation=str | None, + ) + ) + sig = sig.replace(parameters=parameters, return_annotation=CallToolResult) + + def factory(method, signature): + async def proxy(**kwargs): + try: + bound = signature.bind(**kwargs) + bound.apply_defaults() + from pydantic_core import to_jsonable_python + + params = to_jsonable_python(dict(bound.arguments)) + if "operation_id" in params and method not in { + "nx_operation_status", + "nx_cancel_operation", + }: + params["operation_id"] = params["operation_id"] or ( + "op_" + uuid.uuid4().hex + ) + if method == "nx_package_assembly": + result = await package_assembly(bridge, workspace, params["path"]) + elif method in SIDE: + result = artifact_call(method, params, workspace) + else: + if (path_key := PATHS.get(method)) and params.get(path_key) is not None: + params[path_key] = str(workspace.resolve(params[path_key])) + if method == "nx_import_geometry" and params.get("output_path"): + params["output_path"] = str(workspace.resolve(params["output_path"])) + if method == "nx_batch": + for op in params["operations"]: + if op.get("method") == "nx_add_component" and "part_path" in op.get( + "params", {} + ): + op["params"]["part_path"] = str( + workspace.resolve(op["params"]["part_path"]) + ) + result = await bridge.call(method, params) + response = envelope(result, error=result.get("status") == "error") + if ( + method in {"nx_screenshot", "nx_render_view"} + or (method == "nx_download_file" and params["delivery"] == "image") + ) and not response.isError: + file = workspace.resolve(result["path"]) + if file.stat().st_size <= 8 * 1024 * 1024: + data = file.read_bytes() + if hashlib.sha256(data).hexdigest() != result["sha256"]: + raise NXToolError( + "NX_ARTIFACT_CHANGED", + "Capture file changed after its operation committed", + details={"mutation_outcome": "committed"}, + ) + response.content.append( + ImageContent( + type="image", + mimeType="image/png", + data=base64.b64encode(data).decode(), + ) + ) + else: + result.setdefault("warnings", []).append( + "Image exceeds inline limit; use nx_download_file" + ) + response = envelope(result) + return response + except (NXToolError, WorkspaceViolation, ValueError, TypeError) as e: + error = ( + e + if isinstance(e, NXToolError) + else NXToolError( + "NX_PATH_OUTSIDE_WORKSPACE" + if isinstance(e, WorkspaceViolation) + else "NX_INVALID_ARGUMENT", + str(e), + ) + ) + if "params" in locals() and params.get("operation_id"): + error.details.setdefault("operation_id", params["operation_id"]) + error.details.setdefault("mutation_outcome", "unknown") + return envelope(error.as_dict(), True) + + proxy.__name__ = method + proxy.__signature__ = signature + return proxy + + if old: + mcp.remove_tool(name) + description = DESCRIPTIONS.get( + name, + (inspect.getdoc(definitions[name]) if name in definitions else None) + or (old.description if old else name), + ) + description = description.replace("EXPERIMENTAL: ", "") + if description.strip() == name: + description = ( + "Experimental NX operation; semantics and installed API support have not been validated. " + + name + ) + mcp.add_tool( + factory(name, sig), + name=name, + description=description, + structured_output=False, + annotations=ToolAnnotations( + readOnlyHint=name in READ_ONLY and name != "nx_ui_control", + idempotentHint=name in READ_ONLY + or name + in { + "nx_set_component_transform", + "nx_create_directory", + "nx_edit_explosion", + "nx_show_explosion", + }, + ), + ) + tool = mcp._tool_manager.get_tool(name) + tool.fn_metadata.output_model = IntegrationEnvelope + tool.fn_metadata.output_schema = output_schema( + name, IntegrationEnvelope.model_json_schema() + ) + tool.fn_metadata.arg_model.model_config["extra"] = "forbid" + tool.fn_metadata.arg_model.model_rebuild(force=True) + tool.parameters = tool.fn_metadata.arg_model.model_json_schema() + if name == "nx_edit_sketch": + tool.parameters["properties"]["operations"].update( + minItems=1, maxItems=100, items={"oneOf": authoring_server.SKETCH_OPERATION_SCHEMAS} + ) + if name == "nx_edit_explosion": + for schema in tool.parameters["properties"]["placements"]["anyOf"]: + if schema.get("type") == "array": + schema.update(maxItems=1000, items=authoring_server.EXPLOSION_PLACEMENT_SCHEMA) + original_call = mcp.call_tool + + async def uniform_call(name, arguments): + try: + return await original_call(name, arguments) + except Exception as error: + return envelope( + NXToolError( + "NX_INVALID_ARGUMENT", str(error), details={"mutation_outcome": "not_started"} + ).as_dict(), + True, + ) + + mcp.call_tool = uniform_call + mcp._mcp_server.call_tool(validate_input=False)(uniform_call) + mcp._mcp_server.instructions = "Siemens NX v2606 integration. Use nx_capabilities for tested scope. Discover the host root with nx_workspace_info. File paths are workspace-relative or absolute inside that root; use explicit project subfolders on every file call. Use client-supplied operation_id for mutation retry; query receipts after transport failure. No general certification is claimed." + + +def artifact_call(method, p, workspace): + store = OperationStore(workspace.root) + if method == "nx_read_result": + from nx_mcp.result_transport import read_result + + return read_result(workspace.root / ".nx-mcp" / "bridge-results", **p) + if method == "nx_operation_status": + return store.get(p["operation_id"]) + if method == "nx_cancel_operation": + record = store.get(p["operation_id"]) + if record["state"] != "running" or record.get("method") != "nx_batch": + raise NXToolError("NX_NOT_CANCELLABLE", "Only running batches accept cancellation") + store.path(p["operation_id"]).with_suffix(".cancel").touch() + return { + "status": "success", + "operation_id": p["operation_id"], + "cancellation_requested": True, + } + if method == "nx_workspace_info": + return { + "status": "success", + "root": str(workspace.root), + "path_host": "NX server", + "relative_to": str(workspace.root), + "absolute_paths": "accepted inside workspace only", + "parent_creation": ["nx_create_part", "nx_save_as", "nx_upload_file"], + "current_directory": "No mutable current directory; use explicit paths on every call", + } + path = workspace.resolve(p["path"]) + if method == "nx_create_directory": + existed = path.is_dir() + try: + path.mkdir(parents=True, exist_ok=True) + except OSError as error: + raise NXToolError("NX_DIRECTORY_ERROR", str(error)) from error + return { + "status": "success", + "path": str(path), + "relative_path": str(path.relative_to(workspace.root)), + "created": not existed, + "mutation_outcome": "committed", + } + if ".nx-mcp" in path.relative_to(workspace.root).parts: + raise NXToolError("NX_PATH_RESERVED", "Internal service state is not an artifact") + + def metadata(file): + h = hashlib.sha256() + with file.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return { + "path": str(file.relative_to(workspace.root)), + "size": file.stat().st_size, + "sha256": h.hexdigest(), + } + + if method == "nx_workspace_list": + if not path.exists(): + raise NXToolError( + "NX_DIRECTORY_NOT_FOUND", + "Workspace directory does not exist; inspect the parent directory or correct path", + ) + if not path.is_dir(): + raise NXToolError( + "NX_NOT_DIRECTORY", + "path must name a workspace directory; use nx_download_file(delivery='metadata') for a file", + ) + offset, limit, prefix = p.get("offset", 0), p.get("limit", 100), p.get("prefix", "") + if offset < 0 or not 1 <= limit <= 1000: + raise NXToolError("NX_INVALID_ARGUMENT", "offset must be >= 0; limit must be 1..1000") + files = [ + f + for f in sorted(path.iterdir()) + if f.name.casefold() != ".nx-mcp" and f.name.startswith(prefix) + ] + items = [] + for f in files[offset : offset + limit]: + workspace.ensure_inside(f) + items.append( + metadata(f) | {"kind": "file"} + if f.is_file() + else {"path": str(f.relative_to(workspace.root)), "kind": "directory"} + ) + next_offset = offset + len(items) + return { + "status": "success", + "path": str(path), + "entries": items, + "count": len(items), + "total_count": len(files), + "offset": offset, + "next_offset": next_offset if next_offset < len(files) else None, + } + if method == "nx_download_file": + if p["offset"] < 0 or not 1 <= p["length"] <= 262144: + raise NXToolError( + "NX_INVALID_ARGUMENT", "offset must be >= 0; length must be 1..262144 bytes" + ) + if not path.is_file(): + raise NXToolError("NX_FILE_NOT_FOUND", "path must name an existing workspace file") + delivery = p.get("delivery", "base64") + if delivery not in {"base64", "image", "metadata"}: + raise NXToolError("NX_INVALID_ARGUMENT", "delivery must be base64, image or metadata") + if delivery != "base64" and (p["offset"] != 0 or p["length"] != 262144): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "image/metadata delivery requires default offset=0 and length=262144; chunk ranges apply only to base64", + ) + meta = metadata(path) + if delivery == "metadata": + return {"status": "success", **meta, "delivery": delivery} + if delivery == "image": + if meta["size"] > 8 * 1024 * 1024: + raise NXToolError( + "NX_IMAGE_TOO_LARGE", "Inline PNG limit is 8 MiB; use base64 chunks" + ) + with path.open("rb") as stream: + header = stream.read(24) + if len(header) != 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + raise NXToolError( + "NX_UNSUPPORTED_IMAGE", + "Inline delivery supports PNG files with an IHDR header; use base64 for other formats", + ) + return { + "status": "success", + **meta, + "delivery": delivery, + "mime_type": "image/png", + "resolution": list(struct.unpack(">II", header[16:24])), + } + with path.open("rb") as stream: + stream.seek(p["offset"]) + data = stream.read(p["length"]) + next_offset = p["offset"] + len(data) + return { + "status": "success", + **meta, + "offset": p["offset"], + "bytes_returned": len(data), + "next_offset": next_offset if next_offset < meta["size"] else None, + "data_base64": base64.b64encode(data).decode(), + "eof": next_offset >= meta["size"], + } + if path.suffix.lower() not in { + ".prt", + ".step", + ".stp", + ".png", + ".json", + ".zip", + ".txt", + ".pdf", + }: + raise NXToolError( + "NX_UNSUPPORTED_FILE_TYPE", "Upload is limited to CAD and review artifacts" + ) + if not 0 <= p["offset"] <= p["total_size"] <= 256 * 1024 * 1024: + raise NXToolError("NX_INVALID_ARGUMENT", "Invalid offset or total_size (max 256 MiB)") + if len(p["sha256"]) != 64 or any(c not in "0123456789abcdef" for c in p["sha256"]): + raise NXToolError("NX_INVALID_ARGUMENT", "Expected lowercase SHA-256") + data = base64.b64decode(p["data_base64"], validate=True) + if len(data) > 262144 or p["offset"] + len(data) > p["total_size"]: + raise NXToolError("NX_INVALID_ARGUMENT", "Chunk exceeds limit") + if path.exists(): + meta = metadata(path) + if meta["sha256"] == p["sha256"] and meta["size"] == p["total_size"]: + return {"status": "success", **meta, "committed": True, "replayed": True} + raise NXToolError("NX_FILE_EXISTS", "Existing file differs; choose a new destination") + staging = workspace.root / ".nx-mcp" / "uploads" + staging.mkdir(exist_ok=True, parents=True) + key = hashlib.sha256( + (str(path) + "|" + p["sha256"] + "|" + str(p["total_size"])).encode() + ).hexdigest() + temp = staging / key + size = temp.stat().st_size if temp.exists() else 0 + if p["offset"] < size: + with temp.open("rb") as f: + f.seek(p["offset"]) + previous = f.read(len(data)) + if previous != data: + raise NXToolError("NX_UPLOAD_CONFLICT", "Retried chunk differs from staged data") + elif p["offset"] == size: + with temp.open("ab") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + else: + raise NXToolError("NX_UPLOAD_GAP", "Chunks must be contiguous") + complete = temp.stat().st_size == p["total_size"] + if complete: + if metadata(temp)["sha256"] != p["sha256"]: + raise NXToolError( + "NX_CHECKSUM_MISMATCH", + "Uploaded bytes do not match SHA-256; choose a corrected upload", + ) + path.parent.mkdir(parents=True, exist_ok=True) + # Exclusive destination creation prevents overwrite races between clients. + os.link(temp, path) # Atomic exclusive publication on the same filesystem. + temp.unlink() + return { + "status": "success", + "path": str(path.relative_to(workspace.root)), + "received": size + len(data) if p["offset"] == size else size, + "committed": complete, + "sha256": p["sha256"], + } + + +async def package_assembly(bridge, workspace, path): + import zipfile + + destination = workspace.resolve(path) + if destination.suffix.lower() != ".zip": + raise NXToolError("NX_INVALID_ARGUMENT", "Package path must end with .zip") + opened = await bridge.call("nx_list_open_parts", {}) + active = next(p for p in opened["parts"] if p["work"]) + components = await bridge.call("nx_list_components", {}) + paths = {active["path"]} | {c["part_path"] for c in components["components"]} + for p in opened["parts"]: + if p["path"] in paths and p["modified"]: + raise NXToolError( + "NX_UNSAVED_PART", "Save referenced parts before packaging: " + p["path"] + ) + manifest = [] + for p in sorted(paths): + file = workspace.ensure_inside(p) + if not file.is_file(): + raise NXToolError("NX_FILE_NOT_FOUND", str(file)) + manifest.append( + { + "path": str(file.relative_to(workspace.root)).replace("\\", "/"), + "size": file.stat().st_size, + "sha256": hashlib.sha256(file.read_bytes()).hexdigest(), + } + ) + destination.parent.mkdir(parents=True, exist_ok=True) + temp = workspace.root / ".nx-mcp" / ("package-" + uuid.uuid4().hex + ".zip") + temp.parent.mkdir(parents=True, exist_ok=True) + try: + with zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED) as archive: + for item in manifest: + archive.write(workspace.resolve(item["path"]), item["path"]) + archive.writestr( + "nx-assembly-manifest.json", + json.dumps( + {"assembly": active, "components": components, "files": manifest}, indent=2 + ), + ) + os.link(temp, destination) + finally: + temp.unlink(missing_ok=True) + return { + "status": "success", + "path": str(destination.relative_to(workspace.root)), + "size": destination.stat().st_size, + "sha256": hashlib.sha256(destination.read_bytes()).hexdigest(), + "prototype_files": len(paths) - 1, + "component_instances": components["count"], + "files": manifest, + } diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py new file mode 100644 index 0000000..9406cbd --- /dev/null +++ b/src/nx_mcp/interactive.py @@ -0,0 +1,520 @@ +"""Windows NX UI-thread host. No background thread calls NXOpen. + +The journal returns after installing a Win32 timer. NX's existing message pump +invokes TIMERPROC on the registering thread; the pinned callback drains one call. +""" + +from __future__ import annotations + +import ctypes +import json +import os +import threading +import time +import uuid +from pathlib import Path + +from nx_mcp.runtime import NXToolError + +_host = None +_retired = [] + + +def nx_main_window(): + """Find the main window owned by this NX process, before creating our panel.""" + from ctypes import wintypes as w + + user = ctypes.WinDLL("user32", use_last_error=True) + callback_type = ctypes.WINFUNCTYPE(w.BOOL, w.HWND, w.LPARAM) + user.EnumWindows.argtypes = [callback_type, w.LPARAM] + user.GetWindowThreadProcessId.argtypes = [w.HWND, ctypes.POINTER(w.DWORD)] + user.GetWindowRect.argtypes = [w.HWND, ctypes.POINTER(w.RECT)] + user.IsWindowVisible.argtypes = [w.HWND] + windows = [] + + def visit(hwnd, param): + pid = w.DWORD() + user.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value == os.getpid() and user.IsWindowVisible(hwnd): + rect = w.RECT() + user.GetWindowRect(hwnd, ctypes.byref(rect)) + windows.append(((rect.right - rect.left) * (rect.bottom - rect.top), hwnd)) + return True + + callback = callback_type(visit) + user.EnumWindows(callback, 0) + if not windows: + raise RuntimeError("NX has no visible main window") + return max(windows)[1] + + +class ControlPanel: + def __init__(self, host): + from ctypes import wintypes as w + + self.host = host + self.user = ctypes.WinDLL("user32", use_last_error=True) + self.proc_type = ctypes.WINFUNCTYPE(ctypes.c_ssize_t, w.HWND, w.UINT, w.WPARAM, w.LPARAM) + + class WNDCLASS(ctypes.Structure): + _fields_ = [ + ("style", w.UINT), + ("lpfnWndProc", self.proc_type), + ("cbClsExtra", ctypes.c_int), + ("cbWndExtra", ctypes.c_int), + ("hInstance", w.HINSTANCE), + ("hIcon", w.HICON), + ("hCursor", w.HANDLE), + ("hbrBackground", w.HBRUSH), + ("lpszMenuName", w.LPCWSTR), + ("lpszClassName", w.LPCWSTR), + ] + + self.user.DefWindowProcW.argtypes = [w.HWND, w.UINT, w.WPARAM, w.LPARAM] + self.user.DefWindowProcW.restype = ctypes.c_ssize_t + self.user.CreateWindowExW.argtypes = [ + w.DWORD, + w.LPCWSTR, + w.LPCWSTR, + w.DWORD, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + w.HWND, + w.HMENU, + w.HINSTANCE, + ctypes.c_void_p, + ] + self.user.CreateWindowExW.restype = w.HWND + self.user.SetWindowTextW.argtypes = [w.HWND, w.LPCWSTR] + self.user.UpdateWindow.argtypes = [w.HWND] + self.user.DestroyWindow.argtypes = [w.HWND] + self.user.UnregisterClassW.argtypes = [w.LPCWSTR, w.HINSTANCE] + self.user.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASS)] + self.proc = self.proc_type(self._message) + cls = WNDCLASS() + cls.lpfnWndProc = self.proc + cls.hbrBackground = 6 + cls.lpszClassName = "NxMcpPanel" + uuid.uuid4().hex + self._class = cls + if not self.user.RegisterClassW(ctypes.byref(cls)): + raise ctypes.WinError(ctypes.get_last_error()) + self.hwnd = self.user.CreateWindowExW( + 0x80, + cls.lpszClassName, + "NX MCP control", + 0x10C80000, + 40, + 60, + 560, + 195, + self.host.main_hwnd, + None, + None, + None, + ) + if not self.hwnd: + raise ctypes.WinError(ctypes.get_last_error()) + self.label = self.user.CreateWindowExW( + 0, + "STATIC", + "Starting interactive bridge...", + 0x50000000, + 12, + 10, + 530, + 85, + self.hwnd, + None, + None, + None, + ) + for caption, x, ident in [ + ("Pause / manual", 12, 101), + ("Resume agent", 152, 102), + ("Stop bridge", 292, 103), + ]: + self.user.CreateWindowExW( + 0, "BUTTON", caption, 0x50010000, x, 110, 130, 30, self.hwnd, ident, None, None + ) + + def _message(self, hwnd, msg, wp, lp): + try: + if msg == 0x111: + ident = int(wp) & 0xFFFF + if ident == 101: + self.host.requested_mode = "manual" + elif ident == 102: + self.host.requested_mode = "agent" + elif ident == 103: + self.host.stop_requested = True + return 0 + if msg == 0x10: + self.host.stop_requested = True + return 0 + except BaseException as exc: + self.host.last_error = str(exc) + return self.user.DefWindowProcW(hwnd, msg, wp, lp) + + def update(self, text): + if text == getattr(self, "_text", None): + return + self._text = text + self.user.SetWindowTextW(self.label, text) + # Paint only this panel before native work. Never pump arbitrary NX + # messages here: that would permit reentrant modeling calls. + self.user.UpdateWindow(self.label) + self.user.UpdateWindow(self.hwnd) + + def close(self): + self.user.DestroyWindow(self.hwnd) + self.user.UnregisterClassW(self._class.lpszClassName, None) + + +class InteractiveHost: + def __init__(self, workspace, descriptor_path): + import secrets + + import NXOpen + + from nx_mcp import nx_bridge + from nx_mcp.bridge import BridgeDescriptor, BridgeServer, MainThreadDispatcher + from nx_mcp.hardened import HardenedExecutor + from nx_mcp.workspace import Workspace + + self.session = NXOpen.Session.GetSession() + self.nx = NXOpen + self.ui = NXOpen.UI.GetUI() + if self.session.IsBatch: + raise RuntimeError("Interactive host requires a graphical NX session") + self.thread = threading.get_ident() + self.native_thread = ctypes.windll.kernel32.GetCurrentThreadId() + self.root = Path(workspace) + self.root.mkdir(parents=True, exist_ok=True) + self.state_dir = self.root / ".nx-mcp" + self.state_dir.mkdir(exist_ok=True) + self.stop_file = self.state_dir / "ui-stop" + self.stop_file.unlink(missing_ok=True) + self.mode = "manual" + self.requested_mode = None + self.owns_lock = False + self.busy = False + self.stop_requested = False + self.stopped = False + self.ticks = 0 + self.completed = 0 + self.running_method = None + self.operation_started = None + self.last_duration_seconds = None + self._snapshot = None + self.last_method = None + self.last_error = None + self.started = time.time() + batch_descriptor = Path(os.environ.get("LOCALAPPDATA", "")) / "nx-mcp" / "bridge.json" + default_workspace = Path(os.environ.get("NX_MCP_WORKSPACE", r"D:\CAD\NX_MCP_WORKSPACE")) + if self.root.resolve() == default_workspace.resolve() and batch_descriptor.exists(): + raise RuntimeError( + "Stop the batch bridge before attaching the interactive host to this workspace" + ) + self.executor = HardenedExecutor( + self.session, + NXOpen, + nx_bridge._detect_nx_version(self.session), + Workspace(self.root), + enable_experimental=True, + enable_journal=False, + ) + self.executor._handlers["nx_ui_control"] = self.control + token = secrets.token_hex(32) + self.dispatcher = MainThreadDispatcher(self.execute) + self.server = BridgeServer( + self.dispatch, + token=token, + concurrent_requests=True, + result_directory=Path(self.root) / ".nx-mcp" / "bridge-results", + ) + self.descriptor_path = Path(descriptor_path) + self.server.start() + self.descriptor = BridgeDescriptor.create( + self.server.port, self.executor.nx_version, token=token + ) + self.main_hwnd = nx_main_window() + self.window_disabled = False + self.panel = ControlPanel(self) + self.user = ctypes.WinDLL("user32", use_last_error=True) + self.user.EnableWindow.argtypes = [ctypes.c_void_p, ctypes.c_int] + self.user.IsWindowEnabled.argtypes = [ctypes.c_void_p] + self.timer_type = ctypes.WINFUNCTYPE( + None, ctypes.c_void_p, ctypes.c_uint, ctypes.c_size_t, ctypes.c_uint32 + ) + self.callback = self.timer_type(self.tick) + self.user.SetTimer.argtypes = [ + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_uint, + self.timer_type, + ] + self.user.SetTimer.restype = ctypes.c_size_t + self.user.KillTimer.argtypes = [ctypes.c_void_p, ctypes.c_size_t] + self.timer = self.user.SetTimer(None, 0, 100, self.callback) + if not self.timer: + raise ctypes.WinError(ctypes.get_last_error()) + self.descriptor.write(self.descriptor_path) + self.auto_start = True + + def dispatch(self, method, params): + # Bridge-worker health reads use an immutable UI-thread snapshot only. + # They must not wait behind the native operation they are diagnosing. + snapshot = getattr(self, "_snapshot", None) + if method == "nx_ui_control" and params.get("mode", "status") == "status" and snapshot: + result = dict(snapshot) + result["snapshot_age_seconds"] = max( + 0, time.monotonic() - result.pop("_sample_monotonic") + ) + result["snapshot_only"] = True + started = result.pop("_operation_monotonic", None) + result["operation_elapsed_seconds"] = ( + max(0, time.monotonic() - started) if started is not None else None + ) + return result + return self.dispatcher.call(method, params) + + def publish(self): + state = self.status() + self._snapshot = { + **state, + "_sample_monotonic": time.monotonic(), + "_operation_monotonic": self.operation_started, + } + self.panel.update(self.panel_text()) + temp = self.state_dir / "ui-state.tmp" + temp.write_text(json.dumps(state)) + temp.replace(self.state_dir / "ui-state.json") + + def panel_text(self): + if self.running_method: + return ( + f"Running {self.running_method} — NX input reserved\n" + "Native work may block repainting. Pause / Stop applies after it returns." + ) + if self.mode == "manual": + return "Manual editing enabled — agent requests paused\nFinish NX dialogs, then Resume agent." + detail = self.last_error or ( + f"Last: {self.last_method} ({self.last_duration_seconds:.2f}s)" + if self.last_duration_seconds is not None + else "Waiting for MCP requests" + ) + return ( + "Agent idle — NX input intentionally reserved\nUse Pause / manual to edit or navigate NX.\n" + + detail + ) + + def status(self): + return { + "interactive": True, + "pid": os.getpid(), + "mode": self.mode, + "activity": "running" + if self.running_method + else ("reserved_idle" if self.mode == "agent" else "manual"), + "running_method": self.running_method, + "operation_elapsed_seconds": ( + time.monotonic() - self.operation_started + if self.operation_started is not None + else None + ), + "last_duration_seconds": self.last_duration_seconds, + "sampled_at": time.time(), + "snapshot_only": False, + "pause_semantics": "between operations; does not interrupt a native call", + "ui_locked_by_bridge": self.owns_lock, + "actual_ui_lock": self.ui.AskLockStatus() == self.nx.UI.Status.Lock, + "native_lock_value": str(self.ui.AskLockStatus()), + "can_open_part": self.ui.CanOpenPart(), + "last_unlock": getattr(self, "last_unlock", None), + "nx_window_input_enabled": bool(self.user.IsWindowEnabled(self.main_hwnd)), + "main_thread_id": self.native_thread, + "callback_thread_id": ctypes.windll.kernel32.GetCurrentThreadId(), + "ticks": self.ticks, + "completed_operations": self.completed, + "last_method": self.last_method, + "last_error": self.last_error, + "uptime_seconds": time.time() - self.started, + "scheduler": "Win32 UI-thread timer; one queued call per tick", + } + + def control(self, mode="status"): + if threading.get_ident() != self.thread: + raise RuntimeError("UI control called off NX thread") + if mode == "manual": + if self.mode == "agent": + if hasattr(self.executor, "_clear_highlights"): + self.executor._clear_highlights() + # Manual edits have no bridge receipt. Do not let a later agent + # rollback undo them, or resolve references across that boundary. + for part in self.session.Parts: + self.executor.objects.invalidate_part(self.executor._part_id(part)) + self.executor._history.clear() + self.executor._checkpoints.clear() + self.executor._review_epoch = getattr(self.executor, "_review_epoch", 0) + 1 + if self.owns_lock: + before = str(self.ui.AskLockStatus()) + self.ui.UnlockAccess() + self.last_unlock = { + "before": before, + "after": str(self.ui.AskLockStatus()), + "can_open_part": self.ui.CanOpenPart(), + } + self.owns_lock = False + if self.window_disabled: + self.user.EnableWindow(self.main_hwnd, True) + self.window_disabled = False + self.mode = "manual" + self.auto_start = False + elif mode == "agent": + if not self.owns_lock: + if self.ui.AskLockStatus() == self.nx.UI.Status.Lock or not self.ui.CanOpenPart(): + raise NXToolError( + "NX_UI_BUSY", + "Finish the current NX dialog before resuming agent control", + details={"mutation_outcome": "not_started"}, + ) + self.ui.LockAccess() + self.owns_lock = True + self.user.EnableWindow(self.main_hwnd, False) + self.window_disabled = True + self.mode = "agent" + elif mode != "status": + raise NXToolError("NX_INVALID_ARGUMENT", "mode must be status, manual or agent") + return self.status() + + def execute(self, method, params): + if threading.get_ident() != self.thread: + raise RuntimeError("NX request called off registering UI thread") + if method == "nx_ui_control": + return self.control(params.get("mode", "status")) + if method == "nx_status": + return {**self.executor.execute(method, params), "ui": self.status()} + if self.mode != "agent" or not self.owns_lock: + raise NXToolError( + "NX_UI_PAUSED", + "Agent control is paused. Finish manual edits and resume in NX MCP control.", + details={"mutation_outcome": "not_started"}, + ) + # FileNew must not run inside LockAccess: NX v2606 can strand its + # internal UI lock. The disabled main window reserves user input while + # native operations execute on this same UI thread. + self.ui.UnlockAccess() + self.last_method = method + self.running_method = method + self.operation_started = time.monotonic() + self.last_error = None + try: + try: + self.publish() + except Exception as exc: + self.last_error = "UI status publication failed: " + str(exc) + result = self.executor.execute(method, params) + self.completed += 1 + part = self.session.Parts.Display + from nx_mcp.hardened import READ_ONLY + + sheet = getattr(getattr(part, "DrawingSheets", None), "CurrentDrawingSheet", None) + if part and method not in READ_ONLY and sheet is None: + try: + part.ModelingViews.WorkView.UpdateDisplay() + except Exception as exc: + result.setdefault("warnings", []).append("View refresh: " + str(exc)) + return result + except BaseException as exc: + self.last_error = str(exc) + raise + finally: + self.last_duration_seconds = time.monotonic() - self.operation_started + self.running_method = None + self.operation_started = None + # Restore the between-operation native lock after all NX work. + try: + if self.ui.AskLockStatus() != self.nx.UI.Status.Lock: + self.ui.LockAccess() + except Exception as exc: + self.last_error = "Cannot restore agent UI reservation: " + str(exc) + self.control("manual") + try: + self.publish() + except Exception as exc: + # A failed diagnostic write must not turn a committed operation + # into a failure response that invites an unsafe retry. + self.last_error = "UI status publication failed: " + str(exc) + + def tick(self, *args): + if self.busy or self.stopped: + return + self.busy = True + try: + self.ticks += 1 + if self.stop_requested or self.stop_file.exists(): + self.stop() + return + if self.requested_mode: + mode, self.requested_mode = self.requested_mode, None + try: + self.control(mode) + except NXToolError as exc: + self.last_error = str(exc) + if self.auto_start: + try: + self.control("agent") + self.auto_start = False + except NXToolError: + pass + self.dispatcher.drain(timeout=0, limit=1) + self.panel.update(self.panel_text()) + if self.ticks % 10 == 0: + self.publish() + except BaseException as exc: + self.last_error = str(exc) + try: # noqa: SIM105 - Last-resort native callback cleanup must not escape. + self.control("manual") + except BaseException: + pass + finally: + self.busy = False + + def stop(self): + from nx_mcp.bridge import BridgeDescriptor + + self.control("manual") + self.stopped = True + self.user.KillTimer(None, self.timer) + self.dispatcher.stop() + self.server.stop() + self.panel.close() + try: + if BridgeDescriptor.read(self.descriptor_path).token == self.descriptor.token: + self.descriptor_path.unlink() + except (ValueError, OSError): + pass + + +def start(workspace, descriptor_path): + global _host + if _host is not None and not _host.stopped: + return _host.status() + if _host is not None: + _retired.append(_host) # Keep native callback delegates alive until NX exits. + candidate = InteractiveHost.__new__(InteractiveHost) + try: + candidate.__init__(workspace, descriptor_path) + except BaseException: + if getattr(candidate, "timer", None): + candidate.user.KillTimer(None, candidate.timer) + if hasattr(candidate, "server"): + candidate.server.stop() + if hasattr(candidate, "panel"): + candidate.panel.close() + _retired.append(candidate) + raise + _host = candidate + return _host.status() diff --git a/src/nx_mcp/inventory.py b/src/nx_mcp/inventory.py new file mode 100644 index 0000000..b16c16d --- /dev/null +++ b/src/nx_mcp/inventory.py @@ -0,0 +1,30 @@ +"""Backward-compatible projections for large native inventories.""" + +from nx_mcp.runtime import NXToolError + + +def compact_reference(reference): + return { + k: v + for k, v in reference.items() + if k in {"id", "kind", "name", "part_id", "occurrence_path"} + } + + +def page(rows, offset=0, limit=None): + if ( + type(offset) is not int + or offset < 0 + or (limit is not None and (type(limit) is not int or not 1 <= limit <= 1000)) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "offset must be nonnegative; limit must be 1–1000 or null" + ) + selected = rows[offset:] if limit is None else rows[offset : offset + limit] + end = offset + len(selected) + return selected, { + "total_count": len(rows), + "count": len(selected), + "offset": offset, + "next_offset": end if end < len(rows) else None, + } diff --git a/src/nx_mcp/legacy_repairs.py b/src/nx_mcp/legacy_repairs.py new file mode 100644 index 0000000..7fab8d2 --- /dev/null +++ b/src/nx_mcp/legacy_repairs.py @@ -0,0 +1,132 @@ +"""Typed-reference replacements for legacy NX 2606 entry points.""" + +from __future__ import annotations + +import math + +from nx_mcp.runtime import NXToolError + + +class LegacyRepairsMixin: + def _delete_feature(self, name): + feature = self._resolve(name, {"feature"}) + ref = self._reference(feature, "feature", self._work_part(), "Feature") + manager = self.session.UpdateManager + self._require_api(manager, "AddToDeleteList", "DoUpdate") + manager.AddToDeleteList(feature) + self._update_model() + return {"deleted": [ref]} + + def _angle_direction(self, obj): + if isinstance(obj, self.nxopen.Line): + a, b = obj.StartPoint, obj.EndPoint + return [b.X - a.X, b.Y - a.Y, b.Z - a.Z], "line_start_to_end" + if isinstance(obj, self.nxopen.Edge): + if obj.SolidEdgeType != self.nxopen.Edge.EdgeType.Linear: + raise NXToolError("NX_INVALID_ARGUMENT", "Angle requires a straight edge") + a, b = obj.GetVertices() + return [b.X - a.X, b.Y - a.Y, b.Z - a.Z], "edge_vertex_order" + if isinstance(obj, self.nxopen.Face): + uf = self.nxopen.UF.UFSession.GetUFSession() + code, _, direction, _, _, _, sign = uf.Modeling.AskFaceData(obj.Tag) + if code != 22: + raise NXToolError("NX_INVALID_ARGUMENT", "Angle requires a planar face") + return [float(x) * sign for x in direction], "outward_face_normal" + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Use lines, straight edges or planar faces") + + def _measure_angle(self, obj1, obj2): + objects = [self._resolve(r, {"curve", "edge", "face"}) for r in (obj1, obj2)] + vectors, conventions = zip(*(self._angle_direction(o) for o in objects), strict=True) + lengths = [math.sqrt(sum(x * x for x in v)) for v in vectors] + if any(n <= 1e-12 for n in lengths): + raise NXToolError("NX_INVALID_ARGUMENT", "Cannot measure a degenerate direction") + dot = sum(a * b for a, b in zip(*vectors, strict=True)) / math.prod(lengths) + angle = math.degrees(math.acos(max(-1.0, min(1.0, dot)))) + return { + "angle_deg": angle, + "units": "deg", + "coordinate_frame": "work_part", + "range": [0, 180], + "direction_conventions": list(conventions), + "resolved_references": [ + self._reference( + o, + "face" + if isinstance(o, self.nxopen.Face) + else "edge" + if isinstance(o, self.nxopen.Edge) + else "curve", + self._work_part(), + "Angle target", + ) + for o in objects + ], + } + + def _sketch_constraint(self, constraint_type, targets, value=None): + key = constraint_type.strip().lower() + single = {"fix", "fixed", "horizontal", "vertical"} + pair = { + "parallel", + "perpendicular", + "equal_length", + "equal_radius", + "concentric", + "tangent", + "coincident", + } + dims = { + "distance": "length", + "length": "length", + "radius": "radius", + "diameter": "diameter", + } + if key not in single | pair | set(dims) | {"angle"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Unsupported constraint type; use the published enum" + ) + count = 2 if key in pair or key == "angle" else 1 + if len(targets) != count: + raise NXToolError("NX_INVALID_ARGUMENT", f"{key} requires {count} curve reference(s)") + dimensional = key in dims or key == "angle" + if dimensional != (value is not None): + raise NXToolError("NX_INVALID_ARGUMENT", "Only dimensional constraints require a value") + curves = [self._resolve(t, {"curve"}) for t in targets] + sketches = [ + s + for s in self._work_part().Sketches + if all(any(int(c.Tag) == int(g.Tag) for g in s.GetAllGeometry()) for c in curves) + ] + if len(sketches) != 1: + raise NXToolError("NX_INVALID_ARGUMENT", "Targets must belong to one sketch") + sketch = sketches[0] + ref = self._reference(sketch, "sketch", self._work_part(), "Sketch")["id"] + if key in single: + return self._edit_sketch( + ref, + [ + { + "action": "constraint", + "curve": targets[0], + "type": "fixed" if key in {"fix", "fixed"} else key, + } + ], + ) + if key == "tangent": + return self._sketch_tangent(ref, *targets) + if key in pair: + # Legacy signature has no endpoint arguments: make the default explicit. + result = self._sketch_relation( + ref, + *targets, + relation=key, + **({"point1": "start", "point2": "start"} if key == "coincident" else {}), + ) + if key == "coincident": + result["endpoint_convention"] = ( + "start_to_start; use nx_sketch_relation for explicit endpoints" + ) + return result + if key == "angle": + return self._sketch_angle(ref, *targets, value=value, origin=[0, 0]) + return self._sketch_dimension(ref, targets[0], dims[key], value, [0, 0]) diff --git a/src/nx_mcp/manufacturing.py b/src/nx_mcp/manufacturing.py new file mode 100644 index 0000000..5b0865e --- /dev/null +++ b/src/nx_mcp/manufacturing.py @@ -0,0 +1,426 @@ +"""Native thread and geometric manufacturing inspection operations.""" + +from __future__ import annotations + +from nx_mcp.authoring import finite +from nx_mcp.runtime import NXToolError + + +class ManufacturingMixin: + def _thread( + self, + face, + start_face, + pitch, + major_diameter, + minor_diameter, + length, + angle=60.0, + detailed=False, + left_hand=False, + starts=1, + reverse=False, + ): + import NXOpen.Features as F + + target = self._engineering_owned(face, "face") + start = self._engineering_owned(start_face, "face") + import NXOpen.UF as U + + cylinder = U.UFSession.GetUFSession().Modeling.AskFaceData(target.Tag) + if cylinder[0] != 16 or start.GetBody() != target.GetBody(): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Select a cylindrical face and a start face on the same body" + ) + diameter = 2 * cylinder[4] + values = { + k: finite(v, k, True) + for k, v in { + "pitch": pitch, + "major_diameter": major_diameter, + "minor_diameter": minor_diameter, + "length": length, + "angle": angle, + }.items() + } + if ( + values["minor_diameter"] >= values["major_diameter"] + or angle >= 180 + or type(starts) is not int + or not 1 <= starts <= 16 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Require minor < major, angle < 180 degrees and 1..16 starts" + ) + b = self._freeform_builder("CreateThreadBuilder") + try: + b.ThreadInput = F.ThreadBuilder.Input.Manual + b.SmartThread = False + b.ThreadType = ( + F.ThreadBuilder.Type.Detailed if detailed else F.ThreadBuilder.Type.Symbolic + ) + b.CylindricalFace.Value = target + b.StartObject.Value = start + b.TapDrillDiameterExp.RightHandSide = str(diameter) + b.ShaftDiameterExp.RightHandSide = str(diameter) + b.ThreadLimit = F.ThreadBuilder.LimitOption.Value + b.ThreadHandedness = ( + F.ThreadBuilder.Handedness.LeftHand + if left_hand + else F.ThreadBuilder.Handedness.RightHand + ) + b.NumStarts = starts + b.ReverseThreadDirection = reverse + for prop, key in [ + ("PitchExp", "pitch"), + ("MajorDiameterExp", "major_diameter"), + ("MinorDiameterExp", "minor_diameter"), + ("ThreadLength", "length"), + ("AngleExp", "angle"), + ]: + getattr(b, prop).RightHandSide = str(values[key]) + result = self._freeform_commit(b) + result.update( + { + "representation": "detailed" if detailed else "symbolic", + "internal": b.IsInternalThread, + "pitch": b.Pitch, + "major_diameter": b.MajorDiameter, + "minor_diameter": b.MinorDiameter, + "length": b.ThreadLength.Value, + "starts": b.NumStarts, + } + ) + return result + finally: + b.Destroy() + + def _pmi_datum(self, faces, letter, position, annotation=None): + import re + + from nx_mcp.freeform import points3 + + targets = self._freeform_refs(faces, "face") + if not isinstance(letter, str) or not re.fullmatch(r"[A-Z]{1,3}", letter): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Datum identifier must be 1..3 uppercase letters" + ) + point = points3([position])[0] + existing = self._engineering_owned(annotation, "annotation") if annotation else None + b = self._work_part().Annotations.Datums.CreatePmiDatumFeatureSymbolBuilder(existing) + try: + b.Letter = letter + return self._commit_pmi(b, targets, point, existing) + finally: + b.Destroy() + + def _pmi_fcf( + self, + faces, + characteristic, + tolerance, + position, + datums=None, + annotation=None, + material="none", + zone_shape="none", + datum_material=None, + projected_height=None, + tangent_plane=False, + free_state=False, + ): + import NXOpen.Annotations as A + + from nx_mcp.freeform import points3 + + allowed = { + "Straightness", + "Flatness", + "Circularity", + "Cylindricity", + "ProfileOfALine", + "ProfileOfASurface", + "Angularity", + "Perpendicularity", + "Parallelism", + "Position", + "Concentricity", + "Symmetry", + "CircularRunout", + "TotalRunout", + "AxisIntersection", + } + if characteristic not in allowed: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported geometric characteristic") + tolerance = finite(tolerance, "tolerance", True) + targets = self._freeform_refs(faces, "face") + point = points3([position])[0] + if datums is not None and (not isinstance(datums, list) or len(datums) > 3): + raise NXToolError( + "NX_INVALID_ARGUMENT", "At most three datum annotation IDs are supported" + ) + refs = [self._engineering_owned(r, "annotation") for r in (datums or [])] + if characteristic in {"Straightness", "Flatness", "Circularity", "Cylindricity"} and refs: + raise NXToolError("NX_INVALID_ARGUMENT", "Form tolerances do not take datum references") + letters = [] + for obj in refs: + reader = self._work_part().Annotations.Datums.CreatePmiDatumFeatureSymbolBuilder(obj) + try: + letters.append(reader.Letter) + finally: + reader.Destroy() + modifiers = { + "none": "NotSet", + "MMC": "MaximumMaterialCondition", + "LMC": "LeastMaterialCondition", + "RFS": "RegardlessOfFeatureSize", + } + zones = { + "none": "NotSet", + "diameter": "Diameter", + "spherical_diameter": "SphericalDiameter", + "square": "Square", + } + if material not in modifiers or zone_shape not in zones: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown tolerance modifier or zone shape") + if datum_material is None: + datum_material = ["none"] * len(refs) + if len(datum_material) != len(refs) or any(x not in modifiers for x in datum_material): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Specify one supported material modifier per datum" + ) + if projected_height is not None: + projected_height = finite(projected_height, "projected_height", True) + if characteristic in {"Straightness", "Flatness", "Circularity", "Cylindricity"} and ( + material != "none" or projected_height is not None + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "These form-tolerance modifier combinations are not supported", + ) + existing = self._engineering_owned(annotation, "annotation") if annotation else None + b = self._work_part().Annotations.CreatePmiFeatureControlFrameBuilder(existing) + try: + b.Characteristic = getattr( + A.FeatureControlFrameBuilder.FcfCharacteristic, characteristic + ) + b.FrameStyle = A.FeatureControlFrameBuilder.FcfFrameStyle.SingleFrame + frame = b.FeatureControlFrameDataList.FindItem(0) + frame.ToleranceValue = str(tolerance) + frame.MaterialModifier = getattr( + A.FeatureControlFrameDataBuilder.ToleranceMaterialModifier, modifiers[material] + ) + frame.ZoneShape = getattr( + A.FeatureControlFrameDataBuilder.ToleranceZoneShape, zones[zone_shape] + ) + frame.Projected = projected_height is not None + frame.ProjectedValue = str(projected_height or 0) + frame.TangentPlane = tangent_plane + frame.FreeState = free_state + datum_fields = [ + "PrimaryDatumReference", + "SecondaryDatumReference", + "TertiaryDatumReference", + ] + for index, name in enumerate(datum_fields): + datum = getattr(frame, name) + datum.Letter = letters[index] if index < len(letters) else "" + datum.MaterialCondition = getattr( + A.DatumReferenceBuilder.DatumReferenceMaterialCondition, + modifiers[datum_material[index] if index < len(datum_material) else "none"], + ) + result = self._commit_pmi(b, targets, point, existing) + result.update( + { + "characteristic": characteristic, + "tolerance": tolerance, + "datum_letters": letters, + "material": material, + "zone_shape": zone_shape, + "datum_material": datum_material, + "projected_height": projected_height, + "tangent_plane": tangent_plane, + "free_state": free_state, + } + ) + return result + finally: + b.Destroy() + + def _commit_pmi(self, builder, targets, position, existing): + builder.AssociatedObjects.Nxobjects.Clear() + builder.AssociatedObjects.Nxobjects.Add(targets) + builder.Origin.SetInferRelativeToGeometry(True) + builder.Origin.Origin.SetValue(None, None, self.nxopen.Point3d(*position)) + if not builder.Validate(): + raise NXToolError("NX_ANNOTATION_INVALID", "Native PMI builder validation failed") + obj = builder.Commit() + self._update_model() + if obj is None: + raise NXToolError("NX_VERIFICATION_FAILED", "NX returned no PMI object") + ref = self._reference(obj, "annotation", self._work_part(), "PMI") + return { + "annotation": ref, + "created": [] if existing else [ref], + "modified": [ref] if existing else [], + "units": self._units(), + "coordinate_frame": "work_part", + "geometry_associated": True, + } + + def _face_samples(self, faces, samples_per_axis): + import NXOpen.UF as U + + if type(samples_per_axis) is not int or not 1 <= samples_per_axis <= 20: + raise NXToolError("NX_INVALID_ARGUMENT", "samples_per_axis must be 1..20") + if len(faces) * samples_per_axis**2 > 10000: + raise NXToolError( + "NX_SAMPLE_LIMIT", "Select fewer faces or lower sample density (maximum 10000)" + ) + uf = U.UFSession.GetUFSession().Modeling + samples = [] + skipped = 0 + for face in faces: + bounds = uf.AskFaceUvMinmax(face.Tag) + for i in range(samples_per_axis): + for j in range(samples_per_axis): + uv = [ + bounds[0] + (bounds[1] - bounds[0]) * (i + 0.5) / samples_per_axis, + bounds[2] + (bounds[3] - bounds[2]) * (j + 0.5) / samples_per_axis, + ] + data = uf.AskFaceProps(face.Tag, uv) + if uf.AskPointContainment(data[0], face.Tag) == 2: + skipped += 1 + continue + samples.append((face, uv, data)) + return samples, skipped + + def _face_analysis(self, faces, samples_per_axis=3, pull_direction=None, minimum_draft=1.0): + import math + + from nx_mcp.visual_tools import unit_normal + + objects = self._freeform_refs(faces, "face") + direction = unit_normal(pull_direction) if pull_direction is not None else None + minimum = finite(minimum_draft, "minimum_draft") + if not 0 <= minimum < 90 or (direction is None and minimum_draft != 1.0): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "minimum_draft requires a pull direction and must be 0..<90 degrees", + ) + samples, skipped = self._face_samples(objects, samples_per_axis) + reports = [] + for face, uv, data in samples: + point, _, _, _, _, normal, radii = data + curvature = [0.0 if abs(r) > 1e25 else 1 / r if abs(r) > 1e-12 else None for r in radii] + record = { + "face": self._reference(face, "face", self._work_part(), "Face"), + "uv": uv, + "point": list(point), + "normal": list(normal), + "principal_radii": [None if abs(r) > 1e25 else r for r in radii], + "principal_curvatures": curvature, + } + if direction is not None: + cosine = sum(a * b for a, b in zip(normal, direction, strict=False)) + draft = math.degrees(math.asin(max(-1.0, min(1.0, cosine)))) + record.update( + { + "signed_draft_degrees": draft, + "classification": "negative" + if draft < -1e-7 + else "below_minimum" + if draft < minimum + else "positive", + } + ) + reports.append(record) + return { + "samples": reports, + "sample_count": len(reports), + "skipped_outside_trim": skipped, + "units": self._units(), + "curvature_units": "1/" + self._units(), + "coordinate_frame": "work_part", + "pull_direction": direction, + "minimum_draft_degrees": minimum if direction is not None else None, + "method": "native face derivatives at a trimmed UV grid; sampled values, not certified global extrema or mold-release feasibility", + } + + def _wall_thickness(self, body, faces=None, samples_per_axis=3, tolerance=0.001): + import math + + import NXOpen.UF as U + + target = self._engineering_owned(body, "body") + if not target.IsSolidBody: + raise NXToolError("NX_NOT_SOLID", "Wall thickness requires a solid body") + tolerance = finite(tolerance, "tolerance", True) + selected = ( + self._freeform_refs(faces, "face") if faces is not None else list(target.GetFaces()) + ) + if any(f.GetBody() != target for f in selected): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Faces must belong to the selected body") + samples, skipped = self._face_samples(selected, samples_per_axis) + uf = U.UFSession.GetUFSession().Modeling + face_map = {int(f.Tag): f for f in target.GetFaces()} + identity = [1.0 if i % 5 == 0 else 0.0 for i in range(16)] + reports = [] + for face, _uv, data in samples: + point, normal = data[0], data[5] + direction = [-v for v in normal] + origin = [p + tolerance * d for p, d in zip(point, direction, strict=False)] + if uf.AskPointContainment(origin, target.Tag) != 1: + reports.append( + { + "source_face": self._reference(face, "face", self._work_part(), "Face"), + "point": list(point), + "status": "unresolved", + "reason": "Inward offset is not inside solid; decrease tolerance or sample away from boundaries", + } + ) + continue + _, hits = uf.TraceARay(1, [target.Tag], origin, direction, identity, 0) + hit = next( + ( + h + for h in hits + if sum( + (a - b) * d for a, b, d in zip(h.HitPoint, origin, direction, strict=False) + ) + > 0 + ), + None, + ) + record = { + "source_face": self._reference(face, "face", self._work_part(), "Face"), + "point": list(point), + "status": "unresolved", + } + if hit is not None: + distance = math.dist(point, hit.HitPoint) + record.update( + { + "status": "measured", + "thickness": distance, + "opposite_point": list(hit.HitPoint), + "opposite_face": self._reference( + face_map[int(hit.HitFace)], "face", self._work_part(), "Face" + ), + } + ) + reports.append(record) + distances = [x["thickness"] for x in reports if x["status"] == "measured"] + return { + "body": self._reference(target, "body", self._work_part(), "Body"), + "samples": reports, + "sample_count": len(reports), + "measured_count": len(distances), + "skipped_outside_trim": skipped, + "minimum_sampled_thickness": min(distances) if distances else None, + "maximum_sampled_thickness": max(distances) if distances else None, + "tolerance": tolerance, + "units": self._units(), + "coordinate_frame": "work_part", + "method": "first exit along inward native face normal; sampled wall distance, not global minimum thickness or rolling-ball thickness", + } diff --git a/src/nx_mcp/manufacturing_server.py b/src/nx_mcp/manufacturing_server.py new file mode 100644 index 0000000..e49f858 --- /dev/null +++ b/src/nx_mcp/manufacturing_server.py @@ -0,0 +1,123 @@ +"""Native manufacturing detail and bounded, explicitly sampled analysis contracts.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from nx_mcp.schema_types import Field + +READ_ONLY = {"nx_face_analysis", "nx_wall_thickness"} +NON_MODEL: set[str] = set() + + +def nx_thread( + face: str, + start_face: str, + pitch: float, + major_diameter: float, + minor_diameter: float, + length: float, + angle: float = 60.0, + detailed: bool = False, + left_hand: bool = False, + starts: int = 1, + reverse: bool = False, +): + """Create an associative native manual thread on a cylindrical face with an explicit start face on the same body. All lengths use part units; angle is the included profile angle in degrees. Symbolic threads preserve simplified geometry; detailed=True models the thread. Actual cylinder diameter is used for tap-drill/shaft diameter. Specify valid minor/major diameters, pitch, length, handedness and 1..16 starts. No standard or fit class is inferred from these manual dimensions. Returns native internal/external classification and parameter readback.""" + + +def nx_pmi_datum( + faces: list[str], letter: str, position: list[float], annotation: str | None = None +): + """Create or edit a native geometry-associated PMI datum feature symbol on work-part faces. letter is 1..3 uppercase letters, position is [x,y,z] in part units. annotation edits an existing datum ID. Uses the current annotation plane. Datum schemes are supplied by the caller, not inferred from manufacturing intent.""" + + +def nx_pmi_fcf( + faces: list[str], + characteristic: Literal[ + "Straightness", + "Flatness", + "Circularity", + "Cylindricity", + "ProfileOfALine", + "ProfileOfASurface", + "Angularity", + "Perpendicularity", + "Parallelism", + "Position", + "Concentricity", + "Symmetry", + "CircularRunout", + "TotalRunout", + "AxisIntersection", + ], + tolerance: float, + position: list[float], + datums: list[str] | None = None, + annotation: str | None = None, + material: Literal["none", "MMC", "LMC", "RFS"] = "none", + zone_shape: Literal["none", "diameter", "spherical_diameter", "square"] = "none", + datum_material: list[Literal["none", "MMC", "LMC", "RFS"]] | None = None, + projected_height: float | None = None, + tangent_plane: bool = False, + free_state: bool = False, +): + """Create/edit a native single-frame geometry-associated GD&T PMI feature-control frame. Select work-part faces, a positive tolerance in part units, [x,y,z] annotation position and up to three ordered existing datum annotation IDs. Form tolerances reject datum references. Supports explicit tolerance/datum material modifiers, zone shape, projected height, tangent-plane and free-state flags. Omitted modifiers reset to none on editing. Does not claim a complete standards compliance check. annotation edits an existing FCF ID.""" + + +def nx_face_analysis( + faces: list[str], + samples_per_axis: int = 3, + pull_direction: list[float] | None = None, + minimum_draft: float = 1.0, +): + """Sample native face normals and principal curvatures on a trimmed UV grid. Optional work-part pull_direction enables signed draft angles: asin(normal dot pull), with positive/negative/below-minimum classifications. minimum_draft is degrees. 1..20 samples per UV axis, at most 10000 total; skips points outside trimmed boundaries. Reports sampled values, not global curvature extrema, mold-release feasibility, or an undercut certification.""" + + +def nx_wall_thickness( + body: str, + faces: list[str] | None = None, + samples_per_axis: int = 3, + tolerance: float = 0.001, +): + """Measure a solid's sampled wall thickness using native inward-normal ray intersections. Select an owned work-part solid and optionally its faces. Sample a trimmed UV grid (1..20 per axis, max 10000); start each ray tolerance part-units inside the solid. Returns source/opposite faces and points, sampled min/max, unresolved counts and units. This is first-exit normal thickness, not global minimum or rolling-ball thickness. Thin regions smaller than tolerance require a smaller tolerance.""" + + +def nx_thread_catalog( + standard: str | None = None, size: str | None = None, offset: int = 0, limit: int = 50 +): + """Query installed NX thread-table choices in place. Without standard returns names; select standard for sizes and exact size for dimensional metadata, method and radial engagement. Paged, 1..200 rows. No catalog file transfer; no invented fit classes. Exact catalog strings are required by nx_standard_thread.""" + + +def nx_standard_thread( + face: str, + start_face: str, + standard: str, + size: str, + length: float, + method: str | None = None, + radial_engage: str | None = None, + detailed: bool = False, + left_hand: bool = False, + reverse: bool = False, +): + """Create a native ThreadTable thread from one installed standard/size row. Use nx_thread_catalog to disambiguate method and radial_engage. Select a cylindrical face and same-body start face; length uses part units. Symbolic or detailed, handedness and direction are explicit. Returns native dimensions and actual catalog selection; no manual approximation of a standard thread.""" + + +READ_ONLY.add("nx_thread_catalog") + + +NON_MODEL.add("nx_export_planar_dxf") +Point3 = Annotated[list[float], Field(min_length=3, max_length=3)] + + +def nx_export_planar_dxf( + source: str, + path: str, + origin: Point3 | None = None, + x_axis: Point3 | None = None, + y_axis: Point3 | None = None, + layer: str = "OUTLINE", + layers: dict[str, str] | None = None, +): + """Export an owned sketch or planar face (all boundary loops, including holes) as analytic LINE/ARC/CIRCLE DXF at 1:1 mm. No model changes or save. Rejects splines and non-planar geometry, never approximates them. Default frame is the sketch basis or a deterministic face basis; response reports the exact frame. Supply both orthonormal axes and an origin in the source plane to choose PCB coordinates, in work-part units. layers maps current source curve/edge IDs to ASCII layer names; unlisted entities use layer. Existing files are rejected. This is separate from native sheet-metal flat-pattern export.""" diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py index 4d65aa1..479c5c3 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -2,8 +2,10 @@ from __future__ import annotations +import base64 import os import secrets +import subprocess from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -30,6 +32,12 @@ class NXOpenExecutor: "nx_sketch_rectangle", "nx_finish_sketch", "nx_extrude", + "nx_revolve", + "nx_sketch_arc", + "nx_hole", + "nx_boolean", + "nx_add_component", + "nx_reposition_component", } def __init__( @@ -67,6 +75,19 @@ def __init__( "nx_extrude": self._extrude, "nx_undo": self._undo, "nx_fit_view": self._fit_view, + "nx_revolve": self._revolve, + "nx_sketch_arc": self._sketch_arc_legacy, + "nx_hole": self._hole, + "nx_boolean": self._boolean, + "nx_screenshot": self._screenshot, + "nx_get_bounding_box": self._get_bounding_box, + "nx_measure_volume": self._measure_volume, + "nx_add_component": self._add_component, + "nx_list_components": self._list_components, + "nx_reposition_component": self._reposition_component, + "nx_set_view": self._set_view, + "nx_get_feature_info": self._get_feature_info, + "nx_list_open_parts": self._list_open_parts, } def execute(self, method: str, params: dict[str, Any]) -> dict[str, Any]: @@ -210,7 +231,7 @@ def _open_part(self, path: str) -> dict[str, Any]: def _save_part(self) -> dict[str, Any]: part = self._work_part() part.Save( - self.nxopen.BasePart.SaveComponents.TrueValue, + self.nxopen.BasePart.SaveComponents.FalseValue, self.nxopen.BasePart.CloseAfterSave.FalseValue, ) self._undo_marks.clear() @@ -232,18 +253,42 @@ def _close_part(self, save: bool = True) -> dict[str, Any]: return {"message": f"Closed part: {part_name}"} def _export_step(self, path: str) -> dict[str, Any]: - self._work_part() + part = self._work_part() destination = self.workspace.ensure_inside(path) destination.parent.mkdir(parents=True, exist_ok=True) + self._save_part() builder = self.session.DexManager.CreateStepCreator() try: + builder.SettingsFile = str( + Path(os.environ.get("UGII_BASE_DIR", r"C:\Program Files\Siemens\Designcenter2606")) + / "STEP214UG" + / "ugstep214.def" + ) + builder.LayerMask = "1-256" + builder.InputFile = part.FullPath + builder.ExportFrom = self.nxopen.StepCreator.ExportFromOption.ExistingPart + builder.ObjectTypes.Solids = True + builder.ObjectTypes.Surfaces = True + builder.ObjectTypes.Curves = False builder.OutputFile = str(destination) + builder.ProcessHoldFlag = True builder.Commit() finally: builder.Destroy() + if not destination.is_file() or destination.stat().st_size == 0: + log = destination.with_suffix(".log") + detail = ( + log.read_text(errors="replace")[-1200:] if log.is_file() else "No translator log" + ) + raise NXToolError("NX_EXPORT_FAILED", "STEP output was not created: " + detail) + payload = destination.read_text(errors="replace") + if not any( + token in payload for token in ("MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS", "FACETED_BREP") + ): + raise NXToolError("NX_EXPORT_NO_SOLIDS", "STEP file contains no solid BREP entities") return { "path": str(destination), - "message": f"Exported STEP: {destination.name}", + "message": f"Exported and verified STEP file: {destination.name}", } def _create_sketch(self, plane: str = "XY", name: str | None = None) -> dict[str, Any]: @@ -422,6 +467,483 @@ def _fit_view(self) -> dict[str, Any]: part.ModelingViews.WorkView.Fit() return {"message": "View fitted"} + def _find_sketch(self, name: str) -> Any: + part = self._work_part() + for sketch in part.Sketches: + sketch_name = self._name(sketch, "Sketch") + if sketch_name == name or sketch_name.endswith(name): + return sketch + raise NXToolError("NX_NOT_FOUND", f"Sketch not found: {name}") + + def _revolve( + self, + angle: float = 360.0, + axis: str = "Z", + sketch_name: str | None = None, + boolean: str = "none", + axis_origin: list[float] | None = None, + axis_direction: list[float] | None = None, + ) -> dict[str, Any]: + if angle <= 0 or angle > 360: + raise NXToolError("NX_INVALID_ARGUMENT", "angle must be greater than 0 and at most 360") + if not sketch_name: + raise NXToolError("NX_INVALID_ARGUMENT", "sketch_name is required") + vectors = { + "X": (1.0, 0.0, 0.0), + "Y": (0.0, 1.0, 0.0), + "Z": (0.0, 0.0, 1.0), + "-X": (-1.0, 0.0, 0.0), + "-Y": (0.0, -1.0, 0.0), + "-Z": (0.0, 0.0, -1.0), + } + axis_key = axis.strip().upper() + if axis_key not in vectors: + raise NXToolError("NX_INVALID_ARGUMENT", "axis must be X, Y, Z, -X, -Y, or -Z") + from nx_mcp.hardened import vector as point_vector + from nx_mcp.visual_tools import unit_normal + + if (axis_origin is None) != (axis_direction is None): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply both axis_origin and axis_direction") + if axis_direction is not None and axis != "Z": + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Custom axis cannot be combined with a nondefault principal axis", + ) + axis_point = ( + point_vector(axis_origin, "axis_origin") if axis_origin is not None else [0.0, 0.0, 0.0] + ) + axis_vector = ( + unit_normal(axis_direction, "axis_direction") + if axis_direction is not None + else vectors[axis_key] + ) + boolean_types = { + "none": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Create, + "unite": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Unite, + "subtract": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Subtract, + "intersect": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Intersect, + } + boolean_key = boolean.strip().lower() + if boolean_key not in boolean_types: + raise NXToolError( + "NX_INVALID_ARGUMENT", "boolean must be none, unite, subtract, or intersect" + ) + part = self._work_part() + sketch = self._find_sketch(sketch_name) + section = part.Sections.CreateSection() + rule = part.ScRuleFactory.CreateRuleCurveFeature( + [sketch.Feature], + self.nxopen.DisplayableObject.Null, + part.ScRuleFactory.CreateRuleOptions(), + ) + section.AddToSection( + [rule], + self.nxopen.NXObject.Null, + self.nxopen.NXObject.Null, + self.nxopen.NXObject.Null, + self.nxopen.Point3d(0.0, 0.0, 0.0), + self.nxopen.Section.Mode.Create, + False, + ) + vector = self.nxopen.Vector3d(*axis_vector) + origin = part.Points.CreatePoint(self.nxopen.Point3d(*axis_point)) + direction = part.Directions.CreateDirection(origin, vector) + revolve_axis = part.Axes.CreateAxis( + origin, + direction, + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + builder = part.Features.CreateRevolveBuilder(self.nxopen.Features.Feature.Null) + try: + builder.Section = section + builder.Axis = revolve_axis + builder.Limits.StartExtend.Value.RightHandSide = "0" + builder.Limits.EndExtend.Value.RightHandSide = str(angle) + builder.BooleanOperation.Type = boolean_types[boolean_key] + if boolean_key != "none": + bodies = list(part.Bodies) + if not bodies: + raise NXToolError("NX_NO_TARGET_BODY", "No target body is available") + builder.BooleanOperation.SetTargetBodies(bodies) + feature = builder.CommitFeature() + finally: + builder.Destroy() + return { + "feature": self._reference(feature, "feature", part, "Revolve"), + "angle": angle, + "axis": axis_key if axis_direction is None else "custom", + "axis_origin": list(axis_point), + "axis_direction": list(axis_vector), + "message": f"Revolved {self._name(sketch, sketch_name)} by {angle} degrees", + } + + def _sketch_arc_legacy( + self, + cx: float, + cy: float, + radius: float, + start_angle: float, + end_angle: float, + ) -> dict[str, Any]: + import math + + if radius <= 0: + raise NXToolError("NX_INVALID_ARGUMENT", "radius must be greater than zero") + if start_angle == end_angle: + raise NXToolError("NX_INVALID_ARGUMENT", "start_angle and end_angle must differ") + part = self._work_part() + arc = part.Curves.CreateArc( + self.nxopen.Point3d(cx, cy, 0.0), + self.nxopen.Vector3d(1.0, 0.0, 0.0), + self.nxopen.Vector3d(0.0, 1.0, 0.0), + radius, + math.radians(start_angle), + math.radians(end_angle), + ) + sketch = self.session.ActiveSketch + if sketch is None: + raise NXToolError("NX_NO_ACTIVE_SKETCH", "Create and activate an XY sketch first") + sketch.AddGeometry(arc, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints) + reference = self._reference(arc, "curve", part, "Arc") + return { + "object": reference, + "center": [cx, cy], + "radius": radius, + "start_angle": start_angle, + "end_angle": end_angle, + "message": "Created arc", + } + + def _hole( + self, + diameter: float, + depth: float, + x: float, + y: float, + z: float, + ) -> dict[str, Any]: + if diameter <= 0 or depth <= 0: + raise NXToolError("NX_INVALID_ARGUMENT", "diameter and depth must be greater than zero") + part = self._work_part() + bodies = list(part.Bodies) + if not bodies: + raise NXToolError("NX_NO_TARGET_BODY", "Create a solid body before creating a hole") + builder = part.Features.CreateCylinderBuilder(self.nxopen.Features.Feature.Null) + try: + builder.Origin = self.nxopen.Point3d(x, y, z) + builder.Direction = self.nxopen.Vector3d(0.0, 0.0, 1.0) + builder.Diameter.RightHandSide = str(diameter) + builder.Height.RightHandSide = str(depth) + builder.BooleanOption.Type = ( + self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Subtract + ) + builder.BooleanOption.SetTargetBodies([bodies[0]]) + feature = builder.CommitFeature() + finally: + builder.Destroy() + return { + "feature": self._reference(feature, "feature", part, "Hole"), + "diameter": diameter, + "depth": depth, + "location": [x, y, z], + "message": "Created cylindrical hole along +Z", + } + + def _resolve_body(self, value: str, part: Any) -> Any: + try: + return self.objects.resolve( + value, + expected_kind="body", + part_id=self._part_id(part), + ) + except NXToolError: + pass + bodies = list(part.Bodies) + for index, body in enumerate(bodies, start=1): + names = { + self._name(body, f"body_{index}"), + f"body_{index}", + str(getattr(body, "JournalIdentifier", "")), + } + if value in names: + return body + raise NXToolError("NX_NOT_FOUND", f"Body not found: {value}") + + def _boolean(self, boolean_type: str, targets: list[str]) -> dict[str, Any]: + type_map = { + "unite": self.nxopen.Features.FeatureBooleanType.Unite, + "subtract": self.nxopen.Features.FeatureBooleanType.Subtract, + "intersect": self.nxopen.Features.FeatureBooleanType.Intersect, + } + key = boolean_type.strip().lower() + if key not in type_map: + raise NXToolError( + "NX_INVALID_ARGUMENT", "boolean_type must be unite, subtract, or intersect" + ) + if len(targets) < 2: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "targets must contain the target body followed by at least one tool body", + ) + part = self._work_part() + bodies = [self._resolve_body(value, part) for value in targets] + builder = part.Features.CreateBooleanBuilder(self.nxopen.Features.BooleanFeature.Null) + try: + builder.Operation = type_map[key] + builder.Target = bodies[0] + builder.Tools.Add(bodies[1:]) + feature = builder.CommitFeature() + finally: + builder.Destroy() + return { + "feature": self._reference(feature, "feature", part, "Boolean"), + "boolean_type": key, + "targets": targets, + "message": f"Boolean {key} completed", + } + + def _get_bounding_box(self, body=None): + import NXOpen.UF + + part = self._work_part() + bodies = [self._resolve_body(body, part)] if body else list(part.Bodies) + uf = NXOpen.UF.UFSession.GetUFSession() + rows = [] + for item in bodies: + box = list(uf.ModlGeneral.AskBoundingBox(item.Tag)) + rows.append( + { + "body": self._reference(item, "body", part, "Body"), + "solid": bool(item.IsSolidBody), + "box": box, + } + ) + if not rows: + raise NXToolError("NX_NO_TARGET_BODY", "No bodies in work part") + low = [min(row["box"][i] for row in rows) for i in range(3)] + high = [max(row["box"][i + 3] for row in rows) for i in range(3)] + return { + "min": low, + "max": high, + "dimensions": [b - a for a, b in zip(low, high, strict=False)], + "units": str(part.PartUnits), + "bodies": rows, + "message": "UF bounding boxes; may be conservative for curved geometry", + } + + def _measure_volume(self, body=None): + part = self._work_part() + bodies = [self._resolve_body(body, part)] if body else list(part.Bodies) + units = [ + part.UnitCollection.FindObject(n) + for n in ["SquareMilliMeter", "CubicMilliMeter", "Kilogram", "MilliMeter", "Newton"] + ] + rows = [] + for item in bodies: + if not item.IsSolidBody: + raise NXToolError("NX_NOT_SOLID", "Volume measurement requires solid bodies") + props = part.MeasureManager.NewMassProperties(units, 0.999, [item]) + try: + props.InformationUnit = self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter + rows.append( + { + "body": self._reference(item, "body", part, "Body"), + "volume_mm3": float(props.Volume), + } + ) + finally: + props.Dispose() + return { + "bodies": rows, + "volume_mm3": sum(r["volume_mm3"] for r in rows), + "message": "Sum of solid body volumes; overlapping bodies are counted separately", + } + + def _add_component(self, part_path, name=None): + part = self._work_part() + path = self.workspace.ensure_inside(part_path) + matrix = self.nxopen.Matrix3x3() + matrix.Xx = matrix.Yy = matrix.Zz = 1.0 + component, status = part.ComponentAssembly.AddComponent( + str(path), + "Entire Part", + name or Path(path).stem, + self.nxopen.Point3d(0.0, 0.0, 0.0), + matrix, + -1, + ) + try: + return { + "component": component.Name, + "tag": int(component.Tag), + "path": str(path), + "message": "Component added at origin", + } + finally: + if status is not None: + status.Dispose() + + def _list_components(self): + part = self._work_part() + root = part.ComponentAssembly.RootComponent + rows = [] + + def walk(parent, depth): + for comp in parent.GetChildren(): + point, matrix = comp.GetPosition() + rows.append( + { + "name": comp.Name, + "tag": int(comp.Tag), + "depth": depth, + "translation": [point.X, point.Y, point.Z], + "rotation": [ + matrix.Xx, + matrix.Xy, + matrix.Xz, + matrix.Yx, + matrix.Yy, + matrix.Yz, + matrix.Zx, + matrix.Zy, + matrix.Zz, + ], + "part_path": comp.Prototype.FullPath, + } + ) + walk(comp, depth + 1) + + if root is not None: + walk(root, 0) + return {"components": rows, "count": len(rows)} + + def _reposition_component(self, component, dx=0, dy=0, dz=0, rx=0, ry=0, rz=0): + import math + + part = self._work_part() + root = part.ComponentAssembly.RootComponent + matches = [c for c in root.GetChildren() if c.Name == component] + if len(matches) != 1: + raise NXToolError( + "NX_NOT_FOUND", "Component name must match exactly one immediate child" + ) + a, b, c = [math.radians(v) for v in (rx, ry, rz)] + sx, cx, sy, cy, sz, cz = ( + math.sin(a), + math.cos(a), + math.sin(b), + math.cos(b), + math.sin(c), + math.cos(c), + ) + matrix = self.nxopen.Matrix3x3() + values = [ + cz * cy, + sz * cy, + -sy, + cz * sy * sx - sz * cx, + sz * sy * sx + cz * cx, + cy * sx, + cz * sy * cx + sz * sx, + sz * sy * cx - cz * sx, + cy * cx, + ] + for key, value in zip( + ("Xx", "Xy", "Xz", "Yx", "Yy", "Yz", "Zx", "Zy", "Zz"), values, strict=False + ): + setattr(matrix, key, value) + part.ComponentAssembly.MoveComponent(matches[0], self.nxopen.Vector3d(dx, dy, dz), matrix) + return {"component": component, "message": "Applied relative translation and rotation"} + + def _set_view(self, orientation): + options = { + "isometric": "Isometric", + "trimetric": "Trimetric", + "front": "Front", + "back": "Back", + "top": "Top", + "bottom": "Bottom", + "left": "Left", + "right": "Right", + } + key = orientation.strip().lower() + if key not in options: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown view orientation") + self._work_part().ModelingViews.WorkView.Orient( + getattr(self.nxopen.View.Canned, options[key]), self.nxopen.View.ScaleAdjustment.Fit + ) + return { + "message": "View orientation set", + "orientation": key, + "viewport_available": not self.session.IsBatch, + } + + def _get_feature_info(self, name): + part = self._work_part() + try: + feature = self.objects.resolve( + name, expected_kind="feature", part_id=self._part_id(part) + ) + except NXToolError: + matches = [f for f in part.Features if f.Name == name or f.JournalIdentifier == name] + if len(matches) != 1: + raise NXToolError( + "NX_NOT_FOUND", "Feature reference is not unique or does not exist" + ) from None + feature = matches[0] + return { + "name": feature.Name, + "type": feature.FeatureType, + "identifier": feature.JournalIdentifier, + "expressions": [ + {"name": e.Name, "formula": e.RightHandSide} for e in feature.GetExpressions() + ], + } + + def _list_open_parts(self): + return {"parts": [{"name": p.Name, "path": p.FullPath} for p in self.session.Parts]} + + def _screenshot(self, path: str) -> dict[str, Any]: + destination = self.workspace.ensure_inside(path) + destination.parent.mkdir(parents=True, exist_ok=True) + script = r""" +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen +$bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) +try { + $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size) + $bitmap.Save($env:NX_MCP_SCREENSHOT_PATH, [System.Drawing.Imaging.ImageFormat]::Png) +} finally { + $graphics.Dispose() + $bitmap.Dispose() +} +""" + encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") + environment = dict(os.environ) + environment["NX_MCP_SCREENSHOT_PATH"] = str(destination) + completed = subprocess.run( + [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + encoded, + ], + capture_output=True, + check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + env=environment, + text=True, + timeout=30, + ) + if completed.returncode != 0 or not destination.is_file(): + message = (completed.stderr or completed.stdout or "screen capture failed").strip() + raise NXToolError("NX_SCREENSHOT_FAILED", message) + return {"path": str(destination), "message": f"Screenshot saved to {destination.name}"} + @dataclass class BridgeRuntime: @@ -480,7 +1002,9 @@ def start_bridge( import NXOpen session = NXOpen.Session.GetSession() - executor = NXOpenExecutor( + from nx_mcp.hardened import HardenedExecutor + + executor = HardenedExecutor( session, NXOpen, _detect_nx_version(session), @@ -490,7 +1014,11 @@ def start_bridge( ) token = secrets.token_hex(32) dispatcher = MainThreadDispatcher(executor.execute) - server = BridgeServer(dispatcher.call, token=token) + server = BridgeServer( + dispatcher.call, + token=token, + result_directory=Path(workspace_root) / ".nx-mcp" / "bridge-results", + ) server.start() descriptor = BridgeDescriptor.create( server.port, diff --git a/src/nx_mcp/output_schemas.py b/src/nx_mcp/output_schemas.py new file mode 100644 index 0000000..360bd1d --- /dev/null +++ b/src/nx_mcp/output_schemas.py @@ -0,0 +1,640 @@ +"""JSON Schema payload contracts for the integration surface. + +Schemas describe committed results; they do not run a second validation step after +an NX mutation. Errors have a separate branch so success cardinality requirements +never hide a useful kernel error. Additive native metadata remains permitted. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + +S = {"type": "string"} +N = {"type": "number"} +COUNT = {"type": "integer", "minimum": 0} +B = {"type": "boolean"} +NULL = {"type": "null"} +OUTCOMES = [ + "not_started", + "running", + "committed", + "rolled_back", + "partial", + "unknown", + "not_applicable", +] + + +def arr(item: dict, count: int | None = None) -> dict: + result: dict[str, Any] = {"type": "array", "items": item} + if count is not None: + result.update(minItems=count, maxItems=count) + return result + + +def obj(properties: dict, required: list[str] | None = None) -> dict: + return { + "type": "object", + "properties": properties, + "required": list(properties) if required is None else required, + "additionalProperties": True, + } + + +VEC = arr(N, 3) +REF = obj( + { + "id": S, + "kind": S, + "name": S, + "part_id": S, + "session_id": S, + "generation_id": S, + "owner_part_path": S, + "journal_id": S, + "display_name": S, + }, + ["id", "kind", "name", "part_id"], +) +REF["description"] = ( + "Opaque identity, separate from display name. Reacquire after close, rollback or manual handoff; use occurrence context for assemblies." +) +HASH = {"type": "string", "pattern": "^[0-9a-f]{64}$"} +META = {"path": S, "size": COUNT, "sha256": HASH} +PAIR = obj( + { + "objects": arr(REF, 2), + "distance": N, + "closest_points": arr(VEC, 2), + "classification": {"enum": ["clear", "contact", "penetration", "below_clearance"]}, + "interference_volume_mm3": N, + } +) + +# Required fields follow actual bridge implementations; optional metadata remains typed. +PAYLOADS: dict[str, dict[str, Any]] = { + "nx_get_bounding_box": obj( + { + "min": VEC, + "max": VEC, + "dimensions": VEC, + "coordinate_frame": S, + "bounds_type": {"enum": ["exact", "conservative"]}, + "body_count": COUNT, + "scope": S, + "bodies": arr(obj({"body": REF, "box": arr(N, 6), "solid": B})), + } + ), + "nx_measure_distance": obj( + { + "distance": N, + "closest_points": arr(VEC, 2), + "references": arr(S, 2), + "resolved_tags": arr(COUNT, 2), + "coordinate_frame": S, + "pair_count": COUNT, + "method": S, + "accuracy": NULL, + } + ), + "nx_measure_volume": obj( + { + "volume_mm3": N, + "volume_units": {"const": "mm^3"}, + "semantics": {"const": "sum_of_included_bodies"}, + "body_count": COUNT, + "scope": S, + "bodies": arr(obj({"body": REF, "volume_mm3": N})), + } + ), + "nx_list_topology": obj({"body": REF, "faces": arr(REF), "edges": arr(REF), "solid": B}), + "nx_list_components": obj( + { + "count": COUNT, + "matrix_convention": S, + "components": arr( + obj( + { + "object": REF, + "name": S, + "part_path": S, + "depth": COUNT, + "translation": VEC, + "rotation_matrix": arr(VEC, 3), + "coordinate_frame": S, + "suppressed": B, + "reference_set": S, + } + ) + ), + } + ), + "nx_operation_status": obj( + { + "operation_id": S, + "state": {"enum": ["running", "committed", "failed", "unknown", "rolled_back"]}, + "mutation_outcome": {"enum": OUTCOMES}, + "method": S, + "reason": S, + "session_id": S, + "started_at": S, + "finished_at": S, + "result": {"type": "object"}, + "error": {"type": "object"}, + "reverted_by": S, + "query_operation_id": S, + "query_session_id": S, + }, + ["operation_id", "state", "mutation_outcome"], + ), + "nx_checkpoint": obj({"checkpoint_id": S, "message": S}), + "nx_rollback": obj({"checkpoint_id": S, "message": S}), + "nx_checkpoint_state": obj( + { + "checkpoints": arr( + obj({"checkpoint_id": S, "part_id": S, "index": COUNT, "label": S, "available": B}) + ), + "undo_depth": COUNT, + "save_semantics": S, + "retention": S, + } + ), + "nx_workspace_list": obj( + { + "path": S, + "count": COUNT, + "total_count": COUNT, + "offset": COUNT, + "next_offset": {"anyOf": [COUNT, NULL]}, + "entries": arr( + { + "oneOf": [ + obj({**META, "kind": {"const": "file"}}), + obj({"path": S, "kind": {"const": "directory"}}), + ] + } + ), + } + ), + "nx_download_file": { + "oneOf": [ + obj({**META, "delivery": {"const": "metadata"}}), + obj( + { + **META, + "delivery": {"const": "image"}, + "mime_type": {"const": "image/png"}, + "resolution": arr(COUNT, 2), + } + ), + obj( + { + **META, + "offset": COUNT, + "bytes_returned": COUNT, + "next_offset": {"anyOf": [COUNT, NULL]}, + "data_base64": S, + "eof": B, + } + ), + ] + }, +} +for name in ("nx_check_interference", "nx_check_clearance"): + PAYLOADS[name] = obj( + { + "pairs": arr(PAIR), + "counts": {"type": "object", "additionalProperties": COUNT}, + "body_count": COUNT, + "pair_count": COUNT, + "reported_pair_count": COUNT, + "broad_phase_clear_pairs": COUNT, + "minimum_clearance": N, + "volume_units": {"const": "mm^3"}, + "coordinate_frame": S, + "complete": B, + "semantics": S, + } + ) +for name in ("nx_extrude", "nx_pattern"): + PAYLOADS[name] = obj( + {"feature": REF, "body": {"anyOf": [REF, NULL]}, "bodies": arr(REF), "body_count": COUNT}, + ["feature", "bodies"], + ) +PAYLOADS["nx_revolve"] = obj({"feature": REF, "angle": N, "axis": S}) +for name in ( + "nx_screenshot", + "nx_render_view", + "nx_export_step", + "nx_export_drawing_pdf", + "nx_export_flat_pattern", + "nx_package_assembly", +): + PAYLOADS[name] = obj({**META, "mime_type": S, "resolution": arr(COUNT, 2)}, ["path", "sha256"]) + + +# Lifecycle and authoring results use their actual native field names. +INTEGER = {"type": "integer"} +PAGE = { + "count": COUNT, + "total_count": COUNT, + "offset": COUNT, + "next_offset": {"anyOf": [COUNT, NULL]}, +} +EXPRESSION = obj( + { + "object": REF, + "name": S, + "formula": S, + "type": S, + "value": {"anyOf": [N, NULL]}, + "units": S, + "editable": B, + "parents": arr(REF), + "dependents": arr(REF), + "dependency_scope": S, + } +) +PAYLOADS.update( + { + "nx_create_part": obj({"part": REF, "message": S}), + "nx_open_part": obj( + {"part": REF, "work": B, "display": B, "already_loaded": B, "path": S, "message": S} + ), + "nx_activate_part": obj({"part": REF, "work": B, "display": B, "message": S}), + "nx_save_as": obj({"part": REF, "path": S, "message": S}), + "nx_save_part": obj( + { + "path": S, + "message": S, + "recovery": PAYLOADS["nx_checkpoint_state"], + "requested_file": S, + "saved_files": arr(S), + "observed_changed_files": arr(S), + "save_scope": S, + "verification_scope": S, + "verified_part_count": COUNT, + "target_file": {"type": "object"}, + "unrelated_modified_parts": arr({"type": "object"}), + "unexpected_changes": arr({"type": "object"}), + "unexpected_new_parts": arr({"type": "object"}), + "native_save_errors": arr({"type": "object"}), + } + ), + "nx_close_part": obj( + { + "closed_parts": arr(REF), + "closed_count": COUNT, + "remaining_count": COUNT, + "message": S, + } + ), + "nx_list_open_parts": obj( + { + "parts": arr( + obj({"part": REF, "name": S, "path": S, "work": B, "display": B, "modified": B}) + ), + **PAGE, + } + ), + "nx_sketch_info": obj( + { + "object": REF, + "frame": obj( + { + "origin": VEC, + "x_axis": VEC, + "y_axis": VEC, + "normal": VEC, + "coordinate_frame": S, + } + ), + "curves": arr(obj({"object": REF, "type": S})), + "curve_count": COUNT, + } + ), + "nx_sketch_diagnostics": obj( + { + "sketch": REF, + "solver_status": S, + "remaining_degrees_of_freedom": {"anyOf": [INTEGER, NULL]}, + "native_dof_value": INTEGER, + "constraints": arr( + obj({"object": REF, "type": S, "expression": EXPRESSION}, ["object", "type"]) + ), + "constraint_count": COUNT, + "geometry": arr(obj({"object": REF, "constraints": arr(S)})), + "geometry_count": COUNT, + "evaluation": S, + "conflicting_constraints": NULL, + "work_region_handling": S, + } + ), + "nx_sheet_metal_feature": obj( + { + "feature": REF, + "bodies": arr(REF), + "body_count": COUNT, + "body": {"anyOf": [REF, NULL]}, + "coordinate_frame": S, + "created": arr(REF), + "modified": arr(REF), + "operation": S, + "native_feature_type": S, + "requested_parameters": {"type": "object"}, + "expressions": arr(EXPRESSION), + "model_view_name": S, + }, + [ + "feature", + "bodies", + "body_count", + "body", + "coordinate_frame", + "created", + "modified", + "operation", + "native_feature_type", + "requested_parameters", + "expressions", + ], + ), + "nx_sheet_metal_info": obj( + { + "items": arr( + obj( + { + "body": REF, + "sheet_metal": B, + "thickness": N, + "bends": arr( + obj( + { + "face": REF, + "state": S, + "inner_radius": N, + "angle_degrees": N, + "neutral_factor": N, + } + ) + ), + "bend_count": COUNT, + }, + ["body", "sheet_metal"], + ) + ), + "total": COUNT, + "offset": COUNT, + "next_offset": {"anyOf": [COUNT, NULL]}, + "coordinate_frame": S, + } + ), + "nx_list_drawings": obj( + { + "sheets": arr( + obj( + { + "object": REF, + "name": S, + "active": B, + "width": N, + "height": N, + "units": S, + "scale": arr(N, 2), + "views": arr(obj({"object": REF, "name": S})), + } + ) + ), + "units": {"const": "per_sheet"}, + "coordinate_frame": {"const": "drawing_sheet"}, + "modeling_active": B, + } + ), + "nx_drawing_view_info": obj( + { + "object": REF, + "drawing": REF, + "native_type": S, + "position": arr(N, 2), + "scale": N, + "bounds": arr(N, 4), + "inside_sheet": B, + "units": S, + "coordinate_frame": S, + "bounds_semantics": S, + } + ), + } +) +PAYLOADS["nx_activate_drawing"] = deepcopy(PAYLOADS["nx_list_drawings"]) +PAYLOADS["nx_edit_drawing_view"] = deepcopy(PAYLOADS["nx_drawing_view_info"]) +component_fields = PAYLOADS["nx_list_components"]["properties"]["components"]["items"] +component_fields["properties"].update( + part_path={"anyOf": [S, NULL]}, load_state=S, prototype_type={"anyOf": [S, NULL]} +) +component_fields["required"] = [ + x + for x in component_fields["required"] + if x not in {"translation", "rotation_matrix", "coordinate_frame"} +] +PAYLOADS["nx_list_components"]["properties"].update(PAGE) + + +REFERENCE_SET = obj( + { + "object": REF, + "name": S, + "member_count": COUNT, + "members": arr(REF), + "add_components_automatically": B, + } +) +DISPLAY_ROW = obj({"object": REF, "blanked": B}) +PAYLOADS.update( + { + "nx_create_reference_set": REFERENCE_SET, + "nx_list_reference_sets": obj( + {"reference_sets": arr(REFERENCE_SET), "count": COUNT, "built_in": arr(S)} + ), + "nx_set_component_reference_set": obj( + { + "components": arr( + obj({"object": REF, "previous_reference_set": S, "reference_set": S}) + ), + "count": COUNT, + "prototype_parts_modified": {"const": False}, + } + ), + "nx_list_datums": obj( + { + "datums": arr(obj({"object": REF, "native_type": S, "blanked": B})), + "count": COUNT, + "scope": {"const": "work_part"}, + } + ), + "nx_set_datum_visibility": obj( + { + "restore_id": S, + "objects": arr(DISPLAY_ROW), + "count": COUNT, + "visible": B, + "scope": {"const": "work_part"}, + } + ), + "nx_flat_pattern_orientation_edges": obj( + { + "upward_face": REF, + "edges": arr( + obj({"edge": REF, "start": VEC, "end": VEC, "adjacent_faces": arr(S)}) + ), + "count": COUNT, + "coordinate_frame": S, + "eligibility": S, + } + ), + "nx_create_drawing": obj( + { + "object": REF, + "sheet_name": S, + "size": S, + "dimensions_mm": arr(N, 2), + "dimensions": arr(N, 2), + "scale": N, + "projection": S, + } + ), + "nx_list_dimensions": obj( + { + "dimensions": arr( + obj( + { + "object": REF, + "native_type": S, + "computed_value": N, + "retained": B, + "measurement_valid": B, + "origin": VEC, + } + ) + ), + "coordinate_frame": S, + } + ), + "nx_sheet_metal_defaults": obj( + { + "parameters": { + "type": "object", + "additionalProperties": {"anyOf": [EXPRESSION, NULL]}, + }, + "parameter_entry": S, + "bend_definition": S, + "bend_table": S, + "bend_allowance_formula": S, + "bend_deduction_formula": S, + "material": S, + "tool": S, + "material_catalog_status": S, + } + ), + } +) +PAYLOADS["nx_set_sheet_metal_defaults"] = deepcopy(PAYLOADS["nx_sheet_metal_defaults"]) + + +PAYLOADS.update( + { + "nx_read_result": obj( + { + "result_id": S, + "field": S, + "value": {}, + "total_count": {"anyOf": [COUNT, NULL]}, + "offset": COUNT, + "next_offset": {"anyOf": [COUNT, NULL]}, + "omitted": {"type": "object"}, + } + ), + "nx_dimension_format": obj( + { + "object": REF, + "computed_value": N, + "measurement_units": S, + "measurement_valid": B, + "decimal_places": COUNT, + "tolerance_decimal_places": COUNT, + "upper_tolerance": N, + "lower_tolerance": N, + "tolerance_type": S, + "trailing_zeros": B, + "display_units": S, + "decimal_separator": S, + "association_count": COUNT, + } + ), + "nx_export_planar_dxf": obj( + { + "path": S, + "size": COUNT, + "sha256": S, + "units": {"const": "mm"}, + "scale": {"const": 1.0}, + "coordinate_frame": {"type": "object"}, + "entity_count": COUNT, + "entity_counts": {"type": "object"}, + "entities": arr({"type": "object"}), + "all_boundary_loops_included": B, + } + ), + } +) +PAYLOADS["nx_edit_dimension_format"] = deepcopy(PAYLOADS["nx_dimension_format"]) + + +def output_schema(name: str, common: dict) -> dict: + """Keep an object root for MCP; discriminate errors before success payloads.""" + schema = deepcopy(common) + schema["title"] = name + " result" + schema["description"] = "Structured success/error envelope with additive metadata. " + ( + "Tool-specific payload is typed." + if name in PAYLOADS + else "Tool-specific payload remains extensible." + ) + schema["properties"].update( + { + "full_result": obj( + { + "id": S, + "sha256": S, + "size": COUNT, + "tool": {"const": "nx_read_result"}, + "immutable": {"const": True}, + } + ), + "omitted": {"type": "object"}, + "code": S, + "message": S, + "retryable": B, + "suggestion": S, + "nx_code": {"type": ["integer", "string"]}, + "details": obj({"operation_id": S, "mutation_outcome": {"enum": OUTCOMES}}, []), + } + ) + schema["properties"]["mutation_outcome"] = {"anyOf": [{"enum": OUTCOMES}, NULL]} + schema["allOf"] = [ + { + "if": {"properties": {"status": {"const": "error"}}}, + "then": {"required": ["code", "message", "retryable"]}, + } + ] + if name in PAYLOADS: + schema["allOf"].append( + { + "if": { + "properties": {"status": {"const": "success"}}, + "not": {"required": ["full_result"]}, + }, + "then": deepcopy(PAYLOADS[name]), + } + ) + return schema diff --git a/src/nx_mcp/planar_dxf.py b/src/nx_mcp/planar_dxf.py new file mode 100644 index 0000000..170d7db --- /dev/null +++ b/src/nx_mcp/planar_dxf.py @@ -0,0 +1,221 @@ +"""Analytic planar DXF export from native NX curves, without model mutation.""" + +import hashlib +import math +import re +from collections import Counter + +from nx_mcp.runtime import NXToolError + + +def dxf_text(entities): + """Write an ASCII DXF with explicit millimeter units and layer definitions.""" + rows = [] + + def put(*pairs): + for code, value in pairs: + rows.extend( + (str(code), format(value, ".15g") if isinstance(value, float) else str(value)) + ) + + put( + (0, "SECTION"), + (2, "HEADER"), + (9, "$ACADVER"), + (1, "AC1027"), + (9, "$INSUNITS"), + (70, 4), + (9, "$MEASUREMENT"), + (70, 1), + (0, "ENDSEC"), + ) + layers = sorted({e["layer"] for e in entities} | {"0"}) + put( + (0, "SECTION"), + (2, "TABLES"), + (0, "TABLE"), + (2, "LTYPE"), + (70, 1), + (0, "LTYPE"), + (2, "CONTINUOUS"), + (70, 0), + (3, "Solid line"), + (72, 65), + (73, 0), + (40, 0.0), + (0, "ENDTAB"), + (0, "TABLE"), + (2, "LAYER"), + (70, len(layers)), + ) + for layer in layers: + put((0, "LAYER"), (2, layer), (70, 0), (62, 7), (6, "CONTINUOUS")) + put((0, "ENDTAB"), (0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES")) + for e in entities: + kind = e["type"] + put( + (0, kind), + (100, "AcDbEntity"), + (8, e["layer"]), + (100, "AcDbLine" if kind == "LINE" else "AcDbCircle"), + ) + p = e["start"] if kind == "LINE" else e["center"] + put((10, p[0]), (20, p[1]), (30, 0.0)) + if kind == "LINE": + put((11, e["end"][0]), (21, e["end"][1]), (31, 0.0)) + else: + put((40, e["radius"])) + if kind == "ARC": + put((100, "AcDbArc"), (50, e["start_angle"]), (51, e["end_angle"])) + put((0, "ENDSEC"), (0, "EOF")) + return "\n".join(rows) + "\n" + + +class PlanarDxfMixin: + def _export_planar_dxf( + self, source, path, origin=None, x_axis=None, y_axis=None, layer="OUTLINE", layers=None + ): + import NXOpen.UF as U + + from nx_mcp.hardened import cross, dot, vector + from nx_mcp.visual_tools import unit_normal + + destination = self.workspace.ensure_inside(path) + if destination.suffix.lower() != ".dxf" or destination.exists(): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Choose a new .dxf path; existing files are never overwritten", + details={"mutation_outcome": "not_started"}, + ) + layers = {} if layers is None else layers + for name in [layer, *layers.values()]: + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", name): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "DXF layer names require 1..64 ASCII letters, digits, underscore, period or hyphen", + ) + if (x_axis is None) != (y_axis is None): + raise NXToolError("NX_INVALID_ARGUMENT", "Supply both output basis axes") + obj = self._resolve(source, {"sketch", "face"}) + if obj.IsOccurrence or obj.OwningPart != self._work_part(): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Select an owned sketch or face in the work part" + ) + uf = U.UFSession.GetUFSession() + if isinstance(obj, self.nxopen.Sketch): + frame = self._sketch_frame(obj) + curves = list(obj.GetAllGeometry()) + kind = "curve" + else: + typ, point, normal, _, _, _, _ = uf.Modeling.AskFaceData(obj.Tag) + if typ != 22: + raise NXToolError("NX_NON_PLANAR", "Select a planar face or planar sketch") + normal = unit_normal(normal) + seed = [1, 0, 0] if abs(normal[0]) < 0.9 else [0, 1, 0] + x = unit_normal([seed[i] - dot(seed, normal) * normal[i] for i in range(3)]) + frame = { + "origin": list(point), + "x_axis": x, + "y_axis": cross(normal, x), + "normal": normal, + } + curves = list(obj.GetEdges()) + kind = "edge" + if not curves or len(curves) > 20000: + raise NXToolError("NX_OBJECT_LIMIT", "Select 1..20000 analytic curves") + o = vector(origin, "origin") if origin is not None else frame["origin"] + x = vector(x_axis, "x_axis") if x_axis is not None else frame["x_axis"] + y = vector(y_axis, "y_axis") if y_axis is not None else frame["y_axis"] + if abs(dot(x, x) - 1) > 1e-8 or abs(dot(y, y) - 1) > 1e-8 or abs(dot(x, y)) > 1e-8: + raise NXToolError("NX_INVALID_ARGUMENT", "Output basis must be orthonormal") + normal = cross(x, y) + if abs(abs(dot(normal, frame["normal"])) - 1) > 1e-8: + raise NXToolError("NX_NON_PLANAR", "Output basis must lie in the source plane") + factor = {"mm": 1.0, "inch": 25.4}[self._units()] + + def point2(p): + delta = [p[i] - o[i] for i in range(3)] + if abs(dot(delta, normal)) * factor > 1e-6: + raise NXToolError( + "NX_NON_PLANAR", "Source geometry or origin is outside the output plane" + ) + return [dot(delta, x) * factor, dot(delta, y) * factor] + + entities = [] + used = set() + for curve in curves: + ref = self._reference(curve, kind, self._work_part(), "DXF curve") + used.add(ref["id"]) + evaluator = uf.Eval.Initialize2(curve.Tag) + limits = uf.Eval.AskLimits(evaluator) + start = point2(uf.Eval.EvaluateUnitVectors(evaluator, limits[0])[0]) + end = point2(uf.Eval.EvaluateUnitVectors(evaluator, limits[1])[0]) + e = {"source": ref, "layer": layers.get(ref["id"], layer)} + if uf.Eval.IsLine(evaluator): + e.update(type="LINE", start=start, end=end) + elif uf.Eval.IsArc(evaluator): + arc = uf.Eval.AskArc(evaluator) + center = point2(arc.Center) + direction = dot(cross(list(arc.XAxis), list(arc.YAxis)), normal) + if abs(abs(direction) - 1) > 1e-8: + raise NXToolError("NX_NON_PLANAR", "Circular curve is not in the output plane") + e.update( + type="CIRCLE" + if abs(abs(limits[1] - limits[0]) - 2 * math.pi) < 1e-8 + else "ARC", + center=center, + radius=arc.Radius * factor, + ) + if e["type"] == "ARC": + if direction < 0: + start, end = end, start + e.update( + start_angle=math.degrees( + math.atan2(start[1] - center[1], start[0] - center[0]) + ) + % 360, + end_angle=math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) + % 360, + ) + else: + raise NXToolError( + "NX_UNSUPPORTED_GEOMETRY", + "Planar DXF preserves lines/arcs/circles only; unsupported curves are never approximated", + details={"object": ref, "mutation_outcome": "not_started"}, + ) + entities.append(e) + if set(layers) - used: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Layer overrides must reference curves or edges of the selected source", + ) + raw = dxf_text(entities).encode("ascii") + destination.parent.mkdir(parents=True, exist_ok=True) + with destination.open("xb") as stream: + try: + stream.write(raw) + except OSError: + stream.close() + destination.unlink(missing_ok=True) + raise + return { + "path": str(destination), + "size": len(raw), + "sha256": hashlib.sha256(raw).hexdigest(), + "units": "mm", + "scale": 1.0, + "coordinate_frame": { + "origin": o, + "x_axis": x, + "y_axis": y, + "normal": normal, + "source_units": self._units(), + }, + "entity_count": len(entities), + "entity_counts": dict(Counter(e["type"] for e in entities)), + "entities": entities, + "all_boundary_loops_included": True, + "warnings": [ + "Exports the selected analytic geometry; does not certify loop validity or fabrication readiness." + ], + } diff --git a/src/nx_mcp/real_smoke.py b/src/nx_mcp/real_smoke.py index 9568deb..eb48994 100644 --- a/src/nx_mcp/real_smoke.py +++ b/src/nx_mcp/real_smoke.py @@ -61,9 +61,20 @@ async def run_iteration( ) after = await _call(client, "nx_list_bodies", {}) await _call(client, "nx_fit_view", {}) - exported = await _call(client, "nx_export_step", {"path": step_path}) + # STEP export saves the part and invalidates native undo marks. Exercise + # undo before that save boundary, then recreate the solid for export. await _call(client, "nx_undo", {}) after_undo = await _call(client, "nx_list_bodies", {}) + sketches = await _call(client, "nx_list_sketches", {}) + sketch_id = next( + item["id"] for item in sketches["objects"] if item["name"] == sketch["object"]["name"] + ) + extruded = await _call( + client, + "nx_extrude", + {"sketch_id": sketch_id, "distance": 12.5, "reverse": False}, + ) + exported = await _call(client, "nx_export_step", {"path": step_path}) await _call(client, "nx_save_part", {}) await _call(client, "nx_close_part", {"save": False}) except Exception: diff --git a/src/nx_mcp/recovery.py b/src/nx_mcp/recovery.py new file mode 100644 index 0000000..e709b0d --- /dev/null +++ b/src/nx_mcp/recovery.py @@ -0,0 +1,74 @@ +"""Durable request receipts. No NXOpen calls; safe for sidecar status queries.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from datetime import datetime, timezone +from pathlib import Path + +from nx_mcp.runtime import NXToolError + + +def timestamp(): + return datetime.now(timezone.utc).isoformat() + + +class OperationStore: + def __init__(self, root): + self.root = Path(root) / ".nx-mcp" / "operations" + self.root.mkdir(parents=True, exist_ok=True) + + def path(self, operation_id): + if not isinstance(operation_id, str) or not re.fullmatch( + r"[A-Za-z0-9_-]{8,128}", operation_id + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "operation_id must be 8–128 ASCII letters, digits, underscores or hyphens", + ) + return self.root / (operation_id + ".json") + + def get(self, operation_id): + path = self.path(operation_id) + if not path.exists(): + return { + "operation_id": operation_id, + "state": "unknown", + "mutation_outcome": "unknown", + "reason": "No durable receipt exists; do not infer failure.", + } + try: + return json.loads(path.read_text(encoding="utf-8")) + except (ValueError, OSError) as e: + raise NXToolError("NX_RECEIPT_UNREADABLE", str(e)) from e + + def put(self, record): + path = self.path(record["operation_id"]) + temp = path.with_suffix(".tmp") + with temp.open("w", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False, allow_nan=False) + f.flush() + os.fsync(f.fileno()) + os.replace(temp, path) + + def recover(self, session_id): + for path in self.root.glob("*.json"): + record = json.loads(path.read_text(encoding="utf-8")) + if record.get("state") == "running" and record.get("session_id") != session_id: + record.update( + state="unknown", + mutation_outcome="unknown", + reason="NX process restarted before final receipt; manual reconciliation required.", + ) + self.put(record) + + @staticmethod + def fingerprint(method, params): + return hashlib.sha256( + json.dumps( + [method, params], sort_keys=True, separators=(",", ":"), allow_nan=False + ).encode() + ).hexdigest() diff --git a/src/nx_mcp/reference_geometry.py b/src/nx_mcp/reference_geometry.py new file mode 100644 index 0000000..6bdcc14 --- /dev/null +++ b/src/nx_mcp/reference_geometry.py @@ -0,0 +1,174 @@ +"""Explicit reference-set membership and datum display controls for NX 2606.""" + +from nx_mcp.runtime import NXToolError + + +class ReferenceGeometryMixin: + def _flat_pattern_orientation_edges(self, upward_face): + import NXOpen.UF + + from nx_mcp.hardened import xyz + + face = self._engineering_owned(upward_face, "face") + uf = NXOpen.UF.UFSession.GetUFSession() + data = uf.Modeling.AskFaceData(face.Tag) + if data[0] != 22: + raise NXToolError("NX_INVALID_ARGUMENT", "Select a planar upward web face") + if not self._sm_manager().IsSheetmetalBody(face.GetBody()): + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a sheet-metal face") + items = [] + for edge in face.GetEdges(): + if edge.SolidEdgeType != self.nxopen.Edge.EdgeType.Linear: + continue + start, end = edge.GetVertices() + items.append( + { + "edge": self._reference(edge, "edge", self._work_part(), "Orientation edge"), + "start": xyz(start), + "end": xyz(end), + "adjacent_faces": [ + self._reference(f, "face", self._work_part(), "Face")["id"] + for f in edge.GetFaces() + ], + } + ) + return { + "upward_face": self._reference(face, "face", self._work_part(), "Upward face"), + "edges": items, + "count": len(items), + "coordinate_frame": "work_part", + "eligibility": "linear_boundary_of_planar_sheet_metal_face", + "warnings": [ + "These geometric candidates avoid stale outer-edge guesses. Final validity depends on the native Flat Pattern commit; no temporary feature is committed by this inspection." + ], + } + + def _reference_set_record(self, value): + members = list(value.AskAllDirectMembers()) + return { + "object": self._reference(value, "reference_set", self._work_part(), "Reference set"), + "name": value.Name, + "member_count": len(members), + "members": [self._display_ref(x) for x in members], + "add_components_automatically": bool(value.GetAddComponentsAutomatically()), + } + + def _list_reference_sets(self): + part = self._work_part() + self._require_api(part, "GetAllReferenceSets") + records = [self._reference_set_record(x) for x in part.GetAllReferenceSets()] + return { + "reference_sets": records, + "count": len(records), + "built_in": ["Entire Part", "Empty"], + } + + def _create_reference_set(self, name, objects): + part = self._work_part() + if ( + not isinstance(name, str) + or not name.strip() + or len(name) > 132 + or name.casefold() in {"entire part", "empty"} + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Use a nonempty custom reference-set name of at most 132 characters", + ) + self._require_api(part, "CreateReferenceSet", "GetAllReferenceSets") + if any(x.Name.casefold() == name.casefold() for x in part.GetAllReferenceSets()): + raise NXToolError("NX_ALREADY_EXISTS", "A reference set with this name already exists") + if not isinstance(objects, list) or not 1 <= len(objects) <= 1000: + raise NXToolError("NX_INVALID_ARGUMENT", "objects requires 1–1000 explicit references") + values = [self._resolve(x, {"body", "curve", "component", "datum"}) for x in objects] + if any(x.IsOccurrence and x.OwningPart != part for x in values): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Members must be owned by the work part") + result = part.CreateReferenceSet() + result.SetName(name) + result.SetAddComponentsAutomatically(False, False) + result.AddObjectsToReferenceSet(values) + self._update_model() + return self._reference_set_record(result) + + def _set_component_reference_set(self, components, name): + part = self._work_part() + if not isinstance(components, list) or not 1 <= len(components) <= 1000: + raise NXToolError( + "NX_INVALID_ARGUMENT", "components requires 1–1000 direct occurrence references" + ) + values = [self._resolve(x, {"component"}) for x in components] + direct = ( + list(part.ComponentAssembly.RootComponent.GetChildren()) + if part.ComponentAssembly.RootComponent + else [] + ) + if any(x not in direct for x in values): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", + "Activate the owning assembly; only direct children can be changed", + ) + for c in values: + names = [x.Name for x in c.Prototype.GetAllReferenceSets()] + ["Entire Part", "Empty"] + if name not in names: + raise NXToolError( + "NX_NOT_FOUND", + "Reference set is absent from a component prototype", + details={"component": c.Name, "reference_set": name}, + ) + self._require_api(part.ComponentAssembly, "ReplaceReferenceSet") + before = [ + { + "object": self._reference(c, "component", part, "Component"), + "previous_reference_set": c.ReferenceSet, + } + for c in values + ] + for c in values: + part.ComponentAssembly.ReplaceReferenceSet(c, name) + self._update_model() + if any(c.ReferenceSet != name for c in values): + raise NXToolError("NX_VERIFICATION_FAILED", "Reference-set assignment did not persist") + return { + "components": [ + dict(r, reference_set=c.ReferenceSet) for r, c in zip(before, values, strict=True) + ], + "count": len(values), + "prototype_parts_modified": False, + } + + def _datum_objects(self): + part = self._work_part() + values = list(part.Datums) + list(part.CoordinateSystems) + return list({int(x.Tag): x for x in values}.values()) + + def _list_datums(self): + values = self._datum_objects() + return { + "datums": [ + { + "object": self._reference(x, "datum", self._work_part(), "Datum"), + "native_type": type(x).__name__, + "blanked": bool(x.IsBlanked), + } + for x in values + ], + "count": len(values), + "scope": "work_part", + } + + def _set_datum_visibility(self, visible=False): + self._visual_part() + values = self._datum_objects() + before = self._display_records(values) + for x in values: + (x.Unblank if visible else x.Blank)() + return { + "restore_id": self._save_display_snapshot(before), + "objects": self._display_records(values), + "count": len(values), + "visible": visible, + "scope": "work_part", + "warnings": [ + "Changes owned datums and coordinate systems only. Use body-only component reference sets to exclude prototype datums from assembly drawings." + ], + } diff --git a/src/nx_mcp/release_engineering.py b/src/nx_mcp/release_engineering.py new file mode 100644 index 0000000..33da7f4 --- /dev/null +++ b/src/nx_mcp/release_engineering.py @@ -0,0 +1,619 @@ +"""Native drawing authoring and persistent imported-geometry references.""" + +import math +import uuid + +from nx_mcp.authoring import finite +from nx_mcp.runtime import NXToolError + + +def sheet_point(value): + if not isinstance(value, (list, tuple)) or len(value) != 2: + raise NXToolError("NX_INVALID_ARGUMENT", "Position requires two sheet coordinates") + return [finite(x, "position") for x in value] + + +class ReleaseEngineeringMixin: + def _sheet_units(self, sheet): + import NXOpen.Drawings as D + + if sheet.Units == D.DrawingSheet.Unit.Millimeters: + return "mm" + if sheet.Units == D.DrawingSheet.Unit.Inches: + return "in" + raise NXToolError("NX_UNSUPPORTED_UNITS", "Unknown native drawing units") + + def _view_sheet(self, view): + matches = [s for s in self._work_part().DrawingSheets if view in s.GetDraftingViews()] + if len(matches) != 1: + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select a view on one work-part sheet") + return matches[0] + + def _drawing_view_info(self, view): + import NXOpen.UF as U + + from nx_mcp.hardened import xyz + + obj = self._drawing_object(view, "drawing_view") + sheet = self._view_sheet(obj) + uf = U.UFSession.GetUFSession().Draw + bounds = list(uf.AskViewBorders(obj.Tag)) + style = self._view_style(view) + warnings = [] + if not style["hidden_lines"] or not style["self_hidden"]: + warnings.append( + "Hidden-line processing is disabled: occluded edges may be drawn as visible. " + "To hide them use hidden_lines=true, self_hidden=true, hidden_font=0." + ) + return { + "object": self._reference(obj, "drawing_view", self._work_part(), "View"), + "drawing": self._reference(sheet, "drawing_sheet", self._work_part(), "Sheet"), + "native_type": type(obj).__name__, + "style": style, + "warnings": warnings, + "position": xyz(obj.GetDrawingReferencePoint())[:2], + "scale": uf.AskViewScale(obj.Tag)[1], + "bounds": bounds, + "inside_sheet": bounds[0] >= 0 + and bounds[1] >= 0 + and bounds[2] <= sheet.Length + and bounds[3] <= sheet.Height, + "units": self._sheet_units(sheet), + "coordinate_frame": "drawing_sheet", + "bounds_semantics": "native drafting view border, excluding separately placed annotations", + } + + def _edit_drawing_view(self, view, position=None, scale=None, style=None): + import NXOpen.UF as U + + style = {k: v for k, v in (style or {}).items() if v is not None} + if position is None and scale is None and not style: + raise NXToolError("NX_INVALID_ARGUMENT", "Supply position, scale or style") + point = sheet_point(position) if position is not None else None + scale = finite(scale, "scale", True) if scale is not None else None + obj = self._drawing_object(view, "drawing_view") + self._view_sheet(obj).Open() + uf = U.UFSession.GetUFSession().Draw + self._require_api(uf, "SetViewScale", "MoveView") + if scale is not None: + uf.SetViewScale(obj.Tag, scale) + if point is not None: + uf.MoveView(obj.Tag, point) + if style: + self._view_style(view, {k: v for k, v in style.items() if v is not None}) + self._work_part().DraftingViews.UpdateViews([obj]) + self._refresh_detail_boundaries() + result = self._drawing_view_info(view) + if (scale is not None and not math.isclose(result["scale"], scale, abs_tol=1e-8)) or ( + point is not None and math.dist(result["position"], point) > 1e-6 + ): + raise NXToolError( + "NX_VERIFICATION_FAILED", + "View alignment or scale prevented the requested placement; rolling back", + ) + result["modified"] = [result["object"]] + return result + + def _add_section_drawing_view( + self, + parent_view, + cut_object, + position, + step_direction, + arrow_direction, + scale=1.0, + cut_association="start", + ): + import NXOpen.UF as U + + from nx_mcp.hardened import dot + from nx_mcp.visual_tools import unit_normal + + point = sheet_point(position) + scale = finite(scale, "scale", True) + step, arrow = ( + unit_normal(step_direction, "step_direction"), + unit_normal(arrow_direction, "arrow_direction"), + ) + if abs(step[2]) > 1e-8 or abs(arrow[2]) > 1e-8 or abs(dot(step, arrow)) > 1e-8: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Step and arrow must be perpendicular vectors in the sheet XY plane", + ) + parent = self._drawing_object(parent_view, "drawing_view") + edge = self._engineering_owned(cut_object, "edge") + sheet = self._view_sheet(parent) + uf = U.UFSession.GetUFSession().Draw + self._require_api(uf, "CreateSimpleSxview") + sheet.Open() + cut = U.Drf.Object() + cut.ObjectTag = edge.Tag + cut.ObjectViewTag = parent.Tag + if cut_association not in {"start", "end", "arc_center"}: + raise NXToolError( + "NX_INVALID_ARGUMENT", "cut_association must be start, end or arc_center" + ) + cut.ObjectAssocType = ( + U.Drf.AssocType.ARC_CENTER + if cut_association == "arc_center" + else U.Drf.AssocType.END_POINT + ) + cut.ObjectAssocModifier = ( + 0 if cut_association == "arc_center" else 1 if cut_association == "start" else 2 + ) + tag = uf.CreateSimpleSxview(sheet.Tag, scale, step, arrow, parent.Tag, cut, point) + obj = self.nxopen.TaggedObjectManager.GetTaggedObject(tag) + self._work_part().DraftingViews.UpdateViews([obj]) + ref = self._reference(obj, "drawing_view", self._work_part(), "Section view") + return { + **self._edit_drawing_view(ref["id"], scale=scale), + "created": [ref], + "parent": parent_view, + "association": "native simple section anchored to edge", + } + + def _add_detail_drawing_view(self, parent_view, center, radius, position, scale=2.0): + import NXOpen.Drawings as D + + from nx_mcp.freeform import points3 + + center = points3([center])[0] + radius = finite(radius, "radius", True) + point = sheet_point(position) + scale = finite(scale, "scale", True) + parent = self._drawing_object(parent_view, "drawing_view") + sheet = self._view_sheet(parent) + sheet.Open() + part = self._work_part() + self._require_api(part.DraftingViews, "CreateDetailViewBuilder") + b = part.DraftingViews.CreateDetailViewBuilder(None) + try: + b.Parent.View.Value = parent + b.Type = D.DetailViewBuilder.Types.Circular + mapped = self._detail_boundary_points(parent, sheet, center, radius) + b.BoundaryPoint1 = part.Points.CreatePoint(mapped[0]) + b.BoundaryPoint2 = part.Points.CreatePoint(mapped[1]) + b.BoundaryPoint1.Blank() + b.BoundaryPoint2.Blank() + b.Scale.ScaleType = D.ViewScaleBuilder.Type.Ratio + b.Scale.Numerator = scale + b.Scale.Denominator = 1.0 + b.Origin.Placement.SetValue(None, None, self._sheet_point3d(sheet, point)) + if not b.Validate(): + raise NXToolError("NX_DRAWING_INVALID", "Detail view did not validate") + obj = b.Commit() + finally: + b.Destroy() + import json + + import NXOpen.UF as U + + obj.SetAttribute( + "NX_MCP_DETAIL_V1", + json.dumps( + { + "parent": U.UFSession.GetUFSession().Tag.AskHandleFromTag(parent.Tag), + "center": center, + "radius": radius, + } + ), + ) + ref = self._reference(obj, "drawing_view", part, "Detail view") + return { + **self._edit_drawing_view(ref["id"], scale=scale), + "created": [ref], + "parent": parent_view, + "boundary_frame": "work_part", + "boundary_units": self._units(), + } + + def _geometry_anchor(self, object): + import NXOpen.UF as U + + obj = self._resolve(object, {"body", "face", "edge"}) + if obj.IsOccurrence or obj.OwningPart != self._work_part(): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Anchor owned prototype geometry in its work part" + ) + kind = ( + "body" + if isinstance(obj, self.nxopen.Body) + else "face" + if isinstance(obj, self.nxopen.Face) + else "edge" + ) + part = self._work_part() + if not part.FullPath: + raise NXToolError("NX_UNSAVED_PART", "Save the part before creating persistent anchors") + return { + "anchor": { + "version": 1, + "owner_part": part.FullPath, + "kind": kind, + "handle": U.UFSession.GetUFSession().Tag.AskHandleFromTag(obj.Tag), + }, + "semantics": "native persistent identity, survives save/reopen while entity survives; no geometric nearest-neighbor fallback", + } + + def _resolve_geometry_anchor(self, anchor): + import NXOpen.UF as U + + if ( + not isinstance(anchor, dict) + or set(anchor) != {"version", "owner_part", "kind", "handle"} + or anchor["version"] != 1 + or anchor["kind"] not in {"body", "face", "edge"} + or not all(isinstance(anchor[k], str) and anchor[k] for k in ["owner_part", "handle"]) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Use an intact native geometry anchor") + part = self._work_part() + if anchor["owner_part"].casefold() != part.FullPath.casefold(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Anchor belongs to another part") + try: + tag = int(U.UFSession.GetUFSession().Tag.AskTagOfHandle(anchor["handle"])) + objects = ( + list(part.Bodies) + if anchor["kind"] == "body" + else [ + x + for b in part.Bodies + for x in (b.GetFaces() if anchor["kind"] == "face" else b.GetEdges()) + ] + ) + obj = next(o for o in objects if int(o.Tag) == tag) + except Exception as error: + raise NXToolError( + "NX_STALE_REFERENCE", + "Anchored entity no longer exists; explicitly select replacement geometry", + ) from error + return { + "object": self._reference(obj, anchor["kind"], part, "Anchored geometry"), + "anchor": anchor, + "resolution": "native persistent handle and owner/type verification", + } + + def _drawing_table(self, drawing, kind, rows, widths, position, table=None, row_height=7.0): + import NXOpen.UF as U + + if ( + kind not in {"title_block", "revision"} + or not isinstance(rows, list) + or not 1 <= len(rows) <= 100 + or not isinstance(widths, list) + or not 1 <= len(widths) <= 20 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Use title_block or revision with 1..100 rows and 1..20 columns", + ) + if any( + not isinstance(r, list) + or len(r) != len(widths) + or any(not isinstance(c, str) or len(c) > 1024 for c in r) + for r in rows + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Rows must be rectangular strings, maximum 1024 characters per cell", + ) + widths = [finite(w, "width", True) for w in widths] + height = finite(row_height, "row_height", True) + point = sheet_point(position) + sheet = self._drawing_object(drawing, "drawing_sheet") + part = self._work_part() + section = self._engineering_owned(table, "annotation") if table else None + attr = "NX_MCP_DRAWING_TABLE_V1" + guid_attr = "NX_MCP_TABLE_SHEET_GUID_V2" + string_type = self.nxopen.NXObject.AttributeType.String + sheet_guid = ( + sheet.GetStringAttribute(guid_attr) + if sheet.HasUserAttribute(guid_attr, string_type, -1) + else None + ) + if sheet_guid: + matches = [ + s + for s in part.DrawingSheets + if s.HasUserAttribute(guid_attr, string_type, -1) + and s.GetStringAttribute(guid_attr) == sheet_guid + ] + if len(matches) != 1: + raise NXToolError( + "NX_OBJECT_OWNER_AMBIGUOUS", + "Drawing sheets share the same managed identity; resolve duplicated sheet metadata before editing tables", + ) + if section is not None: + kind_ok = ( + section.HasUserAttribute(attr, string_type, -1) + and section.GetStringAttribute(attr) == kind + ) + if section.HasUserAttribute(guid_attr, string_type, -1): + owner_ok = ( + sheet_guid is not None and section.GetStringAttribute(guid_attr) == sheet_guid + ) + else: + try: + owner_ok = ( + U.UFSession.GetUFSession().Tag.AskTagOfHandle( + section.GetStringAttribute("NX_MCP_TABLE_SHEET_V1") + ) + == sheet.Tag + ) + except Exception: + owner_ok = False + if not kind_ok or not owner_ok: + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", + "Edit a managed table of the same kind on this sheet", + ) + if sheet_guid is None: + sheet_guid = uuid.uuid4().hex + sheet.SetAttribute(guid_attr, sheet_guid) + sheet.Open() + if section is None: + b = part.Annotations.TableSections.CreateTableSectionBuilder(None) + try: + b.NumberOfRows = len(rows) + b.NumberOfColumns = len(widths) + b.RowHeight = height + b.ColumnWidth = widths[0] + b.Origin.Origin.SetValue(None, None, self._sheet_point3d(sheet, point)) + section = b.Commit() + finally: + b.Destroy() + section.SetAttribute(attr, kind) + section.SetAttribute( + "NX_MCP_TABLE_SHEET_V1", U.UFSession.GetUFSession().Tag.AskHandleFromTag(sheet.Tag) + ) + section.SetAttribute(guid_attr, sheet_guid) + tab = U.UFSession.GetUFSession().Tabnot + tag = ( + U.UFSession.GetUFSession().Tag.AskTagOfHandle( + section.GetStringAttribute("NX_MCP_NATIVE_TABLE_V1") + ) + if type(section).__name__ == "TitleBlock" + else tab.AskTabularNoteOfSection(section.Tag) + ) + if tab.AskNmColumns(tag) != len(widths): + raise NXToolError( + "NX_UNSUPPORTED_EDIT", "Keep the existing column count when editing a table" + ) + while tab.AskNmRows(tag) < len(rows): + tab.AddRow(tag, tab.CreateRow(height), tab.AskNmRows(tag)) + while tab.AskNmRows(tag) > len(rows): + old = tab.AskNthRow(tag, tab.AskNmRows(tag) - 1) + tab.RemoveRow(old) + U.UFSession.GetUFSession().Obj.DeleteObject(old) + for i, values in enumerate(rows): + row = tab.AskNthRow(tag, i) + tab.SetRowHeight(row, height) + for j, value in enumerate(values): + col = tab.AskNthColumn(tag, j) + tab.SetColumnWidth(col, widths[j]) + tab.SetCellText(tab.AskCellAtRowCol(row, col), value) + section.AnnotationOrigin = self._sheet_point3d(sheet, point) + title = None + if kind == "title_block" and table is None: + b = part.DraftingManager.TitleBlocks.CreateDefineTitleBlockBuilder( + self.nxopen.Annotations.TitleBlock.Null + ) + try: + b.Components.Add(section) + b.UpdateCells() + title = b.Commit() + finally: + b.Destroy() + section = title + section.AnnotationOrigin = self._sheet_point3d(sheet, point) + section.SetAttribute(attr, kind) + section.SetAttribute( + "NX_MCP_TABLE_SHEET_V1", U.UFSession.GetUFSession().Tag.AskHandleFromTag(sheet.Tag) + ) + section.SetAttribute( + "NX_MCP_NATIVE_TABLE_V1", U.UFSession.GetUFSession().Tag.AskHandleFromTag(tag) + ) + section.SetAttribute(guid_attr, sheet_guid) + self._update_model() + ref = self._reference(section, "annotation", part, "Drawing table") + actual = [ + [ + tab.AskEvaluatedCellText( + tab.AskCellAtRowCol(tab.AskNthRow(tag, i), tab.AskNthColumn(tag, j)) + ) + for j in range(len(widths)) + ] + for i in range(len(rows)) + ] + return { + "table": ref, + "kind": kind, + "position": point, + "position_anchor": "lower_right" if kind == "title_block" else "upper_left", + "rows": actual, + "row_count": len(actual), + "column_count": len(widths), + "native_title_block": type(section).__name__ if kind == "title_block" else None, + "created": [ref] if table is None else [], + "modified": [ref] if table else [], + "units": self._sheet_units(sheet), + "coordinate_frame": "drawing_sheet", + "semantics": "Editable native table; revision entries are explicitly supplied, not inferred release approvals", + } + + def _update_assembly_documentation(self): + import NXOpen.Positioning as P + import NXOpen.UF as U + + part = self._explosion_context() + constraints = self._assembly_constraints(part) + if constraints: + positioner = part.ComponentAssembly.Positioner + positioner.BeginAssemblyConstraints() + try: + network = positioner.EstablishNetwork() + network.MoveObjectsState = True + network.Solve() + network.ApplyToModel() + self._update_model() + if any( + c.GetConstraintStatus() != P.Constraint.SolverStatus.Solved for c in constraints + ): + raise NXToolError( + "NX_CONSTRAINT_UNSATISFIED", + "Assembly constraints did not solve; documentation refresh rolled back", + ) + finally: + positioner.ClearNetwork() + positioner.EndAssemblyConstraints() + self._update_model() + for ex in self._explosions(part): + self._refresh_explosion_traces(ex) + uf = U.UFSession.GetUFSession() + boms = list(part.Annotations.PartsLists) + for bom in boms: + uf.Plist.Update(bom.Tag) + annotation_result = self._refresh_annotations() + views = [v for s in part.DrawingSheets for v in s.GetDraftingViews()] + if views: + with self._drawing_save_context(part, force_display=True): + for sheet in part.DrawingSheets: + sheet.Open() + owned_views = list(sheet.GetDraftingViews()) + if owned_views: + part.DraftingViews.UpdateViews(owned_views) + for view in owned_views: + view.UpdateAutomaticViewBound() + retained_annotations = [ + self._reference(a, "annotation", part, "Retained annotation") + for a in self._documentation_annotations(part) + if getattr(a, "IsRetained", False) + ] + retained_dimensions = any(d.IsRetained for d in part.Dimensions) + return { + "constraints": [self._assembly_constraint_record(c) for c in constraints], + "parts_lists": [ + self._parts_list_info(self._reference(b, "annotation", part, "BOM")["id"]) + for b in boms + ], + "views": [ + self._drawing_view_info(self._reference(v, "drawing_view", part, "View")["id"]) + for v in views + ], + "annotations": annotation_result, + "dimensions": self._list_dimensions()["dimensions"], + "retained_annotations": retained_annotations, + "documentation_complete": not retained_dimensions and not retained_annotations, + "warnings": [ + "Retained dimensions or annotations require explicit reassociation or replacement; use nx_add_dimension(dimension=...) for dimensions." + ] + if retained_dimensions or retained_annotations + else [], + "health": self._model_health(scope="assembly"), + "semantics": "Explicit assembly update after prototype edits/replacement: solves constraints, refreshes anchored traces, BOMs and native drawing views. Missing managed anchors or unsolved mates roll back this refresh; previous prototype edits remain separate operations.", + } + + def _drawing_coordinates(self, sheet, value, key="position"): + units = self._sheet_units(sheet) + return { + key: value, + key + "_mm": [v * (25.4 if units == "in" else 1) for v in value], + "sheet_units": units, + "coordinate_frame": "drawing_sheet", + } + + def _sheet_point3d(self, sheet, values): + """NXOpen placement points use part units even on a differently sized sheet.""" + factor = (25.4 if self._sheet_units(sheet) == "in" else 1.0) / ( + 25.4 if self._units() == "inch" else 1.0 + ) + return self.nxopen.Point3d(*(float(v) * factor for v in values), 0.0) + + def _place_drawing_view(self, view, sheet, point, axes=(0, 1)): + import NXOpen.UF as U + + U.UFSession.GetUFSession().Draw.MoveView(view.Tag, point) + self._work_part().DraftingViews.UpdateViews([view]) + actual = view.GetDrawingReferencePoint() + if any(abs([actual.X, actual.Y][i] - point[i]) > 1e-6 for i in axes): + raise NXToolError( + "NX_VERIFICATION_FAILED", + f"Native view placement differs from requested sheet coordinates: actual {[actual.X, actual.Y]}, requested {point}", + ) + + def _detail_boundary_points(self, parent, sheet, center, radius): + import NXOpen.UF as U + + matrix = parent.Matrix + other = [ + c + radius * d for c, d in zip(center, [matrix.Xx, matrix.Xy, matrix.Xz], strict=True) + ] + mapper = U.UFSession.GetUFSession().View.MapModelToDrawing + return [self._sheet_point3d(sheet, mapper(parent.Tag, p)) for p in [center, other]] + + def _refresh_detail_boundaries(self): + import json + + import NXOpen.UF as U + + part = self._work_part() + managed = [ + (sheet, view) + for sheet in getattr(part, "DrawingSheets", []) + for view in sheet.GetDraftingViews() + if view.HasUserAttribute( + "NX_MCP_DETAIL_V1", self.nxopen.NXObject.AttributeType.String, -1 + ) + ] + if not managed: + return + with self._drawing_save_context(part, force_display=True): + for sheet, view in managed: + sheet.Open() + record = json.loads(view.GetStringAttribute("NX_MCP_DETAIL_V1")) + tag = int(U.UFSession.GetUFSession().Tag.AskTagOfHandle(record["parent"])) + parent = next((v for v in sheet.GetDraftingViews() if int(v.Tag) == tag), None) + if parent is None: + raise NXToolError( + "NX_STALE_ANNOTATION_SOURCE", "Detail-view parent no longer resolves" + ) + points = self._detail_boundary_points( + parent, sheet, record["center"], record["radius"] + ) + builder = part.DraftingViews.CreateDetailViewBuilder(view) + try: + builder.BoundaryPoint1.SetCoordinates(points[0]) + builder.BoundaryPoint2.SetCoordinates(points[1]) + builder.Commit() + finally: + builder.Destroy() + + def _drawing_dimension_edge(self, reference): + edge = self._resolve(reference, {"edge"}) + if edge.IsOccurrence: + if edge.OwningComponent not in [c for c, _ in self._walk_components(self._work_part())]: + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Select geometry in the work assembly" + ) + elif edge.OwningPart != self._work_part(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select owned work-part geometry") + return edge + + def _list_dimensions(self): + from nx_mcp.hardened import xyz + + part = self._work_part() + return { + "dimensions": [ + { + "object": self._reference(d, "dimension", part, "Dimension"), + "native_type": type(d).__name__, + "computed_value": d.ComputedSize, + "retained": bool(d.IsRetained), + "measurement_valid": not bool(d.IsRetained), + "origin": xyz(d.AnnotationOrigin), + } + for d in part.Dimensions + ], + "units": self._units(), + "coordinate_frame": "native annotation frame", + } diff --git a/src/nx_mcp/result_retention.py b/src/nx_mcp/result_retention.py new file mode 100644 index 0000000..8eb475a --- /dev/null +++ b/src/nx_mcp/result_retention.py @@ -0,0 +1,59 @@ +"""Retention of disposable response snapshots, never mutation recovery receipts.""" + +import os +import re +import time +from pathlib import Path +from threading import RLock + +LOCK = RLock() +PATTERN = re.compile(r"result_[a-f0-9]{32}\.json") + + +def settings(): + values = { + "max_age_seconds": int(os.environ.get("NX_MCP_RESULT_MAX_AGE_SECONDS", "604800")), + "max_bytes": int(os.environ.get("NX_MCP_RESULT_MAX_BYTES", "268435456")), + } + if any(v <= 0 for v in values.values()): + raise ValueError("Snapshot retention age and byte limits must be positive integers") + return values + + +def maintain(root, *, apply=False, max_age_seconds=None, max_bytes=None, protect=None): + root = Path(root) + config = settings() + for key, value in {"max_age_seconds": max_age_seconds, "max_bytes": max_bytes}.items(): + if value is not None: + if type(value) is not int or value <= 0: + raise ValueError("Retention limits must be positive integers") + config[key] = value + with LOCK: + records = [] + if root.exists(): + for p in root.iterdir(): + if PATTERN.fullmatch(p.name) and p.is_file() and not p.is_symlink(): + stat = p.stat() + records.append((stat.st_mtime, stat.st_size, p)) + records.sort(key=lambda x: (x[0], x[2].name)) + total = sum(size for _, size, _ in records) + selected = [] + now = time.time() + for modified, size, path in records: + if path.stem == protect: + continue + if now - modified > config["max_age_seconds"] or total > config["max_bytes"]: + selected.append(path) + total -= size + if apply: + for path in selected: + path.unlink(missing_ok=True) + return { + "policy": config, + "snapshot_count": len(records), + "bytes_before": sum(x[1] for x in records), + "selected_count": len(selected), + "bytes_after_cleanup": total, + "applied": apply, + "recovery_receipts_affected": False, + } diff --git a/src/nx_mcp/result_transport.py b/src/nx_mcp/result_transport.py new file mode 100644 index 0000000..3da8ceb --- /dev/null +++ b/src/nx_mcp/result_transport.py @@ -0,0 +1,174 @@ +"""Bounded bridge receipts with immutable, independently pageable full results.""" + +from __future__ import annotations + +import hashlib +import json +import re +import uuid +from pathlib import Path + +from nx_mcp.result_retention import LOCK, maintain, settings +from nx_mcp.runtime import NXToolError + + +def encoded(value): + return json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def preview(value, path="", omitted=None, budget=None): + omitted = {} if omitted is None else omitted + budget = [2000] if budget is None else budget + budget[0] -= 1 + if budget[0] <= 0: + omitted.setdefault(path, {"reason": "Read this field separately"}) + return None + if isinstance(value, dict): + if len(value) > 100: + omitted[path] = {"total_keys": len(value), "returned_keys": 100} + return { + k: preview(v, path + "/" + k.replace("~", "~0").replace("/", "~1"), omitted, budget) + for k, v in list(value.items())[:100] + } + if isinstance(value, list): + if len(value) > 5: + omitted[path] = {"total_count": len(value), "returned_count": 5} + return [preview(v, path + "/" + str(i), omitted, budget) for i, v in enumerate(value[:5])] + if isinstance(value, str) and len(value) > 2048: + omitted[path] = {"total_characters": len(value), "returned_characters": 2048} + return value[:2048] + return value + + +def bound_result(result, directory): + raw = encoded(result) + if len(raw) < 512 * 1024: + return result + if directory is None: + raise NXToolError( + "NX_RESPONSE_TOO_LARGE", + "Bridge result exceeds the delivery budget", + details={ + "operation_id": result.get("operation_id"), + "mutation_outcome": result.get("mutation_outcome", "unknown"), + }, + ) + root = Path(directory) + identifier = "result_" + uuid.uuid4().hex + try: + root.mkdir(parents=True, exist_ok=True) + with LOCK: + config = settings() + age, budget = config["max_age_seconds"], config["max_bytes"] + if len(raw) > budget: + raise OSError("Result exceeds snapshot storage budget") + p = root / (identifier + ".json") + tmp = p.with_suffix(".tmp") + try: + tmp.write_bytes(raw) + tmp.replace(p) + finally: + tmp.unlink(missing_ok=True) + maintain(root, apply=True, max_age_seconds=age, max_bytes=budget, protect=p.stem) + except OSError as error: + raise NXToolError( + "NX_RESULT_STORAGE_FAILED", + "Result delivery failed after execution; inspect durable operation status before retrying", + details={ + "operation_id": result.get("operation_id"), + "mutation_outcome": result.get("mutation_outcome", "unknown"), + "restore_id": result.get("restore_id"), + "cause": str(error), + }, + ) from error + omitted = {} + summary = preview(result, omitted=omitted) + if len(encoded({"value": summary, "omitted": omitted})) > 400 * 1024: + summary = { + k: result[k] + for k in ( + "status", + "operation_id", + "session_id", + "mutation_outcome", + "restore_id", + "body_count", + "object_count", + "expanded_count", + ) + if k in result + } + omitted = {"/": {"reason": "Full result requires pagination"}} + summary["full_result"] = { + "id": identifier, + "sha256": hashlib.sha256(raw).hexdigest(), + "size": len(raw), + "tool": "nx_read_result", + "immutable": True, + } + summary["omitted"] = omitted + return summary + + +def read_result(root, result_id, field="", offset=0, limit=20): + if not re.fullmatch(r"result_[0-9a-f]{32}", result_id) or offset < 0 or not 1 <= limit <= 100: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use a returned result ID, offset >=0 and limit 1..100" + ) + p = Path(root) / (result_id + ".json") + with LOCK: + if not p.is_file() or p.is_symlink(): + raise NXToolError("NX_RESULT_EXPIRED", "Result snapshot is unavailable") + value = json.loads(p.read_text(encoding="utf-8")) + if field and not field.startswith("/"): + raise NXToolError("NX_INVALID_ARGUMENT", "field must be a JSON pointer") + try: + for key in field.split("/")[1:]: + key = key.replace("~1", "/").replace("~0", "~") + value = value[int(key)] if isinstance(value, list) else value[key] + except (KeyError, IndexError, ValueError, TypeError) as error: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown result field") from error + total = len(value) if isinstance(value, (list, str, dict)) else None + selected = ( + dict(list(value.items())[offset : offset + limit]) + if isinstance(value, dict) + else value[offset : offset + limit] + if total is not None + else value + ) + omitted = {} + data = ( + [preview(v, field + "/" + str(offset + i), omitted) for i, v in enumerate(selected)] + if isinstance(selected, list) + else preview(selected, field, omitted) + ) + if len(encoded({"value": data, "omitted": omitted})) > 400 * 1024: + if isinstance(selected, list): + data = [ + {"field": field + "/" + str(offset + i), "read_separately": True} + for i in range(len(selected)) + ] + elif isinstance(selected, dict): + data = { + k: { + "field": field + "/" + k.replace("~", "~0").replace("/", "~1"), + "read_separately": True, + } + for k in list(selected)[:100] + } + else: + data = None + omitted = {field: {"reason": "Read child fields separately"}} + return { + "status": "success", + "result_id": result_id, + "field": field, + "value": data, + "total_count": total, + "offset": offset, + "next_offset": min(offset + limit, total) + if total is not None and offset + limit < total + else None, + "omitted": omitted, + "mutation_outcome": "not_applicable", + } diff --git a/src/nx_mcp/review_tools.py b/src/nx_mcp/review_tools.py new file mode 100644 index 0000000..a340eec --- /dev/null +++ b/src/nx_mcp/review_tools.py @@ -0,0 +1,552 @@ +"""Model summaries, reproducible views, inspection artifacts and reversible previews.""" + +from __future__ import annotations + +import contextlib +import hashlib +import inspect +import json +import uuid +import zipfile +from pathlib import Path +from tempfile import TemporaryDirectory + +from nx_mcp.authoring import finite, page +from nx_mcp.runtime import NXToolError + + +class ReviewToolsMixin: + def _set_camera(self, rotation, origin, scale): + from nx_mcp.hardened import vector + + view = self._visual_part().ModelingViews.WorkView + matrix = self._validate_rotation(rotation) + point = vector(origin, "origin") + value = finite(scale, "scale", True) + self._require_api(view, "SetRotationTranslationScale") + view.SetRotationTranslationScale( + self._nx_matrix(matrix), self.nxopen.Point3d(*point), value + ) + view.UpdateDisplay() + return self._view_info() + + def _restore_camera(self, camera): + self._set_camera(camera["rotation"], camera["origin"], camera["scale"]) + styles = { + "shaded": "Shaded", + "shaded_with_edges": "ShadedWithEdges", + "wireframe": "StaticWireframe", + } + if camera["rendering_style"] in styles: + self._work_part().ModelingViews.WorkView.RenderingStyle = getattr( + self.nxopen.View.RenderingStyleType, styles[camera["rendering_style"]] + ) + + @contextlib.contextmanager + def _temporary_view(self): + camera = self._view_info() + snapshot_keys = set(getattr(self, "_display_snapshots", {})) + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP temporary presentation" + ) + try: + yield + finally: + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + except Exception as exc: + raise NXToolError( + "NX_ROLLBACK_FAILED", + "Temporary presentation cleanup failed: " + str(exc), + details={"mutation_outcome": "partial"}, + ) from exc + finally: + for key in set(getattr(self, "_display_snapshots", {})) - snapshot_keys: + self._display_snapshots.pop(key, None) + self._restore_camera(camera) + self._clear_highlights() + + def _locator(self, obj, kind): + return { + "kind": kind, + "journal_id": str(obj.JournalIdentifier), + "owner_part": self._work_part().FullPath, + } + + def _locate(self, locator): + part = self._work_part() + if locator["owner_part"].casefold() != part.FullPath.casefold(): + raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Saved reference belongs to another part") + kind = locator["kind"] + if kind == "expression": + candidates = list(part.Expressions) + elif kind == "component": + candidates = [c for c, _ in self._walk_components(part)] + elif kind == "feature": + candidates = list(part.Features) + elif kind == "sketch": + candidates = list(part.Sketches) + elif kind in {"face", "edge"}: + bodies = self._geometry(scope="assembly") + candidates = [ + v for b in bodies for v in (b.GetFaces() if kind == "face" else b.GetEdges()) + ] + elif kind in {"curve", "body"}: + candidates = list(part.Curves if kind == "curve" else part.Bodies) + else: + candidates = [] + matches = [c for c in candidates if c.JournalIdentifier == locator["journal_id"]] + if len(matches) == 1: + return matches[0] + # Occurrence faces/bodies have assembly-context journal identifiers. + try: + obj = part.FindObject(locator["journal_id"]) + except Exception as exc: + raise NXToolError( + "NX_SAVED_REFERENCE_STALE", + "Saved geometry no longer resolves: " + locator["journal_id"], + ) from exc + if obj is None: + raise NXToolError("NX_SAVED_REFERENCE_STALE", "Saved reference is unavailable") + valid = { + "body": self.nxopen.Body, + "face": self.nxopen.Face, + "edge": self.nxopen.Edge, + "sketch": self.nxopen.Sketch, + } + if kind in valid and not isinstance(obj, valid[kind]): + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Saved reference changed kind") + return obj + + def _save_presentation(self, path): + part = self._visual_part() + file = self.workspace.ensure_inside(path) + if file.suffix.lower() != ".json" or file.exists(): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Choose a new workspace .json presentation path" + ) + try: + bodies = self._geometry(scope="assembly") + except NXToolError as err: + if err.code != "NX_NO_TARGET_BODY": + raise + bodies = [] + values = bodies + [c for c, _ in self._walk_components(part)] + records = self._display_records(values, True) + saved = [] + for row in records: + obj = self._resolve(row["object"]["id"]) + saved.append( + { + **{k: v for k, v in row.items() if k != "object"}, + "locator": self._locator(obj, row["object"]["kind"]), + } + ) + sections = self._list_sections() + data = { + "format": "nx-mcp-presentation", + "version": 1, + "part_path": part.FullPath, + "camera": self._view_info(), + "display": saved, + "section": next( + ( + {k: r[k] for k in ("origin", "normal", "cap")} + for r in sections["sections"] + if r["active"] + ), + None, + ), + "sectioning_enabled": sections["view_sectioning_enabled"], + } + file.parent.mkdir(parents=True, exist_ok=True) + with file.open("x", encoding="utf-8") as stream: + json.dump(data, stream, indent=2) + return { + "path": str(file), + "artifact_path": str(file.relative_to(self.workspace.root)), + "sha256": hashlib.sha256(file.read_bytes()).hexdigest(), + "size": file.stat().st_size, + "objects_saved": len(saved), + "warnings": [ + "Restores explicit appearance attributes. Across revisions, journal identifiers must still identify the intended geometry." + ], + } + + def _restore_presentation(self, path): + file = self.workspace.ensure_inside(path) + if file.stat().st_size > 8 * 1024 * 1024: + raise NXToolError("NX_OBJECT_LIMIT", "Presentation exceeds 8 MiB") + data = json.loads(file.read_text(encoding="utf-8")) + if data.get("format") != "nx-mcp-presentation" or data.get("version") != 1: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported presentation format") + if data["part_path"].casefold() != self._visual_part().FullPath.casefold(): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", + "Activate the presentation owner as work and display part", + ) + if len(data["display"]) > 10000: + raise NXToolError("NX_OBJECT_LIMIT", "Presentation exceeds 10000 display records") + resolved = [(self._locate(r["locator"]), r) for r in data["display"]] + # Preflight all saved entities and camera values before changing the viewport. + self._validate_rotation(data["camera"]["rotation"]) + finite(data["camera"]["scale"], "scale", True) + before = self._view_info() + try: + for obj, row in resolved: + if "color_index" in row: + self._apply_appearance([obj], row["color_index"], row.get("transparency")) + (obj.Blank if row["blanked"] else obj.Unblank)() + view = self._work_part().ModelingViews.WorkView + section = data["section"] + if section: + current = view.ActiveDynamicSection + ref = ( + self._reference(current, "section", self._work_part(), "Section")["id"] + if current + else None + ) + self._section_view(**section, section=ref, name="Presentation section") + view.DisplaySectioningToggle = bool(data["sectioning_enabled"]) + self._restore_camera(data["camera"]) + except Exception: + self._restore_camera(before) + raise + return { + "path": str(file), + "restored_objects": len(resolved), + "camera": self._view_info(), + "geometry_changed": False, + "modified": None, + } + + def _model_summary(self, section="overview", offset=0, limit=50): + part = self._work_part() + if section not in {"overview", "components", "features", "expressions", "sketches"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported summary section") + if section == "expressions": + return self._list_expressions(offset=offset, limit=limit) + if section == "components": + return page(self._list_components()["components"], offset, limit) + if section == "features": + result = page(list(part.Features), offset, limit) + result["items"] = [ + { + "object": self._reference(f, "feature", part, "Feature"), + "type": f.FeatureType, + "suppressed": bool(f.Suppressed), + "parents": [ + self._reference(v, "feature", part, "Feature") for v in f.GetParents() + ], + "expressions": [ + {"name": e.Name, "formula": e.RightHandSide} for e in f.GetExpressions() + ], + } + for f in result["items"] + ] + return result + if section == "sketches": + result = page(list(part.Sketches), offset, limit) + result["items"] = [ + { + "object": self._reference(s, "sketch", part, "Sketch"), + "curve_count": len(s.GetAllGeometry()), + "frame": self._sketch_frame(s), + } + for s in result["items"] + ] + return result + try: + bounds = self._get_bounding_box() + except NXToolError as err: + if err.code != "NX_NO_TARGET_BODY": + raise + bounds = None + return { + "part": self._reference(part, "part", part, "Part"), + "modified": bool(part.IsModified), + "units": self._units(), + "counts": { + "bodies": len(list(part.Bodies)), + "features": len(list(part.Features)), + "sketches": len(list(part.Sketches)), + "expressions": len(list(part.Expressions)), + "component_occurrences": len(self._walk_components(part)), + }, + "bounds": { + k: bounds[k] + for k in ("min", "max", "dimensions", "bounds_type", "coordinate_frame") + } + if bounds + else None, + "diagnostics": self._model_health(limit=10), + "sections": ["components", "features", "expressions", "sketches"], + "warning": "Counts of owned bodies differ from recursive assembly geometry.", + } + + def _inspection_report( + self, + path, + objects=None, + minimum_clearance=0.0, + max_pairs=100, + include_clear=False, + capture=True, + section_planes=None, + ): + import html + + from nx_mcp.hardened import vector + from nx_mcp.visual_tools import unit_normal + + file = self.workspace.ensure_inside(path) + if file.suffix.lower() != ".zip" or file.exists(): + raise NXToolError("NX_INVALID_ARGUMENT", "Choose a new workspace .zip report path") + planes = section_planes or [] + if len(planes) > 6: + raise NXToolError("NX_INVALID_ARGUMENT", "At most six section planes") + for plane in planes: + if set(plane) != {"origin", "normal"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Section requires origin and normal only") + vector(plane["origin"]) + unit_normal(plane["normal"]) + if planes and not capture: + raise NXToolError("NX_INVALID_ARGUMENT", "Section screenshots require capture=true") + file.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix="inspection-", dir=self.workspace.root) as tmp: + root = Path(tmp) + checks = self._check_clearance(objects, minimum_clearance, max_pairs, include_clear) + report = { + "format": "nx-mcp-inspection", + "version": 1, + "summary": self._model_summary(), + "clearance": checks, + "captures": [], + } + if capture: + with self._temporary_view(): + report["captures"].append( + self._capture_view(str(root / "overview.png"), fit=True) + ) + # Capture bounded close-ups for actual flagged pairs, not every clear envelope. + pairs = [ + p + for p in checks["pairs"] + if p["classification"] in {"penetration", "contact", "below_clearance"} + ][:8] + for i, pair in enumerate(pairs): + refs = [r["id"] for r in pair["objects"]] + with self._temporary_view(): + self._set_visibility(refs, "isolate") + self._highlight_objects(refs) + report["captures"].append( + self._capture_view(str(root / f"pair-{i + 1}.png"), fit=True) + ) + for i, plane in enumerate(planes): + with self._temporary_view(): + current = self._work_part().ModelingViews.WorkView.ActiveDynamicSection + ref = ( + self._reference(current, "section", self._work_part(), "Section")[ + "id" + ] + if current + else None + ) + self._section_view(**plane, section=ref) + report["captures"].append( + self._capture_view(str(root / f"section-{i + 1}.png"), fit=True) + ) + for image in report["captures"]: + image["path"] = Path(image["path"]).name + (root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + body = ( + "

NX inspection report

Units: " + + html.escape(self._units()) + + "

"
+                + html.escape(json.dumps(checks, indent=2))
+                + "
" + ) + body += "".join( + '
' + + html.escape(r["path"]) + + "
" + for r in report["captures"] + ) + (root / "index.html").write_text( + 'NX inspection' + body, + encoding="utf-8", + ) + manifest = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in root.iterdir()} + (root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") + # Exclusive creation prevents overwriting reports; partial ZIPs are removed on failure. + created = False + try: + with file.open("xb") as stream: + created = True + with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive: + for p in sorted(root.iterdir()): + archive.write(p, p.name) + except Exception: + if created: + file.unlink(missing_ok=True) + raise + return { + "path": str(file), + "artifact_path": str(file.relative_to(self.workspace.root)), + "size": file.stat().st_size, + "sha256": hashlib.sha256(file.read_bytes()).hexdigest(), + "capture_count": len(report["captures"]), + "clearance": checks, + "geometry_changed": False, + "warnings": [ + "PNG views are native viewport captures, not photorealistic renders. Pair close-ups are capped at eight." + ], + } + + def _preview_plan(self, operations): + allowed = { + "nx_set_expression", + "nx_bind_parameter", + "nx_edit_feature", + "nx_edit_sketch", + "nx_set_component_transform", + "nx_reposition_component", + } + if not isinstance(operations, list) or not 1 <= len(operations) <= 25: + raise NXToolError("NX_INVALID_ARGUMENT", "Preview requires 1–25 supported edits") + locators = {} + + def visit(value): + if isinstance(value, str) and value.startswith("obj_"): + obj = self._resolve(value) + kind = self.objects._objects[value].reference.kind + locators[value] = self._locator(obj, kind) + elif isinstance(value, dict): + for v in value.values(): + visit(v) + elif isinstance(value, list): + for v in value: + visit(v) + + for op in operations: + if set(op) != {"method", "params"} or op["method"] not in allowed: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Preview supports expression, parameter, feature, sketch and placement edits only", + ) + inspect.signature(self._handlers[op["method"]]).bind(**op["params"]) + visit(op["params"]) + return locators + + def _preview_snapshot(self): + result = self._model_summary() + result["parameters"] = [ + {k: self._expression_record(e)[k] for k in ("name", "formula", "value", "units")} + for e in self._work_part().Expressions + if e.Type == "Number" + ] + try: + result["volume"] = self._measure_volume() + except NXToolError as err: + if err.code != "NX_NO_TARGET_BODY": + raise + result["volume"] = None + return result + + def _preview_change(self, operations, capture=True): + import copy + + locators = self._preview_plan(operations) + before = self._preview_snapshot() + part = self._work_part() + pid = self._part_id(part) + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP change preview" + ) + previous = self._active_mark + camera = self._view_info() if capture else None + try: + self._active_mark = mark + for op in operations: + self._handlers[op["method"]](**op["params"]) + after = self._preview_snapshot() + image = self._capture_view(fit=True) if capture else None + finally: + self._active_mark = previous + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + self.objects.invalidate_part(pid) + except Exception as exc: + raise NXToolError( + "NX_ROLLBACK_FAILED", + "Preview rollback failed: " + str(exc), + details={"mutation_outcome": "partial"}, + ) from exc + finally: + if camera: + self._restore_camera(camera) + token = "preview_" + uuid.uuid4().hex + if not hasattr(self, "_previews"): + self._previews = {} + if len(self._previews) >= 20: + self._previews.pop(next(iter(self._previews))) + self._previews[token] = { + "operations": copy.deepcopy(operations), + "locators": locators, + "part_id": pid, + "epoch": getattr(self, "_review_epoch", 0), + } + return { + "preview_id": token, + "before": before, + "after": after, + "capture": image, + "model_outcome": "rolled_back", + "warnings": [ + "Preview geometry has already been rolled back; returned geometry references are stale. Accept reapplies the stored edits only if no intervening mutation or manual handoff occurred." + ], + } + + def _finish_preview(self, preview_id, action): + if action not in {"accept", "discard"}: + raise NXToolError("NX_INVALID_ARGUMENT", "action must be accept or discard") + preview = getattr(self, "_previews", {}).get(preview_id) + if preview is None: + raise NXToolError("NX_PREVIEW_STALE", "Unknown or consumed preview") + if action == "discard": + del self._previews[preview_id] + return {"preview_id": preview_id, "action": "discard", "geometry_changed": False} + if preview["epoch"] != getattr(self, "_review_epoch", 0) or preview[ + "part_id" + ] != self._part_id(self._work_part()): + raise NXToolError("NX_PREVIEW_STALE", "Model/session changed; create a fresh preview") + replacements = { + key: self._reference(self._locate(v), v["kind"], self._work_part(), "Preview target")[ + "id" + ] + for key, v in preview["locators"].items() + } + + def translate(value): + if isinstance(value, str): + return replacements.get(value, value) + if isinstance(value, dict): + return {k: translate(v) for k, v in value.items()} + if isinstance(value, list): + return [translate(v) for v in value] + return value + + results = [ + self._handlers[op["method"]](**translate(op["params"])) for op in preview["operations"] + ] + del self._previews[preview_id] + return { + "preview_id": preview_id, + "action": "accept", + "results": results, + "geometry_changed": True, + } diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index 72fccbb..d2fb766 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -5,7 +5,31 @@ from dataclasses import asdict, dataclass from typing import Any, Literal -ObjectKind = Literal["part", "sketch", "curve", "feature", "body"] +ObjectKind = Literal[ + "reference_set", + "datum", + "part", + "sketch", + "curve", + "point", + "feature", + "body", + "component", + "face", + "edge", + "section", + "constraint", + "expression", + "component_pattern", + "assembly_constraint", + "drawing_sheet", + "drawing_view", + "dimension", + "annotation", + "traceline", + "explosion", + "modeling_view", +] @dataclass(frozen=True) diff --git a/src/nx_mcp/save_audit.py b/src/nx_mcp/save_audit.py new file mode 100644 index 0000000..693e2cf --- /dev/null +++ b/src/nx_mcp/save_audit.py @@ -0,0 +1,90 @@ +"""Verify work-part saves against loaded-part state and disk fingerprints.""" + +import hashlib +from pathlib import Path + +from nx_mcp.runtime import NXToolError + + +def fingerprint(path): + if not path or not Path(path).is_file(): + return {"exists": False} + file = Path(path) + before = file.stat() + with file.open("rb") as stream: + hasher = hashlib.sha256() + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(chunk) + digest = hasher.hexdigest() + stat = file.stat() + if (before.st_size, before.st_mtime_ns) != (stat.st_size, stat.st_mtime_ns): + raise OSError(f"File changed while verifying: {path}") + return {"exists": True, "sha256": digest, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns} + + +def snapshot(session): + return { + int(p.Tag): { + "path": p.FullPath, + "modified": bool(p.IsModified), + "file": fingerprint(p.FullPath), + } + for p in session.Parts + } + + +def verify(before, after, target_tag, native_errors): + target = after.get(target_tag) + unrelated = [] + unexpected = [] + for tag, row in before.items(): + if tag == target_tag: + continue + current = after.get(tag) + unchanged = current == row + if row["modified"]: + unrelated.append( + { + "path": row["path"], + "modified_before": True, + "modified_after": current["modified"] if current else None, + "unchanged": unchanged, + } + ) + if not unchanged: + unexpected.append({"path": row["path"], "before": row, "after": current}) + new_parts = [row for tag, row in after.items() if tag not in before] + saved = bool( + target + and target["path"] == before[target_tag]["path"] + and not target["modified"] + and target["file"]["exists"] + and not native_errors + ) + result = { + "requested_file": before[target_tag]["path"], + "saved_files": [target["path"]] if saved else [], + "observed_changed_files": [ + row["path"] + for tag, row in after.items() + if tag not in before or row["file"] != before[tag]["file"] + ], + "save_scope": "work_part_only", + "verification_scope": "loaded_part_flags_and_files", + "verified_part_count": len(before), + "target_file": { + "before": before[target_tag]["file"], + "after": target["file"] if target else None, + }, + "unrelated_modified_parts": unrelated, + "unexpected_changes": unexpected, + "unexpected_new_parts": new_parts, + "native_save_errors": native_errors, + } + if not saved or unexpected or new_parts: + raise NXToolError( + "NX_SAVE_VERIFICATION_FAILED", + "Save did not satisfy its file/state contract; inspect the receipt before retrying", + details={**result, "mutation_outcome": "partial"}, + ) + return result diff --git a/src/nx_mcp/schema_types.py b/src/nx_mcp/schema_types.py new file mode 100644 index 0000000..7a05d8e --- /dev/null +++ b/src/nx_mcp/schema_types.py @@ -0,0 +1,19 @@ +"""Schema metadata is optional in NX's standard-library-only embedded Python. + +The sidecar requires Pydantic and builds/validates actual models. Native handlers +only import contract names and effect sets; they never instantiate these models. +""" + +try: + from pydantic import BaseModel, ConfigDict, Field +except ModuleNotFoundError as error: + if error.name != "pydantic": + raise + + class BaseModel: # type: ignore[no-redef] + pass + + ConfigDict = dict # type: ignore[misc,assignment] + + def Field(*args, **kwargs): # type: ignore[no-redef] + return None diff --git a/src/nx_mcp/server.py b/src/nx_mcp/server.py index 8922be2..1f79e3a 100644 --- a/src/nx_mcp/server.py +++ b/src/nx_mcp/server.py @@ -23,6 +23,7 @@ def create_server( *, enable_experimental: bool | None = None, enable_journal: bool | None = None, + surface: str | None = None, ) -> FastMCP: """Create the certified v0.2 MCP server.""" if workspace is None and (workspace_root := os.environ.get("NX_MCP_WORKSPACE")): @@ -31,13 +32,24 @@ def create_server( enable_experimental = os.environ.get("NX_MCP_ENABLE_EXPERIMENTAL") == "1" if enable_journal is None: enable_journal = os.environ.get("NX_MCP_ENABLE_JOURNAL") == "1" - return create_certified_server( + surface = surface or os.environ.get("NX_MCP_SURFACE", "full") + if surface not in {"full", "agent"}: + raise ValueError("NX_MCP_SURFACE must be full or agent") + if surface == "agent" and (not enable_experimental or workspace is None): + raise ValueError("Agent surface requires experimental integration and workspace") + server = create_certified_server( bridge or DescriptorBridgeClient(), workspace, enable_experimental=enable_experimental, enable_journal=enable_experimental and enable_journal, ) + if surface == "agent": + from nx_mcp.agent_surface import configure + + configure(server, workspace) + return server + async def async_main() -> None: """Run the MCP server with stdio transport.""" diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py new file mode 100644 index 0000000..9875e46 --- /dev/null +++ b/src/nx_mcp/sheet_metal.py @@ -0,0 +1,1165 @@ +"""Native sheet-metal authoring through reviewed NX 2606 builder contracts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from nx_mcp.authoring import finite, page +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import enum_name, unit_normal + +CATALOG = json.loads(Path(__file__).with_name("sheet_metal_catalog.json").read_text()) +APPLICATION = "UG_APP_SBSM" + + +def field_schema(field): + kind = field["kind"] + if kind == "section": + return { + "oneOf": [ + {"type": "string", "description": "Finished work-part sketch ID"}, + *[ + { + "type": "object", + "properties": { + objects: field_schema({"kind": "reference_list"}), + "help_point": field_schema({"kind": "point3d"}), + }, + "required": [objects, "help_point"], + "additionalProperties": False, + } + for objects in ("edges", "curves") + ], + ] + } + if kind in {"expression", "number"}: + return {"type": "number"} + if kind in {"boolean", "integer", "string"}: + return {"type": kind} + if kind == "enum": + return {"type": "string", "enum": field["values"]} + if kind == "object": + return fields_schema(field["fields"]) + if kind in {"flange_list", "joggle_list"}: + return { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": fields_schema( + field["fields"], + field["required"], + ), + } + if kind == "point_section": + return { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": field_schema({"kind": "point3d"}), + } + if kind == "object_list": + return { + "type": "array", + "minItems": 0, + "maxItems": 100, + "items": fields_schema(field["fields"], field["required"]), + } + if kind == "face_pairs": + return { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "string"}}, + } + if kind in {"point", "point3d", "direction"}: + return {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}} + if kind in {"plane", "csys"}: + props = {"origin": field_schema({"kind": "point3d"})} + props.update( + { + key: field_schema({"kind": "direction"}) + for key in (["normal"] if kind == "plane" else ["x_axis", "y_axis"]) + } + ) + return { + "type": "object", + "properties": props, + "required": list(props), + "additionalProperties": False, + } + if kind in {"collector", "reference_list", "select_faces", "select_edges", "select_bodies"}: + return { + "type": "array", + "minItems": 1, + "maxItems": 1000, + "uniqueItems": True, + "items": { + "type": "string", + "description": "Typed work-part " + field.get("objects", "geometry") + " ID", + }, + } + return { + "type": "string", + "description": "Finished work-part sketch ID" + if kind == "section" + else "Typed work-part " + kind + " ID", + } + + +def fields_schema(fields, required=()): + return { + "type": "object", + "properties": { + k: { + **field_schema(v), + **({"description": v["description"]} if "description" in v else {}), + } + for k, v in fields.items() + }, + "required": list(required), + "additionalProperties": False, + } + + +class SheetMetalMixin: + def _create_path_sketch( + self, + edges, + help_point, + percent=0, + orienting_face=None, + reverse_normal=False, + reverse_axis=False, + name=None, + ): + import NXOpen.GeometricUtilities as G + + part = self._work_part() + if self.session.ActiveSketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the active sketch first") + if not self.session.IsBatch and self.session.Parts.Display != part: + raise NXToolError("NX_PART_CONTEXT", "Activate the same work and display part") + values = self._sm_validate( + { + "edges": {"kind": "reference_list", "objects": "edge"}, + "help_point": {"kind": "point3d"}, + "percent": {"kind": "number"}, + "reverse_normal": {"kind": "boolean"}, + "reverse_axis": {"kind": "boolean"}, + }, + { + "edges": edges, + "help_point": help_point, + "percent": percent, + "reverse_normal": reverse_normal, + "reverse_axis": reverse_axis, + }, + ) + if not 0 <= values["percent"] <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Path percentage must be within 0..100") + if name is not None and ( + not isinstance(name, str) + or not name + or len(name) > 132 + or any(ord(c) < 32 for c in name) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use a nonempty sketch name without control characters" + ) + face = self._sm_reference(orienting_face, "face") if orienting_face else None + self._require_api(part.Sketches, "CreateSketchAlongPathBuilder") + builder = part.Sketches.CreateSketchAlongPathBuilder(None) + try: + self._sm_section( + builder.Section, {"edges": values["edges"], "help_point": values["help_point"]} + ) + builder.PlaneOrientation = ( + self.nxopen.SketchAlongPathBuilder.PlaneOrientationType.NormalToPath + ) + builder.SketchOrient = getattr( + self.nxopen.SketchAlongPathBuilder.SketchOrientationType, + "RelativeToFace" if face else "Automatic", + ) + if face: + builder.OrientingFace.ReplaceRules( + [part.ScRuleFactory.CreateRuleFaceDumb([face])], False + ) + location = builder.PlaneLocation + location.IsParameterUsed = False + location.IsPercentUsed = True + location.Expression.RightHandSide = str(values["percent"]) + location.Update(G.OnPathDimensionBuilder.UpdateReason.Path) + builder.ReversePlaneNormal = values["reverse_normal"] + builder.ReverseAxis = values["reverse_axis"] + if not builder.Validate(): + raise NXToolError("NX_SKETCH_INVALID", "Native path sketch validation failed") + sketch = builder.Commit() + if name: + sketch.SetName(name) + self._update_model() + sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue) + return { + "object": self._reference(sketch, "sketch", part, "Path sketch"), + "frame": self._sketch_frame(sketch), + "percent": values["percent"], + "position_convention": "arc_length_percent", + "units": self._units(), + } + finally: + builder.Destroy() + + def _sm_manager(self): + part = self._work_part() + self._require_api(part.Features, "SheetmetalManager") + return part.Features.SheetmetalManager + + def _sm_prepare(self): + part = self._work_part() + if not self.session.IsBatch and self.session.Parts.Display != part: + raise NXToolError("NX_PART_CONTEXT", "Activate the same work and display part") + if self.session.ActiveSketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the sketch before sheet-metal authoring") + if not self.session.IsBatch and self.session.ApplicationName != APPLICATION: + self.session.ApplicationSwitchImmediate(APPLICATION) + if self.session.ApplicationName != APPLICATION: + raise NXToolError("NX_APPLICATION_UNAVAILABLE", "NX did not enter Sheet Metal") + return part + + def _sheet_metal_context(self): + self._sm_prepare() + return { + "application": "batch" if self.session.IsBatch else self.session.ApplicationName, + "recovery": self._checkpoint_state(), + "message": "Native Sheet Metal context is active", + } + + def _sm_require_context(self): + part = self._work_part() + if self.session.ActiveSketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the sketch before sheet-metal authoring") + if not self.session.IsBatch and ( + self.session.ApplicationName != APPLICATION or self.session.Parts.Display != part + ): + raise NXToolError( + "NX_PART_CONTEXT", + "Activate the work/display part and call nx_sheet_metal_context first", + ) + + def _sheet_metal_schema(self, operation=None): + if operation is None: + return { + "operations": [ + { + "operation": op, + "status": spec["native_status"], + "builder": spec["builder"], + "tested_on": spec.get("tested_on"), + "edit_status": spec.get("edit_status", "experimental"), + } + for op, spec in CATALOG.items() + ], + "units": None, + "unit_conventions": "Lengths use work-part units; expression angles are degrees; neutral factor is unitless", + "unavailable": [ + { + "operation": "remove_bends", + "reason": "NX v2606 installation feature toggle is disabled", + } + ], + "coordinate_frame": "work_part", + "not_exposed": ["metaform", "nesting"], + } + if operation not in CATALOG: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown sheet-metal operation") + spec = CATALOG[operation] + return { + "operation": operation, + "parameters_schema": fields_schema(spec["fields"], spec["required"]), + "edit_parameters_schema": fields_schema(spec["fields"]), + "native_builder": spec["builder"], + "validation_status": spec["native_status"], + "tested_on": spec.get("tested_on"), + "validation_scope": spec.get("validation_scope"), + "edit_status": spec.get("edit_status", "experimental"), + "example_parameters": spec.get("example_parameters"), + "example_evidence": spec.get("example_evidence"), + "example_note": "$input_N values are placeholders; select matching geometry from your own fixture", + "defaults": "Unspecified properties retain native part/builder defaults; read feature parameters after creation.", + "units": None, + "coordinate_frame": "work_part", + "unit_conventions": "Lengths use work-part units; expression angles are degrees; neutral factor is unitless", + "prerequisites": [ + "Activate the owning work/display part and call nx_sheet_metal_context before authoring.", + "Select work-part geometry IDs with nx_find_geometry or nx_list_topology; assembly occurrences are rejected.", + "Finish section sketches before passing their IDs. Operation-specific geometry must match the native builder and tested example.", + ] + + spec.get("prerequisites", []), + } + + def _sm_reference(self, reference, kind): + if not isinstance(reference, str): + raise NXToolError("NX_INVALID_ARGUMENT", "Use typed object IDs") + kinds = {"face", "edge"} if kind == "face_or_edge" else {kind} + obj = self._resolve(reference, kinds) + if obj.IsOccurrence or obj.OwningPart != self._work_part(): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Sheet-metal inputs must belong to the work part" + ) + if kind == "sketch" and obj == self.session.ActiveSketch: + raise NXToolError("NX_SKETCH_ACTIVE", "Finish the input sketch") + return obj + + def _sm_validate(self, fields, parameters, required=()): + if ( + not isinstance(parameters, dict) + or set(parameters) - set(fields) + or set(required) - set(parameters) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Missing or unsupported sheet-metal parameters", + details={"required": list(required), "supported": list(fields)}, + ) + result = {} + for key, value in parameters.items(): + f = fields[key] + kind = f["kind"] + if kind == "section" and isinstance(value, dict): + source = "edges" if "edges" in value else "curves" + value = self._sm_validate( + { + source: {"kind": "reference_list", "objects": source[:-1]}, + "help_point": {"kind": "point3d"}, + }, + value, + (source, "help_point"), + ) + elif kind in {"expression", "number"}: + value = finite(value, key) + if "neutral_factor" in key and not 0 <= value <= 1: + raise NXToolError("NX_INVALID_ARGUMENT", "Neutral factor must be within 0–1") + if "thickness" in key and value <= 0: + raise NXToolError("NX_INVALID_ARGUMENT", "Thickness must be positive") + if "radius" in key and value < 0: + raise NXToolError("NX_INVALID_ARGUMENT", "Radius cannot be negative") + elif kind == "integer": + if type(value) is not int: + raise NXToolError("NX_INVALID_ARGUMENT", key + " must be an integer") + elif kind == "boolean": + if type(value) is not bool: + raise NXToolError("NX_INVALID_ARGUMENT", key + " must be boolean") + elif kind == "enum": + if not isinstance(value, str) or value not in f["values"]: + raise NXToolError( + "NX_INVALID_ARGUMENT", key + " must be one of " + ", ".join(f["values"]) + ) + elif kind == "string": + if ( + not isinstance(value, str) + or len(value) > 256 + or any(ord(c) < 32 for c in value) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + key + " must be a short string without control characters", + ) + elif kind == "object": + value = self._sm_validate(f["fields"], value) + elif kind == "point_section": + if not isinstance(value, list) or not 1 <= len(value) <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Use 1–100 point coordinates") + value = [ + self._sm_validate({"point": {"kind": "point3d"}}, {"point": p})["point"] + for p in value + ] + elif kind == "object_list": + if not isinstance(value, list) or len(value) > 100: + raise NXToolError("NX_INVALID_ARGUMENT", key + " requires at most 100 entries") + value = [self._sm_validate(f["fields"], item, f["required"]) for item in value] + elif kind in {"flange_list", "joggle_list"}: + if not isinstance(value, list) or not 1 <= len(value) <= 100: + raise NXToolError("NX_INVALID_ARGUMENT", key + " requires 1–100 entries") + required_item = f["required"] + value = [self._sm_validate(f["fields"], item, required_item) for item in value] + elif kind in { + "collector", + "reference_list", + "select_faces", + "select_edges", + "select_bodies", + "face_pairs", + }: + if not isinstance(value, list) or not 1 <= len(value) <= 1000: + raise NXToolError( + "NX_INVALID_ARGUMENT", key + " requires a nonempty reference list" + ) + if kind == "face_pairs": + if len(value) > 100: + raise NXToolError("NX_INVALID_ARGUMENT", "Use at most 100 face pairs") + if any( + not isinstance(pair, list) or len(pair) != 2 or pair[0] == pair[1] + for pair in value + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Use distinct face pairs") + value = [[self._sm_reference(r, "face") for r in pair] for pair in value] + else: + object_kind = ( + f.get("objects") + or { + "select_faces": "face", + "select_edges": "edge", + "select_bodies": "body", + }[kind] + ) + if any(not isinstance(r, str) for r in value) or len(set(value)) != len(value): + raise NXToolError( + "NX_INVALID_ARGUMENT", "References must be unique strings" + ) + value = [self._sm_reference(r, object_kind) for r in value] + elif kind in {"point", "point3d", "direction"}: + if not isinstance(value, list) or len(value) != 3: + raise NXToolError("NX_INVALID_ARGUMENT", key + " requires three numbers") + value = [finite(v, key) for v in value] + if kind == "direction": + value = unit_normal(value) + elif kind in {"plane", "csys"}: + keys = ["origin", "normal"] if kind == "plane" else ["origin", "x_axis", "y_axis"] + if not isinstance(value, dict) or set(value) != set(keys): + raise NXToolError("NX_INVALID_ARGUMENT", key + " requires " + ", ".join(keys)) + value = self._sm_validate( + {k: {"kind": "point3d" if k == "origin" else "direction"} for k in keys}, value + ) + if kind == "csys": + from nx_mcp.hardened import dot + + if abs(dot(value["x_axis"], value["y_axis"])) > 1e-8: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Coordinate-system axes must be orthogonal" + ) + else: + reference_kind = "sketch" if kind == "section" else kind.removeprefix("select_") + value = self._sm_reference(value, reference_kind) + result[key] = value + return result + + def _sm_section(self, section, sketch): + part = self._work_part() + if isinstance(sketch, dict): + source = "edges" if "edges" in sketch else "curves" + factory = getattr(part.ScRuleFactory, "CreateRule" + source[:-1].title() + "Dumb") + rule = factory(sketch[source]) + section.Clear() + section.AddToSection( + [rule], + sketch[source][0], + None, + None, + self.nxopen.Point3d(*sketch["help_point"]), + self.nxopen.Section.Mode.Create, + False, + ) + return + options = part.ScRuleFactory.CreateRuleOptions() + try: + rule = part.ScRuleFactory.CreateRuleCurveFeature([sketch.Feature], None, options) + finally: + options.Dispose() + section.Clear() + section.AddToSection( + [rule], None, None, None, sketch.Origin, self.nxopen.Section.Mode.Create, False + ) + + def _sm_apply(self, builder, fields, values): + import NXOpen.Features.SheetMetal as SM + + part = self._work_part() + for key, value in values.items(): + f = fields[key] + kind = f["kind"] + if kind == "reference_list": + getattr(builder, f["method"])(value) + continue + if kind == "face_pairs": + count = ( + builder.GetNumberOfFacePairs() + if hasattr(builder, "GetNumberOfFacePairs") + else builder.NumberOfFacePairs + ) + pairs = [builder.GetFacePair(i) for i in range(count)] + for pair in pairs: + builder.RemoveFacePair(*pair) + for pair in value: + builder.AddFacePair(*pair) + continue + if kind in {"flange_list", "joggle_list", "object_list"}: + if kind == "object_list": + container = getattr(builder, f["container"]) if f["container"] else builder + sequence = getattr(container, f["sequence"]) + create = getattr(container, f["creator"]) + elif kind == "flange_list": + container = builder.FlangePropertiesList + sequence = container.FeatureBendPropertiesList + create = container.CreateFlangeBendProperties + else: + sequence = builder.InputList + create = builder.CreateJoggleInputListItem + sequence.Clear(self.nxopen.ObjectList.DeleteOption.Delete) + for item in value: + entry = create() + sequence.Append(entry) + self._sm_apply(entry, f["fields"], item) + continue + path = f["path"] + # Some assignable native sections throw when read before initialization. + # Only read properties whose existing builder object we actually mutate. + needs_target = kind in { + "expression", + "object", + "section", + "point_section", + "collector", + "select_faces", + "select_edges", + "select_bodies", + } or kind.startswith("select_") + target = getattr(builder, path) if needs_target and not f.get("assign") else None + if f.get("getter"): + target = target() + if kind == "expression": + target.RightHandSide = str(value) + elif kind == "object": + self._sm_apply(target, f["fields"], value) + elif kind == "enum": + setattr(builder, path, getattr(getattr(SM, f["enum_type"]), value)) + elif kind == "section": + if f.get("assign"): + if isinstance(value, dict): + section = part.Sections.CreateSection() + setattr(builder, path, section) + self._sm_section(section, value) + else: + setattr(builder, path, self._engineering_section(value)) + else: + self._sm_section(target, value) + elif kind == "point_section": + points = [part.Points.CreatePoint(self.nxopen.Point3d(*p)) for p in value] + for point in points: + point.Blank() + rule = part.ScRuleFactory.CreateRuleCurveDumbFromPoints(points) + target.Clear() + target.AddToSection( + [rule], + None, + None, + None, + self.nxopen.Point3d(*value[0]), + self.nxopen.Section.Mode.Create, + False, + ) + elif kind == "collector": + if f.get("assign"): + target = part.ScCollectors.CreateCollector() + setattr(builder, path, target) + factory = getattr(part.ScRuleFactory, "CreateRule" + f["objects"].title() + "Dumb") + target.ReplaceRules([factory(value)], False) + elif kind in {"select_faces", "select_edges", "select_bodies"}: + target.Clear() + target.Add(value) + elif kind.startswith("select_"): + target.Value = value + elif kind == "point3d": + setattr(builder, path, self.nxopen.Point3d(*value)) + elif kind == "point": + setattr(builder, path, part.Points.CreatePoint(self.nxopen.Point3d(*value))) + elif kind == "direction": + setattr(builder, path, self._engineering_direction(value)) + elif kind == "plane": + plane = part.Planes.CreatePlane( + self.nxopen.Point3d(*value["origin"]), + self.nxopen.Vector3d(*value["normal"]), + self.nxopen.SmartObject.UpdateOption.WithinModeling, + ) + setattr(builder, path, plane) + elif kind == "csys": + from nx_mcp.hardened import cross, transpose + + matrix = self._nx_matrix( + transpose( + [value["x_axis"], value["y_axis"], cross(value["x_axis"], value["y_axis"])] + ) + ) + csys = part.CoordinateSystems.CreateCoordinateSystem( + self.nxopen.Point3d(*value["origin"]), + matrix, + False, + ) + setattr(builder, path, csys) + else: + setattr(builder, path, value) + + def _sheet_metal_feature(self, operation, parameters, feature=None): + if operation not in CATALOG: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown sheet-metal operation") + spec = CATALOG[operation] + self._sm_require_context() + original = self._sm_reference(feature, "feature") if feature else None + if original is not None: + import NXOpen.Features.SheetMetal as SM + + native_type = getattr(SM, spec["builder"].removesuffix("Builder"), None) + expected_type = { + "tab": {"Base Tab", "Secondary Tab"}, + "flat_pattern": {"FLAT_PATTERN"}, + }.get(operation) + if (native_type is not None and not isinstance(original, native_type)) or ( + expected_type is not None and original.FeatureType not in expected_type + ): + raise NXToolError( + "NX_OBJECT_TYPE_MISMATCH", "Feature does not match this sheet-metal operation" + ) + values = self._sm_validate(spec["fields"], parameters, () if original else spec["required"]) + if operation == "tab" and ( + values.get("is_secondary") + or (original is not None and original.FeatureType == "Secondary Tab") + ): + target = values.get("target_body") + if target is None: + bodies = list(original.GetBodies()) if original is not None else [] + if len(bodies) != 1: + raise NXToolError( + "NX_INVALID_ARGUMENT", "Secondary tabs require an explicit target_body" + ) + target = bodies[0] + if ( + "thickness" in values + and abs(values["thickness"] - self._sm_manager().GetBodyThickness(target)) > 1e-8 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Secondary tab thickness must match its target sheet" + ) + if ( + operation == "advanced_flange" + and original is None + and values.get("type") == "ToReference" + and not values.get("faces") + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "ToReference requires reference faces on the same sheet-metal body as the selected edges; a valid geometric combination is still required", + ) + if not values: + raise NXToolError("NX_INVALID_ARGUMENT", "Supply sheet-metal parameters") + manager = self._sm_manager() + self._require_api(manager, spec["factory"]) + import NXOpen.Features.SheetMetal as SM + + b = getattr(manager, spec["factory"])(original) + try: + if original is None and hasattr(b, "SetApplicationContext"): + b.SetApplicationContext(SM.ApplicationContext.NxSheetMetal) + elif ( + original is not None + and hasattr(b, "GetApplicationContext") + and b.GetApplicationContext() != SM.ApplicationContext.NxSheetMetal + ): + raise NXToolError( + "NX_PART_CONTEXT", "Feature belongs to a different native application context" + ) + self._sm_apply(b, spec["fields"], values) + # NX 2606's legacy ValidateBuilderData returned varying nonzero/negative + # values for an unchanged valid tab. Use the supported Builder.Validate + # contract and transactional native commit instead (native api23 evidence). + if not b.Validate(): + raise NXToolError("NX_SHEET_METAL_INVALID", "Native builder validation failed") + f = b.CommitFeature() + self._update_model() + result = self._engineering_result(f) + result.update( + operation=operation, + native_feature_type=f.FeatureType, + requested_parameters=parameters, + expressions=[self._expression_record(exp) for exp in f.GetExpressions()], + ) + if operation == "flat_pattern": + result["model_view_name"] = b.FlatPatternViewName + return result + finally: + b.Destroy() + + def _sheet_metal_info(self, body=None, offset=0, limit=50): + import NXOpen.Features.SheetMetal as SM + + manager = self._sm_manager() + part = self._work_part() + bodies = [self._sm_reference(body, "body")] if body else list(part.Bodies) + result = [] + for b in bodies: + record = { + "body": self._reference(b, "body", part, "Body"), + "sheet_metal": bool(manager.IsSheetmetalBody(b)), + } + if record["sheet_metal"]: + faces, states = manager.GetInnerBendFaces(b) + bends = [] + for face, state in zip(faces, states, strict=True): + params = manager.GetBendParameters(face) + bends.append( + { + "face": self._reference(face, "face", part, "Face"), + "state": enum_name(state, SM.SheetmetalBendState), + "inner_radius": params.InnerRadius, + "angle_degrees": params.BendAngle, + "neutral_factor": params.NeutralFactor, + } + ) + record.update( + thickness=manager.GetBodyThickness(b), bends=bends, bend_count=len(bends) + ) + result.append(record) + return { + **page(result, offset, limit), + "units": self._units(), + "coordinate_frame": "work_part", + "angle_convention": "degrees", + } + + def _sheet_metal_defaults(self): + import NXOpen.Preferences as P + + manager = self._work_part().Preferences.SheetMetalPreferences + values = {} + for key, method in { + "thickness": "GetMaterialThickness", + "bend_radius": "GetBendRadius", + "neutral_factor": "GetNeutralFactor", + "bend_relief_width": "GetBendReliefWidth", + "bend_relief_depth": "GetBendReliefDepth", + }.items(): + self._require_api(manager, method) + expression = getattr(manager, method)() + values[key] = self._expression_record(expression) if expression else None + return { + "parameters": values, + "parameter_entry": enum_name( + manager.GetParameterEntryType(), P.SheetMetalPreferencesBuilder.ParameterEntryTypes + ), + "bend_definition": enum_name( + manager.GetBendDefinitionMethod(), + P.SheetMetalPreferencesBuilder.BendDefinitionMethodOptions, + ), + "bend_table": manager.GetBendTable(), + "bend_allowance_formula": manager.GetBendAllowanceFormula(), + "bend_deduction_formula": manager.GetBendDeductionFormula(), + "material": manager.GetMaterialName(), + "tool": manager.GetToolName(), + "material_catalog_status": "unavailable", + "warnings": [ + "NX v2606 GetMaterialNames raised a native memory-access error on this installation; automatic catalog enumeration is disabled." + ], + "units": self._units(), + } + + def _set_sheet_metal_defaults( + self, + parameter_entry=None, + thickness=None, + bend_radius=None, + neutral_factor=None, + bend_relief_width=None, + bend_relief_depth=None, + material=None, + tool=None, + bend_definition=None, + bend_table=None, + bend_allowance_formula=None, + bend_deduction_formula=None, + ): + import NXOpen.Preferences as P + + numeric = { + "MaterialThickness": thickness, + "BendRadius": bend_radius, + "NeutralFactor": neutral_factor, + "BendReliefWidth": bend_relief_width, + "BendReliefDepth": bend_relief_depth, + } + if all( + v is None + for v in [ + *numeric.values(), + parameter_entry, + material, + tool, + bend_definition, + bend_table, + bend_allowance_formula, + bend_deduction_formula, + ] + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Provide at least one sheet-metal default") + for name, value in numeric.items(): + if value is not None: + finite(value, name, name == "MaterialThickness") + if value < 0 or name == "NeutralFactor" and value > 1: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Defaults require nonnegative lengths and a neutral factor within 0–1", + ) + manager = self._work_part().Preferences.SheetMetalPreferences + for name in (material, tool): + if name is not None and ( + not isinstance(name, str) + or not name + or len(name) > 256 + or any(ord(c) < 32 for c in name) + ): + raise NXToolError("NX_INVALID_ARGUMENT", "Use a valid installed material/tool name") + for formula in (bend_allowance_formula, bend_deduction_formula): + if formula is not None and ( + not isinstance(formula, str) + or not formula + or len(formula) > 1024 + or any(ord(c) < 32 for c in formula) + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Use a nonempty NX bend formula without control characters", + ) + table = self.workspace.resolve(bend_table) if bend_table is not None else None + if table is not None and not table.is_file(): + raise NXToolError("NX_FILE_NOT_FOUND", "Upload the bend table to the workspace first") + if parameter_entry is not None and parameter_entry not in { + "Value", + "MaterialTable", + "ToolIdTable", + }: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown sheet-metal parameter entry mode") + bend_methods = { + "NeutralFactorValue", + "BendTable", + "BendAllowanceFormula", + "MaterialTable", + "ToolTable", + "BendAllowanceTable", + "BendDeductionTable", + "BendDeductionFormula", + "Din6935Formula", + } + if bend_definition is not None and bend_definition not in bend_methods: + raise NXToolError("NX_INVALID_ARGUMENT", "Unknown native bend-definition method") + b = manager.CreateSheetMetalPreferencesBuilder() + try: + if parameter_entry is not None: + b.ParameterEntryType = getattr( + P.SheetMetalPreferencesBuilder.ParameterEntryTypes, parameter_entry + ) + if material is not None: + b.SetMaterial(material) + if tool is not None: + b.SetToolName(tool) + if bend_definition is not None: + choices = P.SheetMetalPreferencesBuilder.BendDefinitionMethodOptions + b.SetBendDefinitionMethod(getattr(choices, bend_definition)) + if table is not None: + b.SetBendTable(str(table)) + if bend_allowance_formula is not None: + b.BendAllowanceFormula = bend_allowance_formula + if bend_deduction_formula is not None: + b.BendDeductionFormula = bend_deduction_formula + for name, value in numeric.items(): + if value is not None: + getattr(b, name).RightHandSide = str(value) + b.Commit() + self._update_model() + finally: + b.Destroy() + result = self._sheet_metal_defaults() + for key, requested in { + "material": material, + "tool": tool, + "parameter_entry": parameter_entry, + "bend_definition": bend_definition, + }.items(): + if requested is not None and result[key] != requested: + raise NXToolError( + "NX_VERIFICATION_FAILED", + "Native sheet-metal default did not retain requested " + key, + ) + for key, requested in { + "thickness": thickness, + "bend_radius": bend_radius, + "neutral_factor": neutral_factor, + "bend_relief_width": bend_relief_width, + "bend_relief_depth": bend_relief_depth, + }.items(): + if requested is not None: + actual = result["parameters"][key]["value"] + if actual is None or abs(actual - requested) > 1e-9 * max(1, abs(requested)): + raise NXToolError( + "NX_VERIFICATION_FAILED", + "Native sheet-metal default did not retain requested " + key, + ) + return result + + def _export_flat_pattern( + self, + flat_pattern, + path, + format="dxf", + revision="R2018", + bend_up=True, + bend_down=True, + bend_tangent=False, + interior_cutout=True, + interior_feature=False, + inner_mold=False, + outer_mold=False, + added_top=False, + added_bottom=False, + tolerance=0.01, + ): + import hashlib + import uuid + + import NXOpen.Features.SheetMetal as SM + + if format not in {"dxf", "geo"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Flat pattern format must be dxf or geo") + destination = self.workspace.resolve(path) + if destination.suffix.lower() != "." + format or destination.exists(): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Use an unused matching .dxf or .geo output path" + ) + tolerance = finite(tolerance, "tolerance", True) + feature = self._sm_reference(flat_pattern, "feature") + if feature.FeatureType != "FLAT_PATTERN": + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a native Flat Pattern feature") + options = { + "BendUp": bend_up, + "BendDown": bend_down, + "BendTangent": bend_tangent, + "InteriorCutout": interior_cutout, + "InteriorFeature": interior_feature, + "InnerMold": inner_mold, + "OuterMold": outer_mold, + "AddedTop": added_top, + "AddedBottom": added_bottom, + } + if any(type(v) is not bool for v in options.values()): + raise NXToolError("NX_INVALID_ARGUMENT", "Export options must be boolean") + if format == "geo" and (inner_mold or outer_mold or revision != "R2018"): + raise NXToolError("NX_INVALID_ARGUMENT", "DXF options are not supported for GEO") + revisions = SM.ExportFlatPatternBuilder.DxfRevisionType + if revision not in { + "R12", + "R13", + "R14", + "R2000", + "R2004", + "R2005", + "R2007", + "R20102012", + "R20132016", + "R2018", + }: + raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported native DXF revision") + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_name(".nx-export-" + uuid.uuid4().hex + destination.suffix) + try: + builder = self._sm_manager().CreateExportFlatPatternBuilder() + try: + builder.FlatPattern.Value = feature + builder.OutputFile = str(temporary) + builder.Type = getattr( + SM.ExportFlatPatternBuilder.FileType, "Dxf" if format == "dxf" else "TrumpfGeo" + ) + builder.ExportLocation = SM.ExportFlatPatternBuilder.ExportLocationOptions.Native + if format == "dxf": + builder.DxfRevision = getattr(revisions, revision) + builder.DeviationalTolerance = tolerance + for name, value in options.items(): + setattr(builder, name, value) + builder.Commit() + finally: + builder.Destroy() + if not temporary.is_file() or not temporary.stat().st_size: + raise NXToolError( + "NX_EXPORT_FAILED", "NX did not produce a nonempty flat-pattern file" + ) + data = temporary.read_bytes() + if ( + format == "dxf" + and b"SECTION" not in data + and not data.startswith(b"AutoCAD Binary DXF") + ): + raise NXToolError("NX_EXPORT_FAILED", "Native output is not recognized as DXF") + with destination.open("xb") as output: + try: + output.write(data) + output.flush() + except BaseException: + output.close() + destination.unlink(missing_ok=True) + raise + return { + "path": str(destination), + "format": format, + "revision": revision if format == "dxf" else None, + "options": options, + "tolerance": tolerance, + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "units": self._units(), + "coordinate_frame": "native_flat_pattern", + "flat_pattern": self._reference( + feature, "feature", self._work_part(), "FlatPattern" + ), + } + finally: + temporary.unlink(missing_ok=True) + + def _add_flat_pattern_view(self, drawing, flat_pattern, position=None): + part = self._work_part() + feature = self._sm_reference(flat_pattern, "feature") + if feature.FeatureType != "FLAT_PATTERN": + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a native Flat Pattern feature") + sheet = self._drawing_object(drawing, "drawing_sheet") + point = [100.0, 100.0] if position is None else position + if not isinstance(point, list) or len(point) != 2: + raise NXToolError( + "NX_INVALID_ARGUMENT", "position requires two sheet coordinates in sheet units" + ) + point = [finite(v, "position") for v in point] + definition = self._sm_manager().CreateFlatPatternBuilder(feature) + try: + model_view = part.ModelingViews.FindObject(definition.FlatPatternViewName) + finally: + definition.Destroy() + sheet.Open() + builder = part.DraftingViews.CreateBaseViewBuilder(None) + try: + builder.SelectModelView.SelectedView = model_view + builder.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point)) + view = builder.Commit() + finally: + builder.Destroy() + self._place_drawing_view(view, sheet, point) + return { + "view": self._reference(view, "drawing_view", part, "Flat pattern view"), + "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"), + "flat_pattern": self._reference(feature, "feature", part, "FlatPattern"), + "model_view_name": model_view.Name, + **self._drawing_coordinates(sheet, point), + "units": self._sheet_units(sheet), + } + + def _sheet_metal_annotation( + self, kind, body, position, faces=None, annotation=None, automatic=False + ): + import NXOpen.Annotations as A + + if kind not in {"body", "bend"}: + raise NXToolError("NX_INVALID_ARGUMENT", "Annotation kind must be body or bend") + target = self._sm_reference(body, "body") + if not self._sm_manager().IsSheetmetalBody(target): + raise NXToolError("NX_NOT_SHEET_METAL", "Select an actual native sheet-metal body") + point = self._sm_validate({"position": {"kind": "point3d"}}, {"position": position})[ + "position" + ] + if kind == "body" and faces is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "Body annotation does not take bend faces") + selected = [] + if kind == "bend": + selected = self._sm_validate({"faces": {"kind": "select_faces"}}, {"faces": faces})[ + "faces" + ] + for face in selected: + if face.GetBody() != target: + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Bend faces must belong to the selected body" + ) + self._sm_manager().GetBendParameters(face) + existing = self._sm_reference(annotation, "annotation") if annotation else None + units = self._units() + if kind == "body": + measured = {"thickness": float(self._sm_manager().GetBodyThickness(target))} + lines = [ + "Sheet metal (managed measurement)" + if automatic + else "Sheet metal (measured snapshot)", + f"Thickness: {measured['thickness']:.3f} {units}", + ] + else: + measured = {"bends": []} + lines = [ + "Bend data (managed measurement)" if automatic else "Bend data (measured snapshot)" + ] + for index, face in enumerate(selected, 1): + data = self._sm_manager().GetBendParameters(face) + values = { + "inner_radius": float(data.InnerRadius), + "angle_degrees": float(data.BendAngle), + "neutral_factor": float(data.NeutralFactor), + } + measured["bends"].append(values) + lines.append( + f"Bend {index}: R {values['inner_radius']:.3f} {units}; " + f"angle {values['angle_degrees']:.3f} deg; K {values['neutral_factor']:.3f}" + ) + builder = self._sm_manager().CreateSheetMetalPmiBuilder(existing) + try: + builder.PMIType = getattr(A.SheetMetalPMIBuilder.Types, kind.capitalize()) + builder.SelectedBody.Value = target + builder.SelectedFace.Clear() + if selected: + builder.SelectedFace.Add(selected) + builder.AssociatedObjects.Nxobjects.Clear() + builder.AssociatedObjects.Nxobjects.Add(selected or [target]) + builder.Text.TextBlock.SetText(lines) + builder.Origin.SetInferRelativeToGeometry(True) + builder.Origin.Origin.SetValue(None, None, self.nxopen.Point3d(*point)) + if not builder.Validate(): + raise NXToolError( + "NX_ANNOTATION_INVALID", "Native sheet-metal annotation validation failed" + ) + result = builder.Commit() + values = list(builder.GetCommittedObjects()) + if not values and result is not None: + values = [result] + if not values: + raise NXToolError( + "NX_VERIFICATION_FAILED", "NX returned no sheet-metal annotations" + ) + self._update_model() + if automatic: + for value in values: + self._remember_managed_annotation(value, kind, target, selected, measured) + elif existing: + for value in values: + value.SetAttribute("NX_MCP_MEASURED_PMI_V1", "disabled") + refs = [ + self._reference(value, "annotation", self._work_part(), "Sheet-metal PMI") + for value in values + ] + return { + "annotations": refs, + "annotation_count": len(refs), + "created": [] if existing else refs, + "modified": refs if existing else [], + "text": [ + list(value.GetText()) if hasattr(value, "GetText") else None for value in values + ], + "body": self._reference(target, "body", self._work_part(), "Body"), + "kind": kind, + "measured_parameters": measured, + "text_semantics": "Automatically refreshed after MCP model mutations; explicitly refresh after manual NX changes" + if automatic + else "Measured snapshot; call this tool with the annotation ID to refresh after model edits", + "automatic": automatic, + "requested_position": point, + "units": self._units(), + "coordinate_frame": "work_part", + } + finally: + builder.Destroy() diff --git a/src/nx_mcp/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json new file mode 100644 index 0000000..e1dcbd5 --- /dev/null +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -0,0 +1,5513 @@ +{ + "tab": { + "builder": "TabBuilder", + "factory": "CreateTabFeatureBuilder", + "fields": { + "is_secondary": { + "path": "IsSecondary", + "getter": false, + "kind": "boolean" + }, + "material_side": { + "path": "MaterialSide", + "getter": false, + "kind": "enum", + "enum_type": "TabBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + }, + "multi_thickness_property": { + "path": "MultiThicknessProperty", + "getter": false, + "kind": "object", + "fields": { + "multi_thickness_toggle": { + "path": "MultiThicknessToggle", + "getter": false, + "kind": "boolean" + }, + "zone_name": { + "path": "ZoneName", + "getter": false, + "kind": "string" + } + } + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "target_body": { + "path": "TargetBody", + "getter": false, + "kind": "body", + "assign": true + }, + "thickness": { + "path": "Thickness", + "getter": false, + "kind": "expression" + }, + "thickness_side": { + "path": "ThicknessSide", + "getter": false, + "kind": "enum", + "enum_type": "TabBuilderThicknessSideOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "multi_bends": { + "kind": "object_list", + "container": "MultiBendPropertiesList", + "sequence": "FeatureBendPropertiesList", + "creator": "CreateFeatureProperties", + "fields": { + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "value": { + "path": "Value", + "getter": false, + "kind": "expression" + } + }, + "required": [] + } + }, + "required": [ + "section", + "thickness" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "100 x 80 mm rectangular tab, thickness 2 mm; successful save and close", + "edit_status": "experimental" + }, + "flange": { + "builder": "MultiFlangeBuilder", + "factory": "CreateMultiFlangeBuilder", + "fields": { + "match_face": { + "path": "MatchFace", + "getter": false, + "kind": "enum", + "enum_type": "MultiFlangeBuilderMatchFaceOptions", + "values": [ + "NotSet", + "UntilSelected" + ] + }, + "multi_thickness_property": { + "path": "MultiThicknessProperty", + "getter": false, + "kind": "object", + "fields": { + "multi_thickness_toggle": { + "path": "MultiThicknessToggle", + "getter": false, + "kind": "boolean" + }, + "zone_name": { + "path": "ZoneName", + "getter": false, + "kind": "string" + } + } + }, + "plane": { + "path": "Plane", + "getter": false, + "kind": "plane", + "assign": true + }, + "flanges": { + "kind": "flange_list", + "required": [ + "edges", + "length", + "angle" + ], + "fields": { + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "distance1": { + "path": "Distance1", + "getter": false, + "kind": "expression" + }, + "distance2": { + "path": "Distance2", + "getter": false, + "kind": "expression" + }, + "edges": { + "path": "Edges", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "inset": { + "path": "Inset", + "getter": false, + "kind": "enum", + "enum_type": "FlangeBendPropertiesBuilderInsets", + "values": [ + "MaterialInside", + "MaterialOutside", + "BendOutside", + "MaterialInsideOML" + ] + }, + "keypoint": { + "path": "Keypoint", + "getter": false, + "kind": "point", + "assign": true, + "description": "Work-part coordinate point for length_option=Keypoint; that mode is not verified by the numeric-length fixture." + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length_option": { + "path": "LengthOption", + "getter": false, + "kind": "enum", + "enum_type": "FlangeBendPropertiesBuilderLengthOptions", + "values": [ + "Value", + "Keypoint" + ], + "description": "Value uses length; Keypoint uses the supplied keypoint. This contract still requires length and angle in every flange entry. The recorded example verifies numeric length only." + }, + "length_reference": { + "path": "LengthReference", + "getter": false, + "kind": "enum", + "enum_type": "FlangeBendPropertiesBuilderLengthReferences", + "values": [ + "Inside", + "Outside", + "Web", + "Tangent" + ] + }, + "miter": { + "path": "Miter", + "getter": false, + "kind": "boolean" + }, + "offset": { + "path": "Offset", + "getter": false, + "kind": "expression" + }, + "point": { + "path": "Point", + "getter": false, + "kind": "point", + "assign": true + }, + "reverse_direction_length": { + "path": "ReverseDirectionLength", + "getter": false, + "kind": "boolean" + }, + "reverse_direction_offset": { + "path": "ReverseDirectionOffset", + "getter": false, + "kind": "boolean" + }, + "use_recipe": { + "path": "UseRecipe", + "getter": false, + "kind": "boolean" + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + }, + "width_option": { + "path": "WidthOption", + "getter": false, + "kind": "enum", + "enum_type": "FlangeBendPropertiesBuilderWidthOptions", + "values": [ + "Full", + "AtCenter", + "AtEnd", + "FromEnd", + "FromBothEnds" + ], + "description": "Full uses the selected edge width. The native AtCenter fixture supplies width=60 on a 100 mm edge. AtEnd/FromEnd/FromBothEnds positioning combinations are not verified by that fixture." + }, + "bend_options": { + "kind": "object", + "path": "BendOptions", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + }, + "description": "For explicit relief/radius/neutral-factor overrides, set the corresponding use_global_* flag false; otherwise the native global setting may remain active." + } + } + } + }, + "required": [ + "flanges" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "90-degree flange, radius and neutral factor read-back; length edited from 20 to 25 mm", + "edit_status": "tested_scoped", + "example_parameters": { + "flanges": [ + { + "edges": [ + "$input_1" + ], + "length": 20, + "length_reference": "Inside", + "angle": 90 + } + ] + }, + "example_evidence": { + "source": "examples/validate_sheet_metal.py", + "validation": "native_fixture", + "scope": "100 x 80 x 2 mm XY tab; edge nearest [50,0,0]; 20 mm Inside-length flange at 90 degrees. Numeric values are fixture millimeters." + }, + "prerequisites": [ + "Select a boundary edge of an existing sheet-metal tab, not an arbitrary solid edge. The recorded fixture uses the 100 mm edge at y=0, z=0 on a 100 x 80 x 2 mm tab.", + "Each flanges entry requires edges, length and angle, including when replacing the list during an edit. Supplying flanges replaces the complete native flange list.", + "For width_option=AtCenter, supply width; the native channel fixture used width=60 on a 100 mm edge. Other width modes require their native positioning inputs and are not covered by this example.", + "For length_option=Keypoint, supply keypoint in work-part coordinates as well as the contract-required length and angle. Keypoint mode is exposed but this example only verifies numeric length." + ] + }, + "contour_flange": { + "builder": "ContourFlangeBuilder", + "factory": "CreateContourFlangeFeatureBuilder", + "fields": { + "thickness": { + "path": "GetThickness", + "getter": true, + "kind": "expression" + }, + "sweep_distance": { + "path": "GetSweepDistance", + "getter": true, + "kind": "expression" + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "edge_chain": { + "path": "EdgeChain", + "getter": false, + "kind": "section", + "assign": true + }, + "is_secondary": { + "path": "IsSecondary", + "getter": false, + "kind": "boolean" + }, + "miter_options": { + "path": "MiterOptions", + "getter": false, + "kind": "object", + "fields": { + "start_value": { + "path": "GetStartValue", + "getter": true, + "kind": "expression" + }, + "end_value": { + "path": "GetEndValue", + "getter": true, + "kind": "expression" + }, + "closed_corner_diameter": { + "path": "GetClosedCornerDiameter", + "getter": true, + "kind": "expression" + }, + "blend_miter": { + "path": "BlendMiter", + "getter": false, + "kind": "boolean" + }, + "closed_corner_gap": { + "path": "ClosedCornerGap", + "getter": false, + "kind": "expression" + }, + "closed_corner_type": { + "path": "ClosedCornerType", + "getter": false, + "kind": "enum", + "enum_type": "MiterOptionsClosedCornerTypeOptions", + "values": [ + "NotSet", + "Open", + "Closed", + "CircularCutout", + "UCutout", + "VCutout" + ] + }, + "closed_cornerv_angle1": { + "path": "ClosedCornerVAngle1", + "getter": false, + "kind": "expression" + }, + "closed_cornerv_angle2": { + "path": "ClosedCornerVAngle2", + "getter": false, + "kind": "expression" + }, + "corner_treatment_offset": { + "path": "CornerTreatmentOffset", + "getter": false, + "kind": "expression" + }, + "corner_treatment_origin_type": { + "path": "CornerTreatmentOriginType", + "getter": false, + "kind": "enum", + "enum_type": "MiterOptionsCornerTreatmentOriginTypeOptions", + "values": [ + "BendCenter", + "CornerPoint" + ] + }, + "end_type": { + "path": "EndType", + "getter": false, + "kind": "enum", + "enum_type": "MiterOptionsTypeOptions", + "values": [ + "NormalToSourceFace", + "NormalToThicknessFace" + ] + }, + "miter_corner": { + "path": "MiterCorner", + "getter": false, + "kind": "boolean" + }, + "miter_interior_corners_if_necessary": { + "path": "MiterInteriorCornersIfNecessary", + "getter": false, + "kind": "boolean" + }, + "miter_root_radius": { + "path": "MiterRootRadius", + "getter": false, + "kind": "expression" + }, + "position": { + "path": "Position", + "getter": false, + "kind": "enum", + "enum_type": "MiterOptionsPositionOptions", + "values": [ + "NotSet", + "Start", + "End", + "Both" + ] + }, + "start_type": { + "path": "StartType", + "getter": false, + "kind": "enum", + "enum_type": "MiterOptionsTypeOptions", + "values": [ + "NormalToSourceFace", + "NormalToThicknessFace" + ] + }, + "three_bend_corner_flange_clearance": { + "path": "ThreeBendCornerFlangeClearance", + "getter": false, + "kind": "expression" + }, + "use_normal_cutout_method": { + "path": "UseNormalCutoutMethod", + "getter": false, + "kind": "boolean" + } + } + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "sweep_side": { + "path": "SweepSide", + "getter": false, + "kind": "enum", + "enum_type": "ContourFlangeBuilderSweepSideOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "sweep_type": { + "path": "SweepType", + "getter": false, + "kind": "enum", + "enum_type": "ContourFlangeBuilderSweepTypeOptions", + "values": [ + "Finite", + "Symmetric", + "ToEnd", + "Chain" + ] + }, + "thickness_side": { + "path": "ThicknessSide", + "getter": false, + "kind": "enum", + "enum_type": "ContourFlangeBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + } + }, + "required": [ + "section", + "thickness", + "sweep_distance" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "thickness": 2, + "sweep_distance": 40 + }, + "edit_status": "experimental" + }, + "lofted_flange": { + "builder": "LoftedFlangeBuilder", + "factory": "CreateLoftedFlangeFeatureBuilder", + "fields": { + "thickness": { + "path": "GetThickness", + "getter": true, + "kind": "expression" + }, + "auto_relief_type": { + "path": "AutoReliefType", + "getter": false, + "kind": "enum", + "enum_type": "LoftedFlangeBuilderAutoReliefTypes", + "values": [ + "Linear", + "Spherical" + ] + }, + "bend_divide_parameter": { + "path": "BendDivideParameter", + "getter": false, + "kind": "enum", + "enum_type": "LoftedFlangeBuilderBendDivideParameters", + "values": [ + "NumberOfBendSegments", + "MaximumChordHeight", + "MaximumSegmentLength", + "MaximumSegmentAngle" + ] + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "bending_method": { + "path": "BendingMethod", + "getter": false, + "kind": "enum", + "enum_type": "LoftedFlangeBuilderBendingMethods", + "values": [ + "Formed", + "Advanced", + "Bends" + ] + }, + "end_section": { + "path": "EndSection", + "getter": false, + "kind": "section", + "assign": true + }, + "end_section_point": { + "path": "EndSectionPoint", + "getter": false, + "kind": "point3d", + "assign": true + }, + "index_mark_length": { + "path": "IndexMarkLength", + "getter": false, + "kind": "expression" + }, + "is_secondary": { + "path": "IsSecondary", + "getter": false, + "kind": "boolean" + }, + "maximum_chord_height": { + "path": "MaximumChordHeight", + "getter": false, + "kind": "expression" + }, + "maximum_segment_angle": { + "path": "MaximumSegmentAngle", + "getter": false, + "kind": "expression" + }, + "maximum_segment_length": { + "path": "MaximumSegmentLength", + "getter": false, + "kind": "expression" + }, + "number_of_bend_segments": { + "path": "NumberOfBendSegments", + "getter": false, + "kind": "integer" + }, + "start_section": { + "path": "StartSection", + "getter": false, + "kind": "section", + "assign": true + }, + "start_section_point": { + "path": "StartSectionPoint", + "getter": false, + "kind": "point3d", + "assign": true + }, + "thickness_side": { + "path": "ThicknessSide", + "getter": false, + "kind": "enum", + "enum_type": "LoftedFlangeBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + }, + "trim_end_plates": { + "path": "TrimEndPlates", + "getter": false, + "kind": "boolean" + }, + "use_segmented_bends": { + "path": "UseSegmentedBends", + "getter": false, + "kind": "boolean" + } + }, + "required": [ + "start_section", + "end_section", + "thickness" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "start_section": "$input_1", + "end_section": "$input_2", + "thickness": 2, + "start_section_point": [ + 0, + 0, + 0 + ], + "end_section_point": [ + 0, + 0, + 40 + ], + "number_of_bend_segments": 8, + "bending_method": "Formed", + "use_segmented_bends": false + }, + "edit_status": "experimental" + }, + "bend": { + "builder": "BendBuilder", + "factory": "CreateBendFeatureBuilder", + "fields": { + "bend_angle": { + "path": "GetBendAngle", + "getter": true, + "kind": "expression" + }, + "bend_location": { + "path": "BendLocation", + "getter": false, + "kind": "enum", + "enum_type": "BendBuilderBendLocationOptions", + "values": [ + "OuterMoldLine", + "CenterLine", + "InnerMoldLine", + "MaterialInside", + "MaterialOutside" + ] + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "direction": { + "path": "Direction", + "getter": false, + "kind": "enum", + "enum_type": "BendBuilderBendDirectionOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "extend_profile": { + "path": "ExtendProfile", + "getter": false, + "kind": "boolean" + }, + "fixed_side": { + "path": "FixedSide", + "getter": false, + "kind": "enum", + "enum_type": "BendBuilderFixedSideOptions", + "values": [ + "SectionSideLeft", + "SectionSideRight" + ] + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "target_face": { + "path": "TargetFace", + "getter": false, + "kind": "face", + "assign": true + } + }, + "required": [ + "section", + "target_face", + "bend_angle" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "target_face": "$input_2", + "bend_angle": 90 + }, + "edit_status": "experimental" + }, + "jog": { + "builder": "JogBuilder", + "factory": "CreateJogFeatureBuilder", + "fields": { + "height": { + "path": "GetHeight", + "getter": true, + "kind": "expression" + }, + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "bend_location": { + "path": "BendLocation", + "getter": false, + "kind": "enum", + "enum_type": "JogBuilderBendLocationOptions", + "values": [ + "MaterialInside", + "MaterialOutside", + "BendOutside" + ] + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "dimension_type": { + "path": "DimensionType", + "getter": false, + "kind": "enum", + "enum_type": "JogBuilderDimensionTypeOptions", + "values": [ + "Offset", + "Full" + ] + }, + "direction_type": { + "path": "DirectionType", + "getter": false, + "kind": "enum", + "enum_type": "JogBuilderDirectionTypeOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "extend_profile": { + "path": "ExtendProfile", + "getter": false, + "kind": "boolean" + }, + "fixed_side": { + "path": "FixedSide", + "getter": false, + "kind": "enum", + "enum_type": "JogBuilderFixedSideOptions", + "values": [ + "SectionSideLeft", + "SectionSideRight" + ] + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "target_face": { + "path": "TargetFace", + "getter": false, + "kind": "face", + "assign": true + } + }, + "required": [ + "section", + "target_face", + "height" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "target_face": "$input_2", + "height": 10, + "angle": 90 + }, + "edit_status": "experimental" + }, + "hem": { + "builder": "HemFlangeBuilder", + "factory": "CreateHemFlangeFeatureBuilder", + "fields": { + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "HemFlangeBuilderBendReliefOptions", + "values": [ + "Square", + "Round", + "NotSet" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "edge_chain": { + "path": "EdgeChain", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "equal_radii": { + "path": "EqualRadii", + "getter": false, + "kind": "boolean" + }, + "first_bend_radius": { + "path": "FirstBendRadius", + "getter": false, + "kind": "expression" + }, + "first_flange_length": { + "path": "FirstFlangeLength", + "getter": false, + "kind": "expression" + }, + "inset_type": { + "path": "InsetType", + "getter": false, + "kind": "enum", + "enum_type": "HemFlangeBuilderInsetTypeOptions", + "values": [ + "MaterialInside", + "MaterialOutside", + "BendOutside" + ] + }, + "miter_angle": { + "path": "MiterAngle", + "getter": false, + "kind": "expression" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "second_bend_radius": { + "path": "SecondBendRadius", + "getter": false, + "kind": "expression" + }, + "second_flange_length": { + "path": "SecondFlangeLength", + "getter": false, + "kind": "expression" + }, + "sweep_angle": { + "path": "SweepAngle", + "getter": false, + "kind": "expression" + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "HemFlangeBuilderTypeOptions", + "values": [ + "ClosedHemType", + "OpenHemType", + "SFlangeHemType", + "CurlHemType", + "OpenLoopHemType", + "ClosedLoopHemType", + "CenteredLoopHemType" + ] + }, + "use_miter": { + "path": "UseMiter", + "getter": false, + "kind": "boolean" + } + }, + "required": [ + "edge_chain" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "edge_chain": [ + "$input_1" + ], + "type": "OpenHemType", + "first_flange_length": 10, + "first_bend_radius": 2 + }, + "edit_status": "experimental" + }, + "normal_cutout": { + "builder": "NormalCutoutBuilder", + "factory": "CreateNormalCutoutFeatureBuilder", + "fields": { + "cut_type": { + "path": "CutType", + "getter": false, + "kind": "enum", + "enum_type": "NormalCutoutBuilderCutTypeOptions", + "values": [ + "ThicknessCut", + "MidPlaneCut", + "NearestFaceCut" + ] + }, + "depth": { + "path": "Depth", + "getter": false, + "kind": "expression" + }, + "depth_side": { + "path": "DepthSide", + "getter": false, + "kind": "enum", + "enum_type": "NormalCutoutBuilderDepthSideOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide", + "Symmetric" + ] + }, + "depth_type": { + "path": "DepthType", + "getter": false, + "kind": "enum", + "enum_type": "NormalCutoutBuilderDepthTypeOptions", + "values": [ + "Finite", + "FromTo", + "ThroughNext", + "ThroughAll" + ] + }, + "from": { + "path": "From", + "getter": false, + "kind": "face", + "assign": true + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "section_side": { + "path": "SectionSide", + "getter": false, + "kind": "enum", + "enum_type": "NormalCutoutBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + }, + "target_body": { + "path": "TargetBody", + "getter": false, + "kind": "body", + "assign": true + }, + "to": { + "path": "To", + "getter": false, + "kind": "face", + "assign": true + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "NormalCutoutBuilderTypeOptions", + "values": [ + "SketchType", + "NonPlanarCurveType" + ] + } + }, + "required": [ + "section", + "target_body" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "target_body": "$input_2", + "depth_type": "ThroughAll", + "depth_side": "Symmetric", + "depth": 5 + }, + "edit_status": "experimental" + }, + "bead": { + "builder": "BeadBuilder", + "factory": "CreateBeadFeatureBuilder", + "fields": { + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "cross_section_type": { + "path": "CrossSectionType", + "getter": false, + "kind": "enum", + "enum_type": "BeadBuilderCrossSectionTypeOptions", + "values": [ + "Circular", + "Ushaped", + "Vshaped" + ] + }, + "die_radius": { + "path": "DieRadius", + "getter": false, + "kind": "expression" + }, + "end_type": { + "path": "EndType", + "getter": false, + "kind": "enum", + "enum_type": "BeadBuilderEndTypeOptions", + "values": [ + "Punched", + "Lanced", + "Formed", + "Tapered" + ] + }, + "height": { + "path": "Height", + "getter": false, + "kind": "expression" + }, + "height_side": { + "path": "HeightSide", + "getter": false, + "kind": "enum", + "enum_type": "BeadBuilderHeightSideOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "include_rounding": { + "path": "IncludeRounding", + "getter": false, + "kind": "boolean" + }, + "minimum_tool_clearance": { + "path": "MinimumToolClearance", + "getter": false, + "kind": "expression" + }, + "punch_radius": { + "path": "PunchRadius", + "getter": false, + "kind": "expression" + }, + "punched_width": { + "path": "PunchedWidth", + "getter": false, + "kind": "expression" + }, + "radius": { + "path": "Radius", + "getter": false, + "kind": "expression" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "taper_distance": { + "path": "TaperDistance", + "getter": false, + "kind": "expression" + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + } + }, + "required": [ + "section" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "height": 3, + "width": 8, + "cross_section_type": "Ushaped", + "angle": 45, + "end_type": "Formed", + "punched_width": 8, + "radius": 3, + "punch_radius": 2, + "die_radius": 2, + "taper_distance": 5 + }, + "edit_status": "experimental" + }, + "dimple": { + "builder": "DimpleBuilder", + "factory": "CreateDimpleFeatureBuilder", + "fields": { + "depth": { + "path": "GetDepth", + "getter": true, + "kind": "expression" + }, + "taper_angle": { + "path": "GetTaperAngle", + "getter": true, + "kind": "expression" + }, + "punch_radius": { + "path": "GetPunchRadius", + "getter": true, + "kind": "expression" + }, + "die_radius": { + "path": "GetDieRadius", + "getter": true, + "kind": "expression" + }, + "fillet_radius": { + "path": "GetFilletRadius", + "getter": true, + "kind": "expression" + }, + "depth_type": { + "path": "DepthType", + "getter": false, + "kind": "enum", + "enum_type": "DimpleBuilderDepthTypeOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "dimension_type": { + "path": "DimensionType", + "getter": false, + "kind": "enum", + "enum_type": "DimpleBuilderDimensionTypeOptions", + "values": [ + "Offset", + "Full" + ] + }, + "fillet_section_corners": { + "path": "FilletSectionCorners", + "getter": false, + "kind": "boolean" + }, + "include_rounding": { + "path": "IncludeRounding", + "getter": false, + "kind": "boolean" + }, + "minimum_tool_clearance": { + "path": "MinimumToolClearance", + "getter": false, + "kind": "expression" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "section_side": { + "path": "SectionSide", + "getter": false, + "kind": "enum", + "enum_type": "DimpleBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + }, + "sidewall_type": { + "path": "SidewallType", + "getter": false, + "kind": "enum", + "enum_type": "DimpleBuilderSidewallTypeOptions", + "values": [ + "Outside", + "Inside" + ] + } + }, + "required": [ + "section" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "depth": 3, + "taper_angle": 15, + "include_rounding": false + }, + "edit_status": "experimental" + }, + "louver": { + "builder": "LouverBuilder", + "factory": "CreateLouverFeatureBuilder", + "fields": { + "depth": { + "path": "Depth", + "getter": false, + "kind": "expression" + }, + "depth_side": { + "path": "DepthSide", + "getter": false, + "kind": "enum", + "enum_type": "LouverBuilderDepthSideOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "die_radius": { + "path": "DieRadius", + "getter": false, + "kind": "expression" + }, + "end_type": { + "path": "EndType", + "getter": false, + "kind": "enum", + "enum_type": "LouverBuilderEndTypeOptions", + "values": [ + "Formed", + "Lanced" + ] + }, + "include_rounding": { + "path": "IncludeRounding", + "getter": false, + "kind": "boolean" + }, + "minimum_tool_clearance": { + "path": "MinimumToolClearance", + "getter": false, + "kind": "expression" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "section_side": { + "path": "SectionSide", + "getter": false, + "kind": "enum", + "enum_type": "LouverBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + } + }, + "required": [ + "section" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "depth": 3, + "width": 12, + "end_type": "Lanced", + "include_rounding": false, + "depth_side": "SectionNormalSide", + "section_side": "Left", + "minimum_tool_clearance": 0.1 + }, + "edit_status": "experimental" + }, + "drawn_cutout": { + "builder": "DrawnCutoutBuilder", + "factory": "CreateDrawnCutoutFeatureBuilder", + "fields": { + "corner_radius": { + "path": "CornerRadius", + "getter": false, + "kind": "expression" + }, + "cutout_depth": { + "path": "CutoutDepth", + "getter": false, + "kind": "expression" + }, + "depth_type": { + "path": "DepthType", + "getter": false, + "kind": "enum", + "enum_type": "DrawnCutoutBuilderDepthTypeOptions", + "values": [ + "SectionNormalSide", + "SectionReverseNormalSide" + ] + }, + "fillet_section_corners": { + "path": "FilletSectionCorners", + "getter": false, + "kind": "boolean" + }, + "include_rounding": { + "path": "IncludeRounding", + "getter": false, + "kind": "boolean" + }, + "minimum_tool_clearance": { + "path": "MinimumToolClearance", + "getter": false, + "kind": "expression" + }, + "radius_of_die": { + "path": "RadiusOfDie", + "getter": false, + "kind": "expression" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "section_side": { + "path": "SectionSide", + "getter": false, + "kind": "enum", + "enum_type": "DrawnCutoutBuilderSectionSideOptions", + "values": [ + "Left", + "Right" + ] + }, + "side_angle": { + "path": "SideAngle", + "getter": false, + "kind": "expression" + }, + "sidewall_type": { + "path": "SidewallType", + "getter": false, + "kind": "enum", + "enum_type": "DrawnCutoutBuilderSidewallTypeOptions", + "values": [ + "Outside", + "Inside" + ] + } + }, + "required": [ + "section" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "cutout_depth": 5, + "side_angle": 60, + "include_rounding": false + }, + "edit_status": "experimental" + }, + "gusset": { + "builder": "GussetBuilder", + "factory": "CreateGussetBuilder", + "fields": { + "bend_face": { + "path": "BendFace", + "getter": false, + "kind": "select_face", + "assign": false + }, + "corner_radius": { + "path": "CornerRadius", + "getter": false, + "kind": "expression" + }, + "datum_plane": { + "path": "DatumPlane", + "getter": false, + "kind": "plane", + "assign": true + }, + "depth": { + "path": "Depth", + "getter": false, + "kind": "expression" + }, + "die_radius": { + "path": "DieRadius", + "getter": false, + "kind": "expression" + }, + "placement_count": { + "path": "PlacementCount", + "getter": false, + "kind": "integer" + }, + "placement_distance": { + "path": "PlacementDistance", + "getter": false, + "kind": "expression" + }, + "placement_spacing": { + "path": "PlacementSpacing", + "getter": false, + "kind": "expression" + }, + "placement_type": { + "path": "PlacementType", + "getter": false, + "kind": "enum", + "enum_type": "GussetBuilderPlacementTypes", + "values": [ + "Single", + "Fit", + "Fill", + "Fixed" + ] + }, + "punch_radius": { + "path": "PunchRadius", + "getter": false, + "kind": "expression" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": false + }, + "shape": { + "path": "Shape", + "getter": false, + "kind": "enum", + "enum_type": "GussetBuilderShapes", + "values": [ + "Square", + "Round" + ] + }, + "side_angle": { + "path": "SideAngle", + "getter": false, + "kind": "expression" + }, + "start_edge": { + "path": "StartEdge", + "getter": false, + "kind": "select_edge", + "assign": false + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "GussetBuilderTypes", + "values": [ + "AutomaticProfile", + "UserDefinedProfile" + ] + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + }, + "width_side": { + "path": "WidthSide", + "getter": false, + "kind": "enum", + "enum_type": "GussetBuilderWidthSides", + "values": [ + "Side1", + "Side2", + "Symmetric" + ] + } + }, + "required": [ + "bend_face" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "bend_face": "$input_1", + "type": "AutomaticProfile", + "datum_plane": { + "origin": [ + 50, + 0, + 0 + ], + "normal": [ + 1, + 0, + 0 + ] + }, + "width": 10, + "depth": 6, + "side_angle": 45, + "punch_radius": 1, + "die_radius": 1 + }, + "edit_status": "experimental" + }, + "edge_rip": { + "builder": "EdgeRipBuilder", + "factory": "CreateEdgeRipFeatureBuilder", + "fields": { + "blend_radius": { + "path": "BlendRadius", + "getter": false, + "kind": "expression" + }, + "blend_sharp_corners": { + "path": "BlendSharpCorners", + "getter": false, + "kind": "boolean" + }, + "end_cap_shape": { + "path": "EndCapShape", + "getter": false, + "kind": "enum", + "enum_type": "EdgeRipBuilderEndCapShapeOptions", + "values": [ + "Square", + "Round" + ] + }, + "reverse_width_direction": { + "path": "ReverseWidthDirection", + "getter": false, + "kind": "boolean" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "symmetric": { + "path": "Symmetric", + "getter": false, + "kind": "boolean" + }, + "use_system_width": { + "path": "UseSystemWidth", + "getter": false, + "kind": "boolean" + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + }, + "rip_edges": { + "kind": "reference_list", + "objects": "edge", + "method": "SetRipEdges" + } + }, + "required": [], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "section": "$input_1", + "width": 0.5, + "symmetric": true, + "use_system_width": false, + "end_cap_shape": "Round" + }, + "edit_status": "experimental" + }, + "break_corner": { + "builder": "BreakCornerBuilder", + "factory": "CreateBreakCornerFeatureBuilder", + "fields": { + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "BreakCornerBuilderTypeOptions", + "values": [ + "Fillet", + "ChamferEqualSetback" + ] + }, + "value": { + "path": "Value", + "getter": false, + "kind": "expression" + }, + "edges": { + "kind": "reference_list", + "objects": "edge", + "method": "SetEdges" + }, + "faces": { + "kind": "reference_list", + "objects": "face", + "method": "SetFaces" + } + }, + "required": [], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "edges": [ + "$input_1" + ], + "type": "Fillet", + "value": 3 + }, + "edit_status": "experimental" + }, + "closed_corner": { + "builder": "ClosedCornerBuilder", + "factory": "CreateClosedCornerFeatureBuilder", + "fields": { + "blend_miter": { + "path": "BlendMiter", + "getter": false, + "kind": "boolean" + }, + "blend_miter_radius": { + "path": "BlendMiterRadius", + "getter": false, + "kind": "expression" + }, + "cut_method": { + "path": "CutMethod", + "getter": false, + "kind": "enum", + "enum_type": "ClosedCornerBuilderCutMethodTypes", + "values": [ + "ByTool", + "ByPath" + ] + }, + "diameter": { + "path": "Diameter", + "getter": false, + "kind": "expression" + }, + "gap": { + "path": "Gap", + "getter": false, + "kind": "expression" + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length1": { + "path": "Length1", + "getter": false, + "kind": "expression" + }, + "length2": { + "path": "Length2", + "getter": false, + "kind": "expression" + }, + "limit_edge1": { + "path": "LimitEdge1", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "limit_edge2": { + "path": "LimitEdge2", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "miter_corner": { + "path": "MiterCorner", + "getter": false, + "kind": "boolean" + }, + "offset": { + "path": "Offset", + "getter": false, + "kind": "expression" + }, + "origin": { + "path": "Origin", + "getter": false, + "kind": "enum", + "enum_type": "ClosedCornerBuilderOriginTypes", + "values": [ + "BendCenter", + "CornerPoint" + ] + }, + "overlap": { + "path": "Overlap", + "getter": false, + "kind": "expression" + }, + "overlap_type": { + "path": "OverlapType", + "getter": false, + "kind": "enum", + "enum_type": "ClosedCornerBuilderOverlapTypeOptions", + "values": [ + "NotSet", + "Side1", + "Side2" + ] + }, + "punch_radius": { + "path": "PunchRadius", + "getter": false, + "kind": "expression" + }, + "rectangular_length": { + "path": "RectangularLength", + "getter": false, + "kind": "expression" + }, + "rectangular_width": { + "path": "RectangularWidth", + "getter": false, + "kind": "expression" + }, + "treatment_type": { + "path": "TreatmentType", + "getter": false, + "kind": "enum", + "enum_type": "ClosedCornerBuilderTreatmentTypeOptions", + "values": [ + "Open", + "Closed", + "CircularCutout", + "UCutout", + "VCutout", + "RectangularCutout" + ] + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "ClosedCornerBuilderTypes", + "values": [ + "CloseAndRelief", + "Relief" + ] + }, + "v_angle1": { + "path": "VAngle1", + "getter": false, + "kind": "expression" + }, + "v_angle2": { + "path": "VAngle2", + "getter": false, + "kind": "expression" + }, + "face_pairs": { + "kind": "face_pairs" + } + }, + "required": [ + "face_pairs" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "face_pairs": [ + [ + "$input_1", + "$input_2" + ] + ], + "gap": 0.5, + "overlap_type": "NotSet", + "treatment_type": "CircularCutout", + "diameter": 6 + }, + "edit_status": "experimental" + }, + "three_bend_corner": { + "builder": "ThreeBendCornerBuilder", + "factory": "CreateThreeBendCornerFeatureBuilder", + "fields": { + "blend_miter": { + "path": "BlendMiter", + "getter": false, + "kind": "boolean" + }, + "blend_miter_radius": { + "path": "BlendMiterRadius", + "getter": false, + "kind": "expression" + }, + "corner_gap": { + "path": "CornerGap", + "getter": false, + "kind": "expression" + }, + "cut_method": { + "path": "CutMethod", + "getter": false, + "kind": "enum", + "enum_type": "ThreeBendCornerBuilderCutMethodTypes", + "values": [ + "ByTool", + "ByPath" + ] + }, + "diameter": { + "path": "Diameter", + "getter": false, + "kind": "expression" + }, + "flange_clearance": { + "path": "FlangeClearance", + "getter": false, + "kind": "expression" + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length1": { + "path": "Length1", + "getter": false, + "kind": "expression" + }, + "length2": { + "path": "Length2", + "getter": false, + "kind": "expression" + }, + "miter_corner": { + "path": "MiterCorner", + "getter": false, + "kind": "boolean" + }, + "miter_root_radius": { + "path": "MiterRootRadius", + "getter": false, + "kind": "expression" + }, + "offset": { + "path": "Offset", + "getter": false, + "kind": "expression" + }, + "origin_type": { + "path": "OriginType", + "getter": false, + "kind": "enum", + "enum_type": "ThreeBendCornerBuilderOriginTypes", + "values": [ + "BendCenter", + "CornerPoint" + ] + }, + "punch_radius": { + "path": "PunchRadius", + "getter": false, + "kind": "expression" + }, + "treatment_type": { + "path": "TreatmentType", + "getter": false, + "kind": "enum", + "enum_type": "ThreeBendCornerBuilderTreatmentTypeOptions", + "values": [ + "Open", + "Closed", + "CircularCutout", + "UCutout", + "VCutout" + ] + }, + "v_cutout_angle1": { + "path": "VCutoutAngle1", + "getter": false, + "kind": "expression" + }, + "v_cutout_angle2": { + "path": "VCutoutAngle2", + "getter": false, + "kind": "expression" + }, + "face_pairs": { + "kind": "face_pairs" + } + }, + "required": [ + "face_pairs" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "face_pairs": [ + [ + "$input_1", + "$input_2" + ] + ], + "corner_gap": 1, + "flange_clearance": 1, + "treatment_type": "Open", + "diameter": 4 + }, + "edit_status": "experimental" + }, + "bridge_bend": { + "builder": "BridgeTransitionBuilder", + "factory": "CreateBridgeTransitionBuilder", + "fields": { + "alternate_solution": { + "path": "AlternateSolution", + "getter": false, + "kind": "boolean" + }, + "bend_options_of_flex_transition": { + "path": "BendOptionsOfFlexTransition", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "bend_options_of_sketched_transition": { + "path": "BendOptionsOfSketchedTransition", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "end_edge": { + "path": "EndEdge", + "getter": false, + "kind": "select_edge", + "assign": false + }, + "end_edge_lateral_offset": { + "path": "EndEdgeLateralOffset", + "getter": false, + "kind": "expression" + }, + "flex_bend_divide_parameter_type": { + "path": "FlexBendDivideParameterType", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderFlexBendDivideParameterOptions", + "values": [ + "BendsSegments", + "MaximumChordHeight", + "MaximumSegmentLength", + "MaximumSegmentAngle" + ] + }, + "flex_bending_method_type": { + "path": "FlexBendingMethodType", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderFlexBendingMethodOptions", + "values": [ + "Formed", + "Bends" + ] + }, + "flex_endpoint": { + "path": "FlexEndpoint", + "getter": false, + "kind": "point", + "assign": true + }, + "flex_length": { + "path": "FlexLength", + "getter": false, + "kind": "expression" + }, + "flex_length_type": { + "path": "FlexLengthType", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderFlexLengthOptions", + "values": [ + "ScaleFactor", + "Value" + ] + }, + "flex_maximum_chord_height": { + "path": "FlexMaximumChordHeight", + "getter": false, + "kind": "expression" + }, + "flex_maximum_segment_angle": { + "path": "FlexMaximumSegmentAngle", + "getter": false, + "kind": "expression" + }, + "flex_maximum_segment_length": { + "path": "FlexMaximumSegmentLength", + "getter": false, + "kind": "expression" + }, + "flex_number_of_bend_segments": { + "path": "FlexNumberOfBendSegments", + "getter": false, + "kind": "integer" + }, + "fold_bend_options": { + "path": "FoldBendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "fold_transition_type": { + "path": "FoldTransitionType", + "getter": false, + "kind": "integer" + }, + "inset_type": { + "path": "InsetType", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderInsetOptions", + "values": [ + "MaterialInside", + "MaterialOutside" + ] + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length_tolerance": { + "path": "LengthTolerance", + "getter": false, + "kind": "number" + }, + "multi_thickness_property": { + "path": "MultiThicknessProperty", + "getter": false, + "kind": "object", + "fields": { + "multi_thickness_toggle": { + "path": "MultiThicknessToggle", + "getter": false, + "kind": "boolean" + }, + "zone_name": { + "path": "ZoneName", + "getter": false, + "kind": "string" + } + } + }, + "plane": { + "path": "Plane", + "getter": false, + "kind": "select_face", + "assign": false + }, + "point": { + "path": "Point", + "getter": false, + "kind": "point", + "assign": true + }, + "reference_geometry_plane": { + "path": "ReferenceGeometryPlane", + "getter": false, + "kind": "plane", + "assign": true + }, + "scale_factor": { + "path": "ScaleFactor", + "getter": false, + "kind": "number" + }, + "section": { + "path": "Section", + "getter": false, + "kind": "section", + "assign": true + }, + "smart_divide": { + "path": "SmartDivide", + "getter": false, + "kind": "boolean" + }, + "start_and_end_parameters_equal": { + "path": "StartAndEndParametersEqual", + "getter": false, + "kind": "boolean" + }, + "start_edge": { + "path": "StartEdge", + "getter": false, + "kind": "select_edge", + "assign": false + }, + "start_edge_lateral_offset": { + "path": "StartEdgeLateralOffset", + "getter": false, + "kind": "expression" + }, + "trim_or_extend_to_bend": { + "path": "TrimOrExtendToBend", + "getter": false, + "kind": "boolean" + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderTypeOptions", + "values": [ + "Zu", + "Fold", + "Flex", + "Sketched" + ] + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + }, + "width_direction": { + "path": "WidthDirection", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderWidthDirectionOptions", + "values": [ + "Left", + "Right" + ] + }, + "width_type": { + "path": "WidthType", + "getter": false, + "kind": "enum", + "enum_type": "BridgeTransitionBuilderWidthOptions", + "values": [ + "Finite", + "Symmetric", + "FullStartEdge", + "FullEndEdge", + "FullBothEdges" + ] + }, + "zu_end_edge_bend_options": { + "path": "ZuEndEdgeBendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "zu_start_edge_bend_options": { + "path": "ZuStartEdgeBendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + } + }, + "required": [ + "start_edge", + "end_edge" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "start_edge": "$input_1", + "end_edge": "$input_2", + "type": "Zu", + "width_type": "FullBothEdges", + "length": 20, + "width": 80 + }, + "edit_status": "experimental" + }, + "bend_taper": { + "builder": "BendTaperBuilder", + "factory": "CreateBendTaperBuilder", + "fields": { + "bend_taper_angle1": { + "path": "BendTaperAngle1", + "getter": false, + "kind": "expression" + }, + "bend_taper_angle2": { + "path": "BendTaperAngle2", + "getter": false, + "kind": "expression" + }, + "bend_taper_input_method1": { + "path": "BendTaperInputMethod1", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderBendTaperInputMethod", + "values": [ + "Angle", + "Distance", + "ToEnd" + ] + }, + "bend_taper_input_method2": { + "path": "BendTaperInputMethod2", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderBendTaperInputMethod", + "values": [ + "Angle", + "Distance", + "ToEnd" + ] + }, + "bend_taper_select_bend_face": { + "path": "BendTaperSelectBendFace", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "bend_taper_type1": { + "path": "BendTaperType1", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderBendTaperType", + "values": [ + "Linear", + "Tangent", + "Square" + ] + }, + "bend_taper_type2": { + "path": "BendTaperType2", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderBendTaperType", + "values": [ + "Linear", + "Tangent", + "Square" + ] + }, + "end_radius1": { + "path": "EndRadius1", + "getter": false, + "kind": "expression" + }, + "end_radius2": { + "path": "EndRadius2", + "getter": false, + "kind": "expression" + }, + "infer_radius1": { + "path": "InferRadius1", + "getter": false, + "kind": "boolean" + }, + "infer_radius2": { + "path": "InferRadius2", + "getter": false, + "kind": "boolean" + }, + "start_radius1": { + "path": "StartRadius1", + "getter": false, + "kind": "expression" + }, + "start_radius2": { + "path": "StartRadius2", + "getter": false, + "kind": "expression" + }, + "start_type1": { + "path": "StartType1", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderStartType", + "values": [ + "TaperFromBend", + "TaperFromWeb" + ] + }, + "start_type2": { + "path": "StartType2", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderStartType", + "values": [ + "TaperFromBend", + "TaperFromWeb" + ] + }, + "stationary_entity": { + "path": "StationaryEntity", + "getter": false, + "kind": "face_or_edge", + "assign": true + }, + "taper_distance1": { + "path": "TaperDistance1", + "getter": false, + "kind": "expression" + }, + "taper_distance2": { + "path": "TaperDistance2", + "getter": false, + "kind": "expression" + }, + "taper_sides": { + "path": "TaperSides", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderBendTaperSides", + "values": [ + "Both", + "Side1", + "Side2", + "Symmetric" + ] + }, + "web_taper_angle1": { + "path": "WebTaperAngle1", + "getter": false, + "kind": "expression" + }, + "web_taper_angle2": { + "path": "WebTaperAngle2", + "getter": false, + "kind": "expression" + }, + "web_taper_type1": { + "path": "WebTaperType1", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderWebTaperType", + "values": [ + "NotSet", + "Face", + "FaceChain" + ] + }, + "web_taper_type2": { + "path": "WebTaperType2", + "getter": false, + "kind": "enum", + "enum_type": "BendTaperBuilderWebTaperType", + "values": [ + "NotSet", + "Face", + "FaceChain" + ] + } + }, + "required": [ + "bend_taper_select_bend_face", + "stationary_entity" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "bend_taper_select_bend_face": [ + "$input_1" + ], + "stationary_entity": "$input_2", + "bend_taper_input_method1": "Distance", + "bend_taper_input_method2": "Distance", + "taper_distance1": 5, + "taper_distance2": 5, + "taper_sides": "Both" + }, + "edit_status": "experimental" + }, + "resize_bend_angle": { + "builder": "ResizeBendAngleBuilder", + "factory": "CreateResizeBendAngleBuilder", + "fields": { + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "bend_face": { + "path": "BendFace", + "getter": false, + "kind": "select_face", + "assign": false + }, + "keep_radius_fixed": { + "path": "KeepRadiusFixed", + "getter": false, + "kind": "boolean" + }, + "reference_edge": { + "path": "ReferenceEdge", + "getter": false, + "kind": "select_edge", + "assign": false + }, + "reference_face": { + "path": "ReferenceFace", + "getter": false, + "kind": "select_face", + "assign": false + } + }, + "required": [ + "bend_face", + "angle", + "reference_face" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "bend_face": "$input_1", + "angle": 110, + "reference_face": "$input_2" + }, + "edit_status": "experimental" + }, + "resize_bend_radius": { + "builder": "ResizeBendRadiusBuilder", + "factory": "CreateResizeBendRadiusFeatureBuilder", + "fields": { + "bend_faces": { + "path": "BendFaces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "reference_entity": { + "path": "ReferenceEntity", + "getter": false, + "kind": "select_face_or_edge", + "assign": false + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "ResizeBendRadiusBuilderTypes", + "values": [ + "FixedFoldedLength", + "FixedUnfoldedLength" + ] + } + }, + "required": [ + "bend_faces", + "bend_radius", + "reference_entity" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "bend_faces": [ + "$input_1" + ], + "bend_radius": 3, + "reference_entity": "$input_2" + }, + "edit_status": "experimental" + }, + "resize_neutral_factor": { + "builder": "ResizeNeutralFactorBuilder", + "factory": "CreateResizeNeutralFactorBuilder", + "fields": { + "bend_faces": { + "path": "BendFaces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "use_global": { + "path": "UseGlobal", + "getter": false, + "kind": "boolean" + } + }, + "required": [ + "bend_faces", + "neutral_factor" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "bend_faces": [ + "$input_1" + ], + "neutral_factor": 0.4 + }, + "edit_status": "experimental" + }, + "convert": { + "builder": "ConvertToSheetmetalBuilder", + "factory": "CreateConvertToSheetmetalFeatureBuilder", + "fields": { + "additional_faces_to_convert": { + "path": "AdditionalFacesToConvert", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "base_face": { + "path": "BaseFace", + "getter": false, + "kind": "face", + "assign": true + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "ConvertToSheetmetalBuilderBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "is_uniform_thickness": { + "path": "IsUniformThickness", + "getter": false, + "kind": "boolean" + }, + "local_base_face": { + "path": "LocalBaseFace", + "getter": false, + "kind": "face", + "assign": true + }, + "local_region_faces": { + "path": "LocalRegionFaces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "maintain_zero_bend_radius": { + "path": "MaintainZeroBendRadius", + "getter": false, + "kind": "boolean" + }, + "rip_section": { + "path": "RipSection", + "getter": false, + "kind": "section", + "assign": true + }, + "rip_edges": { + "kind": "reference_list", + "objects": "edge", + "method": "SetRipEdges" + }, + "corners": { + "kind": "object_list", + "container": null, + "sequence": "CornerList", + "creator": "CreateConvertInputListItem", + "fields": { + "corner_faces": { + "path": "CornerFaces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + } + }, + "required": [ + "corner_faces" + ] + } + }, + "required": [ + "base_face" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "base_face": "$input_1", + "is_uniform_thickness": true + }, + "edit_status": "experimental" + }, + "from_solid": { + "builder": "SheetMetalFromSolidBuilder", + "factory": "CreateSheetMetalFromSolidBuilder", + "fields": { + "bend_edges": { + "path": "BendEdges", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "hide_original": { + "path": "HideOriginal", + "getter": false, + "kind": "boolean" + }, + "reverse_direction": { + "path": "ReverseDirection", + "getter": false, + "kind": "boolean" + }, + "thickness": { + "path": "Thickness", + "getter": false, + "kind": "expression" + }, + "thickness_tolerance": { + "path": "ThicknessTolerance", + "getter": false, + "kind": "expression" + }, + "use_global_thickness": { + "path": "UseGlobalThickness", + "getter": false, + "kind": "boolean" + }, + "web_faces": { + "path": "WebFaces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "bend_properties": { + "kind": "object_list", + "container": null, + "sequence": "BendPropertiesList", + "creator": "CreateSheetMetalFromSolidBendProperties", + "fields": { + "bend_edges": { + "path": "BendEdges", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + } + }, + "required": [ + "bend_edges" + ] + } + }, + "required": [ + "web_faces", + "thickness" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "web_faces": [ + "$input_1", + "$input_2" + ], + "bend_properties": [ + { + "bend_edges": [ + "$input_3" + ], + "bend_options": { + "bend_radius": 2, + "use_global_bend_radius": false + } + } + ], + "thickness": 2, + "use_global_thickness": false, + "hide_original": true + }, + "edit_status": "experimental" + }, + "flat_solid": { + "builder": "FlatSolidBuilder", + "factory": "CreateFlatSolidFeatureBuilder", + "fields": { + "added_geometry": { + "path": "AddedGeometry", + "getter": false, + "kind": "section", + "assign": false + }, + "associative": { + "path": "Associative", + "getter": false, + "kind": "boolean" + }, + "auto_assign_component_layer": { + "path": "AutoAssignComponentLayer", + "getter": false, + "kind": "boolean" + }, + "auto_associate_objects": { + "path": "AutoAssociateObjects", + "getter": false, + "kind": "boolean" + }, + "fix_at_timestamp": { + "path": "FixAtTimestamp", + "getter": false, + "kind": "boolean" + }, + "flat_body_color": { + "path": "FlatBodyColor", + "getter": false, + "kind": "integer" + }, + "flat_body_layer": { + "path": "FlatBodyLayer", + "getter": false, + "kind": "integer" + }, + "inner_corner_treatment": { + "path": "InnerCornerTreatment", + "getter": false, + "kind": "object", + "fields": { + "treatment_type": { + "path": "TreatmentType", + "getter": false, + "kind": "enum", + "enum_type": "CornerTreatmentBuilderCornerTreatmentType", + "values": [ + "NotSet", + "Chamfer", + "Radius" + ] + }, + "use_global": { + "path": "UseGlobal", + "getter": false, + "kind": "boolean" + }, + "value": { + "path": "Value", + "getter": false, + "kind": "expression" + } + } + }, + "layer_setting": { + "path": "LayerSetting", + "getter": false, + "kind": "enum", + "enum_type": "FlatSolidBuilderLayerSettingOption", + "values": [ + "Default", + "Preference", + "Specify" + ] + }, + "orientation": { + "path": "Orientation", + "getter": false, + "kind": "enum", + "enum_type": "FlatSolidBuilderOrientationType", + "values": [ + "Default", + "Edge", + "Csys" + ] + }, + "orientation_csys": { + "path": "OrientationCsys", + "getter": false, + "kind": "csys", + "assign": true + }, + "orientation_from_csys": { + "path": "OrientationFromCsys", + "getter": false, + "kind": "csys", + "assign": true + }, + "outer_corner_treatment": { + "path": "OuterCornerTreatment", + "getter": false, + "kind": "object", + "fields": { + "treatment_type": { + "path": "TreatmentType", + "getter": false, + "kind": "enum", + "enum_type": "CornerTreatmentBuilderCornerTreatmentType", + "values": [ + "NotSet", + "Chamfer", + "Radius" + ] + }, + "use_global": { + "path": "UseGlobal", + "getter": false, + "kind": "boolean" + }, + "value": { + "path": "Value", + "getter": false, + "kind": "expression" + } + } + }, + "reference_vertex": { + "path": "ReferenceVertex", + "getter": false, + "kind": "point3d", + "assign": true + }, + "stationary_face": { + "path": "StationaryFace", + "getter": false, + "kind": "select_face", + "assign": false + }, + "transform_components": { + "path": "TransformComponents", + "getter": false, + "kind": "enum", + "enum_type": "FlatSolidBuilderTransformComponentsOption", + "values": [ + "NotSet", + "Body", + "Csys", + "BodyAndComponent" + ] + }, + "transform_pin_andcc_p": { + "path": "TransformPinAndCCP", + "getter": false, + "kind": "boolean" + }, + "transform_restriction_areas": { + "path": "TransformRestrictionAreas", + "getter": false, + "kind": "boolean" + }, + "transform_to_absolute_csys": { + "path": "TransformToAbsoluteCsys", + "getter": false, + "kind": "boolean" + }, + "x_axis_edge": { + "path": "XAxisEdge", + "getter": false, + "kind": "select_edge", + "assign": false + } + }, + "required": [ + "stationary_face", + "x_axis_edge" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "stationary_face": "$input_1", + "x_axis_edge": "$input_2", + "associative": true + }, + "edit_status": "experimental" + }, + "flat_pattern": { + "builder": "FlatPatternBuilder", + "factory": "CreateFlatPatternBuilder", + "fields": { + "added_geometry": { + "path": "AddedGeometry", + "getter": false, + "kind": "section", + "assign": false + }, + "associative": { + "path": "Associative", + "getter": false, + "kind": "boolean" + }, + "bend_direction_down_text": { + "path": "BendDirectionDownText", + "getter": false, + "kind": "string" + }, + "bend_direction_up_text": { + "path": "BendDirectionUpText", + "getter": false, + "kind": "string" + }, + "fix_at_timestamp": { + "path": "FixAtTimestamp", + "getter": false, + "kind": "boolean" + }, + "hole_treatment": { + "path": "HoleTreatment", + "getter": false, + "kind": "object", + "fields": { + "diameter": { + "path": "Diameter", + "getter": false, + "kind": "expression" + }, + "treatment": { + "path": "Treatment", + "getter": false, + "kind": "enum", + "enum_type": "HoleTreatmentBuilderTreatmentType", + "values": [ + "NotSet", + "Centermark", + "HoleAndCentermark" + ] + }, + "use_global": { + "path": "UseGlobal", + "getter": false, + "kind": "boolean" + } + } + }, + "inner_corner_treatment": { + "path": "InnerCornerTreatment", + "getter": false, + "kind": "object", + "fields": { + "treatment_type": { + "path": "TreatmentType", + "getter": false, + "kind": "enum", + "enum_type": "CornerTreatmentBuilderCornerTreatmentType", + "values": [ + "NotSet", + "Chamfer", + "Radius" + ] + }, + "use_global": { + "path": "UseGlobal", + "getter": false, + "kind": "boolean" + }, + "value": { + "path": "Value", + "getter": false, + "kind": "expression" + } + } + }, + "keep_flat_solid_external": { + "path": "KeepFlatSolidExternal", + "getter": false, + "kind": "boolean" + }, + "orientation": { + "path": "Orientation", + "getter": false, + "kind": "enum", + "enum_type": "FlatSolidBuilderOrientationType", + "values": [ + "Default", + "Edge", + "Csys" + ] + }, + "orientation_csys": { + "path": "OrientationCsys", + "getter": false, + "kind": "csys", + "assign": true + }, + "orientation_from_csys": { + "path": "OrientationFromCsys", + "getter": false, + "kind": "csys", + "assign": true + }, + "outer_corner_treatment": { + "path": "OuterCornerTreatment", + "getter": false, + "kind": "object", + "fields": { + "treatment_type": { + "path": "TreatmentType", + "getter": false, + "kind": "enum", + "enum_type": "CornerTreatmentBuilderCornerTreatmentType", + "values": [ + "NotSet", + "Chamfer", + "Radius" + ] + }, + "use_global": { + "path": "UseGlobal", + "getter": false, + "kind": "boolean" + }, + "value": { + "path": "Value", + "getter": false, + "kind": "expression" + } + } + }, + "reference_vertex": { + "path": "ReferenceVertex", + "getter": false, + "kind": "point3d", + "assign": true + }, + "show_interior_feature_curves": { + "path": "ShowInteriorFeatureCurves", + "getter": false, + "kind": "boolean" + }, + "transform_to_absolute_csys": { + "path": "TransformToAbsoluteCsys", + "getter": false, + "kind": "boolean" + }, + "upward_face": { + "path": "UpwardFace", + "getter": false, + "kind": "select_face", + "assign": false + }, + "x_axis_edge": { + "path": "XAxisEdge", + "getter": false, + "kind": "select_edge", + "assign": false, + "description": "Select a valid orientation edge on the selected upward web face. After forming, reacquire its current boundary/tangent edge rather than reusing the original tab outer-edge location." + } + }, + "required": [ + "upward_face", + "x_axis_edge" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native developed bracket, DXF/GEO export; flat pattern drawing and visually inspected PDF", + "edit_status": "experimental" + }, + "advanced_flange": { + "builder": "AdvancedFlangeBuilder", + "factory": "CreateAdvancedFlangeBuilder", + "fields": { + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "edges": { + "path": "Edges", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "end_adjustment": { + "path": "EndAdjustment", + "getter": false, + "kind": "expression" + }, + "faces": { + "path": "Faces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face", + "description": "Reference-face collector for ToReference. NX requires faces belonging to the same body as the selected edges. Tested web and formed-wall face combinations were rejected; do not infer an arbitrary face is geometrically valid." + }, + "flat_pattern_compensation_at_end": { + "path": "FlatPatternCompensationAtEnd", + "getter": false, + "kind": "boolean" + }, + "flat_pattern_compensation_at_start": { + "path": "FlatPatternCompensationAtStart", + "getter": false, + "kind": "boolean" + }, + "infer_length": { + "path": "InferLength", + "getter": false, + "kind": "boolean", + "description": "Native inference toggle. ByValue=true committed on the numeric-length fixture but produced identical geometry; this is not proof of inferred-length behavior. Reference-driven inference remains unverified." + }, + "inset": { + "path": "Inset", + "getter": false, + "kind": "enum", + "enum_type": "AdvancedFlangeBuilderInsets", + "values": [ + "MaterialInside", + "MaterialOutside", + "BendOutside", + "MaterialInsideOML" + ] + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length_reference": { + "path": "LengthReference", + "getter": false, + "kind": "enum", + "enum_type": "AdvancedFlangeBuilderLengthReferences", + "values": [ + "Inside", + "Outside", + "Web", + "Din6935" + ] + }, + "plane1": { + "path": "Plane1", + "getter": false, + "kind": "plane", + "assign": true, + "description": "First end-limit plane in work-part coordinates. Native 100x80x2 tab fixture: origin [10,0,0], normal [1,0,0] trims 10 mm from the flange end." + }, + "plane2": { + "path": "Plane2", + "getter": false, + "kind": "plane", + "assign": true, + "description": "Second end-limit plane. Native paired fixture uses plane1 at x=10 and plane2 at x=90, both normal +X, to trim both ends. Plane2-only mode is not established." + }, + "reverse_direction": { + "path": "ReverseDirection", + "getter": false, + "kind": "boolean" + }, + "reverse_trim_side": { + "path": "ReverseTrimSide", + "getter": false, + "kind": "boolean" + }, + "start_adjustment": { + "path": "StartAdjustment", + "getter": false, + "kind": "expression" + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "AdvancedFlangeBuilderTypes", + "values": [ + "ByValue", + "ToReference" + ], + "description": "ByValue and ToReference are native builder modes. Only the example with native default mode and numeric length is verified; ToReference geometry combinations remain unverified." + } + }, + "required": [ + "edges" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "edges": [ + "$input_1" + ], + "length": 20, + "angle": 90 + }, + "edit_status": "experimental", + "example_evidence": { + "source": "tests/fixtures/sheet-metal-examples.json#/operations/advanced_flange", + "validation": "native_fixture", + "scope": "suite2 AdvancedFlange creation with length=20, angle=90 on a boundary edge of a 100 x 80 x 2 mm XY tab; optional reference-driven modes were not exercised." + }, + "prerequisites": [ + "Numeric-length ByValue mode and one/two end-limit planes were exercised natively on NX v2606.", + "ToReference without faces fails preflight. Same-body web and formed-wall face selections were rejected by NX with rollback; a successful reference-driven recipe is not established.", + "infer_length=true in ByValue mode did not change the numeric-length fixture." + ] + }, + "variational_flange": { + "builder": "VariationalFlangeBuilder", + "factory": "CreateVariationalFlangeBuilder", + "fields": { + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "angle_law_type": { + "path": "AngleLawType", + "getter": false, + "kind": "enum", + "enum_type": "VariationalFlangeBuilderLawTypes", + "values": [ + "Constant", + "Linear" + ] + }, + "edges": { + "path": "Edges", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "end_angle": { + "path": "EndAngle", + "getter": false, + "kind": "expression" + }, + "end_length": { + "path": "EndLength", + "getter": false, + "kind": "expression" + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length_law_type": { + "path": "LengthLawType", + "getter": false, + "kind": "enum", + "enum_type": "VariationalFlangeBuilderLawTypes", + "values": [ + "Constant", + "Linear" + ] + }, + "length_reference": { + "path": "LengthReference", + "getter": false, + "kind": "enum", + "enum_type": "VariationalFlangeBuilderLengthReferences", + "values": [ + "Inside", + "Outside", + "Web", + "Tangent" + ] + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "radius": { + "path": "Radius", + "getter": false, + "kind": "expression" + }, + "reverse_direction": { + "path": "ReverseDirection", + "getter": false, + "kind": "boolean" + }, + "start_angle": { + "path": "StartAngle", + "getter": false, + "kind": "expression" + }, + "start_length": { + "path": "StartLength", + "getter": false, + "kind": "expression" + } + }, + "required": [ + "edges" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "edges": [ + "$input_1" + ], + "length_law_type": "Linear", + "start_length": 10, + "end_length": 20, + "angle": 90, + "radius": 2, + "neutral_factor": 0.33 + }, + "edit_status": "experimental" + }, + "joggle": { + "builder": "JoggleBuilder", + "factory": "CreateJoggleBuilder", + "fields": { + "adjustment": { + "path": "Adjustment", + "getter": false, + "kind": "expression" + }, + "end_plane": { + "path": "EndPlane", + "getter": false, + "kind": "plane", + "assign": true + }, + "flat_pattern_compensation": { + "path": "FlatPatternCompensation", + "getter": false, + "kind": "boolean" + }, + "limit_type": { + "path": "LimitType", + "getter": false, + "kind": "enum", + "enum_type": "JoggleBuilderLimitTypes", + "values": [ + "Single", + "Twin" + ] + }, + "side1_options": { + "path": "Side1Options", + "getter": false, + "kind": "object", + "fields": { + "clearance": { + "path": "Clearance", + "getter": false, + "kind": "expression" + }, + "offset_radius": { + "path": "OffsetRadius", + "getter": false, + "kind": "expression" + }, + "runout": { + "path": "Runout", + "getter": false, + "kind": "expression" + }, + "stationary_radius": { + "path": "StationaryRadius", + "getter": false, + "kind": "expression" + } + } + }, + "side2_options": { + "path": "Side2Options", + "getter": false, + "kind": "object", + "fields": { + "clearance": { + "path": "Clearance", + "getter": false, + "kind": "expression" + }, + "offset_radius": { + "path": "OffsetRadius", + "getter": false, + "kind": "expression" + }, + "runout": { + "path": "Runout", + "getter": false, + "kind": "expression" + }, + "stationary_radius": { + "path": "StationaryRadius", + "getter": false, + "kind": "expression" + } + } + }, + "start_plane": { + "path": "StartPlane", + "getter": false, + "kind": "plane", + "assign": true + }, + "symmetric_sides": { + "path": "SymmetricSides", + "getter": false, + "kind": "boolean" + }, + "use_material_table": { + "path": "UseMaterialTable", + "getter": false, + "kind": "boolean" + }, + "inputs": { + "kind": "joggle_list", + "required": [ + "faces", + "depth" + ], + "fields": { + "depth": { + "path": "Depth", + "getter": false, + "kind": "expression" + }, + "faces": { + "path": "Faces", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "face" + }, + "reverse_direction": { + "path": "ReverseDirection", + "getter": false, + "kind": "boolean" + } + } + } + }, + "required": [ + "inputs" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "inputs": [ + { + "faces": [ + "$input_1" + ], + "depth": 5 + } + ], + "start_plane": { + "origin": [ + 50, + 0, + 0 + ], + "normal": [ + 1, + 0, + 0 + ] + }, + "limit_type": "Single", + "side1_options": { + "runout": 10, + "stationary_radius": 2, + "offset_radius": 2, + "clearance": 0.2 + } + }, + "edit_status": "experimental" + }, + "lightening_cutout": { + "builder": "LighteningCutoutBuilder", + "factory": "CreateLighteningCutoutBuilder", + "fields": { + "angle": { + "path": "Angle", + "getter": false, + "kind": "expression" + }, + "bend_options": { + "path": "BendOptions", + "getter": false, + "kind": "object", + "fields": { + "bend_radius": { + "path": "BendRadius", + "getter": false, + "kind": "expression" + }, + "bend_relief_depth": { + "path": "BendReliefDepth", + "getter": false, + "kind": "expression" + }, + "bend_relief_type": { + "path": "BendReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsBendReliefTypeOptions", + "values": [ + "NotSet", + "Square", + "Round" + ] + }, + "bend_relief_width": { + "path": "BendReliefWidth", + "getter": false, + "kind": "expression" + }, + "corner_relief_type": { + "path": "CornerReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BendOptionsCornerReliefTypeOptions", + "values": [ + "NotSet", + "BendOnly", + "BendAndFace", + "BendAndFaceChain" + ] + }, + "die_tool_id": { + "path": "DieToolId", + "getter": false, + "kind": "integer" + }, + "die_tool_id_name": { + "path": "DieToolIdName", + "getter": false, + "kind": "string" + }, + "extend_bend_relief": { + "path": "ExtendBendRelief", + "getter": false, + "kind": "boolean" + }, + "include_relief_in_width": { + "path": "IncludeReliefInWidth", + "getter": false, + "kind": "boolean" + }, + "neutral_factor": { + "path": "NeutralFactor", + "getter": false, + "kind": "expression" + }, + "override_tool_set": { + "path": "OverrideToolSet", + "getter": false, + "kind": "boolean" + }, + "punch_tool_id": { + "path": "PunchToolId", + "getter": false, + "kind": "integer" + }, + "punch_tool_id_name": { + "path": "PunchToolIdName", + "getter": false, + "kind": "string" + }, + "use_global_bend_radius": { + "path": "UseGlobalBendRadius", + "getter": false, + "kind": "boolean" + }, + "use_global_neutral_factor": { + "path": "UseGlobalNeutralFactor", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_depth": { + "path": "UseGlobalReliefDepth", + "getter": false, + "kind": "boolean" + }, + "use_global_relief_width": { + "path": "UseGlobalReliefWidth", + "getter": false, + "kind": "boolean" + } + } + }, + "check_clearance": { + "path": "CheckClearance", + "getter": false, + "kind": "boolean" + }, + "clearance": { + "path": "Clearance", + "getter": false, + "kind": "expression" + }, + "diameter": { + "path": "Diameter", + "getter": false, + "kind": "expression" + }, + "die_radius": { + "path": "DieRadius", + "getter": false, + "kind": "expression" + }, + "hole_center": { + "path": "HoleCenter", + "getter": false, + "kind": "point_section", + "assign": false + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "reverse_bend_direction": { + "path": "ReverseBendDirection", + "getter": false, + "kind": "boolean" + }, + "section_corner_radius": { + "path": "SectionCornerRadius", + "getter": false, + "kind": "expression" + }, + "standard_name": { + "path": "StandardName", + "getter": false, + "kind": "string" + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "LighteningCutoutBuilderCutoutType", + "values": [ + "Hole", + "UserDefined" + ] + }, + "user_defined_section": { + "path": "UserDefinedSection", + "getter": false, + "kind": "section", + "assign": false + } + }, + "required": [], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "type": "Hole", + "hole_center": [ + [ + 50, + 40, + 0 + ] + ], + "diameter": 12, + "length": 4, + "angle": 45, + "die_radius": 2 + }, + "edit_status": "experimental" + }, + "solid_punch": { + "builder": "SolidPunchBuilder", + "factory": "CreateSolidPunchBuilder", + "fields": { + "auto_centroid": { + "path": "AutoCentroid", + "getter": false, + "kind": "boolean" + }, + "constant_thickness": { + "path": "ConstantThickness", + "getter": false, + "kind": "boolean" + }, + "die_radius": { + "path": "DieRadius", + "getter": false, + "kind": "expression" + }, + "from_csys": { + "path": "FromCsys", + "getter": false, + "kind": "csys", + "assign": true + }, + "hide_tool": { + "path": "HideTool", + "getter": false, + "kind": "boolean" + }, + "include_rounding": { + "path": "IncludeRounding", + "getter": false, + "kind": "boolean" + }, + "infer_thickness": { + "path": "InferThickness", + "getter": false, + "kind": "boolean" + }, + "pierce_faces": { + "path": "PierceFaces", + "getter": false, + "kind": "select_faces", + "assign": false + }, + "punch_radius": { + "path": "PunchRadius", + "getter": false, + "kind": "expression" + }, + "target_face": { + "path": "TargetFace", + "getter": false, + "kind": "select_face", + "assign": false + }, + "thickness": { + "path": "Thickness", + "getter": false, + "kind": "expression" + }, + "to_csys": { + "path": "ToCsys", + "getter": false, + "kind": "csys", + "assign": true + }, + "tool_body": { + "path": "ToolBody", + "getter": false, + "kind": "select_body", + "assign": false + }, + "type": { + "path": "Type", + "getter": false, + "kind": "enum", + "enum_type": "SolidPunchBuilderTypes", + "values": [ + "PunchType", + "DieType" + ] + } + }, + "required": [ + "target_face", + "tool_body" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "target_face": "$input_1", + "tool_body": "$input_2", + "type": "PunchType", + "from_csys": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 1, + 0, + 0 + ], + "y_axis": [ + 0, + 1, + 0 + ] + }, + "to_csys": { + "origin": [ + 0, + 0, + 0 + ], + "x_axis": [ + 1, + 0, + 0 + ], + "y_axis": [ + 0, + 1, + 0 + ] + }, + "constant_thickness": true, + "infer_thickness": true, + "include_rounding": false, + "auto_centroid": false + }, + "edit_status": "experimental" + }, + "bulge_relief": { + "builder": "BulgeReliefBuilder", + "factory": "CreateBulgeReliefBuilder", + "fields": { + "bend_edges": { + "path": "BendEdges", + "getter": false, + "kind": "collector", + "assign": false, + "objects": "edge" + }, + "depth": { + "path": "Depth", + "getter": false, + "kind": "expression" + }, + "radius": { + "path": "Radius", + "getter": false, + "kind": "expression" + }, + "relief_type": { + "path": "ReliefType", + "getter": false, + "kind": "enum", + "enum_type": "BulgeReliefBuilderReliefTypes", + "values": [ + "Circular", + "UShaped", + "VShaped" + ] + }, + "width": { + "path": "Width", + "getter": false, + "kind": "expression" + }, + "width_type": { + "path": "WidthType", + "getter": false, + "kind": "enum", + "enum_type": "BulgeReliefBuilderWidthTypes", + "values": [ + "Value", + "FullWidth" + ] + } + }, + "required": [ + "bend_edges" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination", + "example_parameters": { + "bend_edges": [ + "$input_1" + ], + "depth": 3, + "width": 6, + "radius": 2, + "relief_type": "Circular" + }, + "edit_status": "experimental" + }, + "unbend": { + "builder": "UnbendBuilder", + "factory": "CreateUnbendFeatureBuilder", + "fields": { + "added_geometry": { + "path": "AddedGeometry", + "getter": false, + "kind": "section", + "assign": true + }, + "extract_gusset_curves": { + "path": "ExtractGussetCurves", + "getter": false, + "kind": "boolean" + }, + "face_collector": { + "path": "FaceCollector", + "getter": false, + "kind": "collector", + "assign": true, + "objects": "face", + "description": "Bend face IDs from nx_sheet_metal_info.items[].bends[].face.id on the owning body; reacquire after unbend or another topology change." + }, + "hide_original_curves": { + "path": "HideOriginalCurves", + "getter": false, + "kind": "boolean" + }, + "reference_entity": { + "path": "ReferenceEntity", + "getter": false, + "kind": "face_or_edge", + "assign": true, + "description": "Stationary face or edge on the same work-part sheet-metal body. Native fixture uses the original largest planar web face, not the flattened bend strip; edge-based stationary references are unverified." + } + }, + "required": [ + "face_collector", + "reference_entity" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Native face collector initialized; actual bend flattened; body consistency and subsequent rebend verified", + "edit_status": "experimental", + "example_parameters": { + "face_collector": [ + "$input_1" + ], + "reference_entity": "$input_2" + }, + "example_evidence": { + "source": "tests/fixtures/sheet-metal-examples.json#/operations/unbend", + "validation": "native_fixture", + "scope": "suite4/unbend_rebend: one bend on a 100 x 80 x 2 mm tab with a 20 mm, 90-degree flange; original planar web is stationary. Edge-based stationary references were not exercised." + }, + "prerequisites": [ + "Start from a native sheet-metal body with a bend; the fixture uses a 100 x 80 x 2 mm tab and a 20 mm, 90-degree flange.", + "$input_1 is the bend face from nx_sheet_metal_info.items[].bends[].face.id; $input_2 is the original planar web face selected from that same body.", + "Reacquire bend and stationary references after topology changes. For rebend, select the current bend face after unbend and keep the original web stationary; do not use the newly flattened strip as the stationary face." + ] + }, + "rebend": { + "builder": "RebendBuilder", + "factory": "CreateRebendFeatureBuilder", + "fields": { + "face_collector": { + "path": "FaceCollector", + "getter": false, + "kind": "collector", + "assign": true, + "objects": "face", + "description": "Bend face IDs from nx_sheet_metal_info.items[].bends[].face.id on the owning body; reacquire after unbend or another topology change." + }, + "reference_entity": { + "path": "ReferenceEntity", + "getter": false, + "kind": "face_or_edge", + "assign": true, + "description": "Stationary face or edge on the same work-part sheet-metal body. Native fixture uses the original largest planar web face, not the flattened bend strip; edge-based stationary references are unverified." + } + }, + "required": [ + "face_collector", + "reference_entity" + ], + "native_status": "tested", + "tested_on": "v2606", + "validation_scope": "Flattened bend reformed about the largest stationary web face; native geometry healthy", + "edit_status": "experimental", + "example_parameters": { + "face_collector": [ + "$input_1" + ], + "reference_entity": "$input_2" + }, + "example_evidence": { + "source": "tests/fixtures/sheet-metal-examples.json#/operations/rebend", + "validation": "native_fixture", + "scope": "suite4/unbend_rebend: one bend on a 100 x 80 x 2 mm tab with a 20 mm, 90-degree flange; original planar web is stationary. Edge-based stationary references were not exercised." + }, + "prerequisites": [ + "Start from a native sheet-metal body with a bend; the fixture uses a 100 x 80 x 2 mm tab and a 20 mm, 90-degree flange.", + "$input_1 is the bend face from nx_sheet_metal_info.items[].bends[].face.id; $input_2 is the original planar web face selected from that same body.", + "Reacquire bend and stationary references after topology changes. For rebend, select the current bend face after unbend and keep the original web stationary; do not use the newly flattened strip as the stationary face." + ] + } +} diff --git a/src/nx_mcp/sheet_metal_server.py b/src/nx_mcp/sheet_metal_server.py new file mode 100644 index 0000000..dd3ac94 --- /dev/null +++ b/src/nx_mcp/sheet_metal_server.py @@ -0,0 +1,142 @@ +"""Public contracts for native sheet-metal operations.""" + +from __future__ import annotations + +from typing import Any, Literal + +SheetMetalOperation = Literal[ + "tab", + "flange", + "contour_flange", + "lofted_flange", + "bend", + "jog", + "hem", + "normal_cutout", + "bead", + "dimple", + "louver", + "drawn_cutout", + "gusset", + "edge_rip", + "break_corner", + "closed_corner", + "three_bend_corner", + "bridge_bend", + "bend_taper", + "resize_bend_angle", + "resize_bend_radius", + "resize_neutral_factor", + "convert", + "from_solid", + "flat_solid", + "flat_pattern", + "advanced_flange", + "variational_flange", + "joggle", + "lightening_cutout", + "solid_punch", + "bulge_relief", + "unbend", + "rebend", +] +READ_ONLY = {"nx_sheet_metal_schema", "nx_sheet_metal_info", "nx_sheet_metal_defaults"} +NON_MODEL = {"nx_export_flat_pattern", "nx_sheet_metal_context"} + + +def nx_create_path_sketch( + edges: list[str], + help_point: list[float], + percent: float = 0, + orienting_face: str | None = None, + reverse_normal: bool = False, + reverse_axis: bool = False, + name: str | None = None, +): + """Create and activate a native sketch along a work-part edge path, normal to the path. percent is arc-length percentage 0..100 from the NX section start, not the curve's mathematical parameter. help_point=[x,y,z] anchors section selection in work-part units. Optional orienting_face controls local axes relative to a face. Return the actual origin, basis and normal; add curves in that local frame and finish the sketch. Needed for secondary contour flanges. Finish another active sketch first; work/display parts must match.""" + + +def nx_sheet_metal_schema(operation: SheetMetalOperation | None = None): + """Discover native sheet-metal operations or the strict creation/edit JSON parameter schema for one operation. Every operation reports its actual native validation status; experimental does not mean geometrically verified. Lengths use work-part units, expression angles degrees, neutral factor is unitless. Use typed geometry IDs from nx_list_topology/nx_find_geometry and finished sketch IDs for sections.""" + + +def nx_sheet_metal_context(): + """Enter modern NX Sheet Metal (UG_APP_SBSM) for the current displayed work part. Finish any active sketch first. Changes the interactive application, not model geometry; inspect checkpoint state afterward. Batch sessions use native builder application context. Sheet-metal feature tools require this context and never silently switch into Modeling.""" + + +def nx_sheet_metal_feature( + operation: SheetMetalOperation, parameters: dict[str, Any], feature: str | None = None +): + """Create a native sheet-metal feature or edit an owned feature by typed ID. First call nx_sheet_metal_schema(operation) for exact parameter names, enums and required geometry. Enter nx_sheet_metal_context before authoring interactively. All references must belong to the work part; unknown parameters are rejected. All changes run serially under an NX rollback mark. Returns all resulting bodies and native feature type. Availability and geometric verification vary by operation and installation; see schema status.""" + + +def nx_sheet_metal_info(body: str | None = None, offset: int = 0, limit: int = 50): + """Inspect sheet-metal bodies, actual native thickness and bend parameters. Omit body to inspect owned work-part bodies. Does not infer manufacturability or constant thickness of arbitrary solids. Returned references use the current part generation.""" + + +def nx_sheet_metal_defaults(): + """Read this part's sheet-metal stock thickness, radius, neutral factor, relief dimensions and native material/tool/bend-definition settings. These are feature creation defaults; existing feature overrides retain their own expressions.""" + + +def nx_set_sheet_metal_defaults( + parameter_entry: Literal["Value", "MaterialTable", "ToolIdTable"] | None = None, + thickness: float | None = None, + bend_radius: float | None = None, + neutral_factor: float | None = None, + bend_relief_width: float | None = None, + bend_relief_depth: float | None = None, + material: str | None = None, + tool: str | None = None, + bend_definition: Literal[ + "NeutralFactorValue", + "BendTable", + "BendAllowanceFormula", + "MaterialTable", + "ToolTable", + "BendAllowanceTable", + "BendDeductionTable", + "BendDeductionFormula", + "Din6935Formula", + ] + | None = None, + bend_table: str | None = None, + bend_allowance_formula: str | None = None, + bend_deduction_formula: str | None = None, +): + """Update native part sheet-metal defaults and return read-back. Lengths use work-part units; thickness must be positive and neutral factor 0..1. Material/tool names must exist in installed tables. Bend-table paths must be existing files inside the allowed workspace. Formula strings use NX syntax. Failed native changes roll back.""" + + +def nx_export_flat_pattern( + flat_pattern: str, + path: str, + format: Literal["dxf", "geo"] = "dxf", + revision: Literal[ + "R12", "R13", "R14", "R2000", "R2004", "R2005", "R2007", "R20102012", "R20132016", "R2018" + ] = "R2018", + bend_up: bool = True, + bend_down: bool = True, + bend_tangent: bool = False, + interior_cutout: bool = True, + interior_feature: bool = False, + inner_mold: bool = False, + outer_mold: bool = False, + added_top: bool = False, + added_bottom: bool = False, + tolerance: float = 0.01, +): + """Export an existing native Flat Pattern feature to a new workspace DXF or Trumpf GEO file. File suffix must match format; existing files are never overwritten. Positive tolerance uses work-part units. Returns actual path, options, units, size and SHA-256 for nx_download_file. Inner/outer mold and DXF revision are DXF-only. This export does not create a flat pattern from a folded body.""" + + +def nx_add_flat_pattern_view(drawing: str, flat_pattern: str, position: list[float] | None = None): + """Place the native named view belonging to an existing Flat Pattern feature on a drawing sheet. Position is [x,y] in sheet units; default [100,100]. Uses actual developed geometry and bend lines from NX, with the existing flat pattern's settings. Returns a typed drawing-view reference for projection, dimensions and PDF export. Does not copy or move the folded solid.""" + + +def nx_sheet_metal_annotation( + kind: Literal["body", "bend"], + body: str, + position: list[float], + faces: list[str] | None = None, + annotation: str | None = None, + automatic: bool = False, +): + """Create or refresh native Sheet Metal PMI attached to an owned body or bend faces. Text contains a measured snapshot of actual thickness or bend radius/angle/neutral factor; it does not automatically refresh after geometry edits. Call again with the annotation ID to refresh. kind=bend requires faces from that body; kind=body forbids faces. Position is [x,y,z] in work-part coordinates and units. Returns annotation references, measured data and native text.""" diff --git a/src/nx_mcp/surface_math.py b/src/nx_mcp/surface_math.py new file mode 100644 index 0000000..7cfe243 --- /dev/null +++ b/src/nx_mcp/surface_math.py @@ -0,0 +1,57 @@ +"""Coordinate-invariant surface differential geometry for sampled continuity checks.""" + +from __future__ import annotations + +import math + +from nx_mcp.runtime import NXToolError + + +def dot(a, b): + return sum(x * y for x, y in zip(a, b, strict=True)) + + +def shape_operator(du, dv, duu, duv, dvv): + normal = [ + du[1] * dv[2] - du[2] * dv[1], + du[2] * dv[0] - du[0] * dv[2], + du[0] * dv[1] - du[1] * dv[0], + ] + area = math.sqrt(dot(normal, normal)) + if area <= 1e-14: + raise NXToolError("NX_SINGULAR_SURFACE", "Surface parameterization is singular at a sample") + normal = [v / area for v in normal] + e, f, g = dot(du, du), dot(du, dv), dot(dv, dv) + determinant = e * g - f * f + if determinant <= 1e-24 * max(1.0, e * g): + raise NXToolError("NX_SINGULAR_SURFACE", "Surface metric is singular at a sample") + inverse = [[g / determinant, -f / determinant], [-f / determinant, e / determinant]] + second = [[dot(normal, duu), dot(normal, duv)], [dot(normal, duv), dot(normal, dvv)]] + tangent = [[du[i], dv[i]] for i in range(3)] + dual = [ + [sum(tangent[i][k] * inverse[k][j] for k in range(2)) for j in range(2)] for i in range(3) + ] + shape = [ + [ + sum( + dual[i][k] * second[k][column] * dual[j][column] + for k in range(2) + for column in range(2) + ) + for j in range(3) + ] + for i in range(3) + ] + return normal, shape + + +def continuity_difference(first, second): + normal_a, shape_a = first + normal_b, shape_b = second + cosine = dot(normal_a, normal_b) + orientation = 1 if cosine >= 0 else -1 + angle = math.degrees(math.acos(min(1.0, abs(cosine)))) + curvature = math.sqrt( + sum((shape_a[i][j] - orientation * shape_b[i][j]) ** 2 for i in range(3) for j in range(3)) + ) + return angle, curvature diff --git a/src/nx_mcp/thread_standards.py b/src/nx_mcp/thread_standards.py new file mode 100644 index 0000000..2ee1515 --- /dev/null +++ b/src/nx_mcp/thread_standards.py @@ -0,0 +1,148 @@ +"""Read installed thread tables in place and use native table-driven builders.""" + +import os +import xml.etree.ElementTree as ET +from pathlib import Path + +from nx_mcp.authoring import finite, page +from nx_mcp.runtime import NXToolError + + +class ThreadStandardsMixin: + def _thread_rows(self): + base = os.environ.get("UGII_BASE_DIR") + if not base: + raise NXToolError("NX_CATALOG_UNAVAILABLE", "NX installation root is not configured") + source = Path(base) / "UGII" / "modeling_standards" / "NX_Thread_Standard.xml" + if not source.is_file(): + raise NXToolError("NX_CATALOG_UNAVAILABLE", "Installed NX thread table was not found") + if source.stat().st_size > 20_000_000: + raise NXToolError("NX_CATALOG_INVALID", "Thread catalog exceeds the supported size") + return [dict(x.attrib) for x in ET.parse(source).getroot().iter("ThreadedHole")] + + def _thread_catalog(self, standard=None, size=None, offset=0, limit=50): + rows = self._thread_rows() + if standard is None: + if size is not None: + raise NXToolError("NX_INVALID_ARGUMENT", "Select a standard before a size") + return { + **page(sorted({r["Standard"] for r in rows}), offset, limit), + "level": "standards", + "source": "installed NX thread catalog; read in place", + } + rows = [ + r + for r in rows + if r.get("Standard") == standard and (size is None or r.get("Size") == size) + ] + if not rows: + raise NXToolError( + "NX_NOT_FOUND", "Requested standard/size is absent from the installed catalog" + ) + keys = ["Standard", "Unit", "Size", "Method", "RadialEngage", "Callout"] + if size is not None: + keys += [ + "MajorDiameter", + "MinorDiameter", + "TapDrillDia", + "ShaftDiameter", + "Pitch", + "Angle", + "NumStarts", + "Tapered", + ] + result = page([{k: r.get(k) for k in keys} for r in rows], offset, limit) + result.update( + level="sizes" if size is None else "selected_size", + source="installed NX thread catalog; read in place", + ) + return result + + def _standard_thread( + self, + face, + start_face, + standard, + size, + length, + method=None, + radial_engage=None, + detailed=False, + left_hand=False, + reverse=False, + ): + import NXOpen.Features as F + import NXOpen.UF as U + + rows = [ + r + for r in self._thread_rows() + if r.get("Standard") == standard + and r.get("Size") == size + and (method is None or r.get("Method") == method) + and (radial_engage is None or r.get("RadialEngage") == radial_engage) + ] + if len(rows) != 1: + raise NXToolError( + "NX_AMBIGUOUS_THREAD" if rows else "NX_NOT_FOUND", + "Select one installed catalog row using standard, size, method and radial_engage", + ) + row = rows[0] + target = self._engineering_owned(face, "face") + start = self._engineering_owned(start_face, "face") + data = U.UFSession.GetUFSession().Modeling.AskFaceData(target.Tag) + if data[0] != 16 or start.GetBody() != target.GetBody(): + raise NXToolError( + "NX_INVALID_ARGUMENT", "Select a cylinder and start face on the same body" + ) + length = finite(length, "length", True) + b = self._freeform_builder("CreateThreadBuilder") + try: + b.ThreadInput = F.ThreadBuilder.Input.ThreadTable + b.SmartThread = False + b.CylindricalFace.Value = target + b.StartObject.Value = start + b.ThreadType = ( + F.ThreadBuilder.Type.Detailed if detailed else F.ThreadBuilder.Type.Symbolic + ) + b.ThreadStandard = row["Standard"] + b.ThreadSize = row["Size"] + b.ThreadMethod = row["Method"] + b.RadialEngage = row["RadialEngage"] + b.MatchThreadSizeToCylinder = False + b.ShaftDiameterExp.RightHandSide = str(2 * data[4]) + b.TapDrillDiameterExp.RightHandSide = str(2 * data[4]) + b.ThreadLimit = F.ThreadBuilder.LimitOption.Value + b.ThreadLength.RightHandSide = str(length) + b.ThreadHandedness = ( + F.ThreadBuilder.Handedness.LeftHand + if left_hand + else F.ThreadBuilder.Handedness.RightHand + ) + b.ReverseThreadDirection = reverse + result = self._freeform_commit(b) + if b.ThreadStandard != row["Standard"] or b.ThreadSize != row["Size"]: + raise NXToolError( + "NX_VERIFICATION_FAILED", + "Native builder did not retain the selected standard/size", + ) + result.update( + standard=b.ThreadStandard, + size=b.ThreadSize, + method=b.ThreadMethod, + radial_engage=b.RadialEngage, + pitch=b.Pitch, + major_diameter=b.MajorDiameter, + minor_diameter=b.MinorDiameter, + internal=b.IsInternalThread, + handedness="left" + if b.ThreadHandedness == F.ThreadBuilder.Handedness.LeftHand + else "right", + parameter_units=self._units(), + representation="detailed" if detailed else "symbolic", + catalog_callout=row.get("Callout"), + catalog_units=row.get("Unit"), + ) + return result + finally: + b.Destroy() diff --git a/src/nx_mcp/tools/file_ops.py b/src/nx_mcp/tools/file_ops.py index eb2e3e0..cb8e83f 100644 --- a/src/nx_mcp/tools/file_ops.py +++ b/src/nx_mcp/tools/file_ops.py @@ -97,7 +97,7 @@ async def nx_open_part(path: str) -> ToolResult | ToolError: # --------------------------------------------------------------------------- @mcp_tool( name="nx_save_part", - description="Save the currently active (work) part.", + description="Save only the active work part; component files are not saved. Activate and save each component explicitly. The integration profile reports saved paths, disk fingerprints, native save errors and preservation checks for unrelated loaded parts.", params={}, ) async def nx_save_part() -> ToolResult | ToolError: @@ -108,7 +108,7 @@ async def nx_save_part() -> ToolResult | ToolError: part = NXSession.get_instance().require_work_part() part.Save( - NXOpen.BasePart.SaveComponents.TrueValue, NXOpen.BasePart.CloseAfterSave.FalseValue + NXOpen.BasePart.SaveComponents.FalseValue, NXOpen.BasePart.CloseAfterSave.FalseValue ) return ToolResult.success( diff --git a/src/nx_mcp/utils/geometry.py b/src/nx_mcp/utils/geometry.py index fe5b4ec..e50783a 100644 --- a/src/nx_mcp/utils/geometry.py +++ b/src/nx_mcp/utils/geometry.py @@ -37,14 +37,28 @@ def resolve_object_by_name( ) -> Any | None: """Find an NX object by name across multiple collections. - Each collection in *collections should support .ToArray() returning + Each collection in *collections must be iterable and return objects with a .Name attribute. Comparison is case-insensitive. - Returns the first match, or None. + Returns a unique match, or None. Ambiguous names are rejected. """ - target = name.lower() + from nx_mcp.runtime import NXToolError + + target = name.casefold() + matches = [] for collection in collections: - for obj in collection.ToArray(): - if obj.Name.lower() == target: - return obj - return None + for obj in collection: + if ( + target + in { + str(getattr(obj, "Name", "")).casefold(), + str(getattr(obj, "JournalIdentifier", "")).casefold(), + } + and obj not in matches + ): + matches.append(obj) + if len(matches) > 1: + raise NXToolError( + "NX_AMBIGUOUS_REFERENCE", "Name matches multiple objects; use an opaque reference" + ) + return matches[0] if matches else None diff --git a/src/nx_mcp/utils/selection.py b/src/nx_mcp/utils/selection.py index 7f120ca..1320962 100644 --- a/src/nx_mcp/utils/selection.py +++ b/src/nx_mcp/utils/selection.py @@ -50,7 +50,7 @@ def create_collector_from_names( collection = getattr(work_part, collection_name, None) if collection is not None: with contextlib.suppress(Exception): - all_objects.extend(collection.ToArray()) + all_objects.extend(list(collection)) resolved: list[Any] = [] for name in names: diff --git a/src/nx_mcp/visual_tools.py b/src/nx_mcp/visual_tools.py new file mode 100644 index 0000000..4f4b866 --- /dev/null +++ b/src/nx_mcp/visual_tools.py @@ -0,0 +1,538 @@ +"""NX-native visualization and solver diagnostics. All calls run on the NX thread.""" + +from __future__ import annotations + +import math +import uuid + +from nx_mcp.runtime import NXToolError + + +def enum_name(value, enum): + for name in dir(enum): + if not name.startswith("_") and getattr(enum, name) == value: + return name + return "unknown_" + str(value) + + +def unit_normal(value, argument="normal"): + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise NXToolError("NX_INVALID_ARGUMENT", f"{argument} requires three finite numbers") + v = [float(x) for x in value] + length = math.sqrt(sum(x * x for x in v)) + if not math.isfinite(length) or length < 1e-12: + raise NXToolError("NX_INVALID_ARGUMENT", f"{argument} must be finite and nonzero") + return [x / length for x in v] + + +class VisualToolsMixin: + def _visual_part(self): + part = self._work_part() + if part != self.session.Parts.Display: + raise NXToolError( + "NX_DISPLAY_PART_MISMATCH", "Activate the target as both work and display part" + ) + return part + + def _display_targets(self, objects, expand=False): + if not isinstance(objects, list) or not 1 <= len(objects) <= 1000: + raise NXToolError("NX_INVALID_ARGUMENT", "objects requires 1–1000 references") + values = [] + for ref in objects: + obj = self._resolve( + ref, {"body", "face", "edge", "curve", "sketch", "component", "feature", "datum"} + ) + if isinstance(obj, self.nxopen.Features.Feature) or ( + expand and hasattr(obj, "FindOccurrence") + ): + values.extend(self._geometry(ref)) + elif isinstance(obj, self.nxopen.Sketch): + values.extend(obj.GetAllGeometry()) + else: + values.append(obj) + values = list({int(x.Tag): x for x in values}.values()) + if not values: + raise NXToolError("NX_NO_TARGET_BODY", "Selection contains no displayable objects") + return values + + def _display_ref(self, obj): + kind = ( + "datum" + if isinstance( + obj, + tuple( + getattr(self.nxopen, n) + for n in ("DatumPlane", "DatumAxis", "CoordinateSystem") + if hasattr(self.nxopen, n) + ), + ) + else "component" + if hasattr(obj, "FindOccurrence") + else "body" + if isinstance(obj, self.nxopen.Body) + else "face" + if isinstance(obj, self.nxopen.Face) + else "edge" + if isinstance(obj, self.nxopen.Edge) + else "curve" + ) + return self._reference(obj, kind, self._work_part(), "Display object") + + def _display_record(self, obj, appearance=False): + record = {"object": self._display_ref(obj), "blanked": bool(obj.IsBlanked)} + if appearance: + record["color_index"] = obj.Color + if isinstance(obj, self.nxopen.Face): + import NXOpen.UF + + record["transparency"] = NXOpen.UF.UFSession.GetUFSession().Obj.AskTranslucency( + obj.Tag + ) + return record + + def _display_records(self, values, appearance=False): + if appearance: + values = list( + { + int(x.Tag): x + for obj in values + for x in ( + [obj] + list(obj.GetFaces()) if isinstance(obj, self.nxopen.Body) else [obj] + ) + }.values() + ) + if len(values) > 10000: + raise NXToolError("NX_OBJECT_LIMIT", "Display change exceeds 10000 objects/faces") + return [self._display_record(x, appearance) for x in values] + + def _save_display_snapshot(self, records): + if not hasattr(self, "_display_snapshots"): + self._display_snapshots = {} + token = "display_" + uuid.uuid4().hex + self._display_snapshots[token] = { + "part_id": self._part_id(self._work_part()), + "records": records, + } + return token + + def _display_info(self, objects, count_only=False): + self._visual_part() + if count_only: + values = self._display_targets(objects, expand=True) + count = len( + { + int(x.Tag) + for obj in values + for x in ( + [obj] + list(obj.GetFaces()) if isinstance(obj, self.nxopen.Body) else [obj] + ) + } + ) + return { + "count": count, + "object_limit": 10000, + "within_limit": count <= 10000, + "expansion": "unique bodies and faces", + "objects": [], + } + records = self._display_records(self._display_targets(objects, expand=True), True) + return { + "objects": records, + "count": len(records), + "transparency_scale": "0 opaque, 100 transparent", + "color_system": "NX part color-table index", + "coordinate_frame": "display_part", + } + + def _apply_appearance(self, values, color_index=None, transparency=None): + modification = self.session.DisplayManager.NewDisplayModification() + try: + modification.ApplyToOwningParts = False + modification.ApplyToAllFaces = True + if color_index is not None: + modification.NewColor = color_index + if transparency is not None: + modification.NewTranslucency = transparency + modification.Apply(values) + finally: + modification.Dispose() + + def _set_display(self, objects, color_index=None, transparency=None, color=None): + self._visual_part() + if color is not None: + import NXOpen.UF + + names = { + n: n.upper() + "_NAME" + for n in [ + "red", + "green", + "blue", + "yellow", + "cyan", + "magenta", + "orange", + "white", + "black", + ] + } + names["gray"] = "MEDIUM_GRAY_NAME" + if color not in names or color_index is not None: + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Specify a supported named color or color_index, not both", + ) + uf = NXOpen.UF.UFSession.GetUFSession() + color_index = uf.Disp.AskClosestColorInDisplayedPart( + getattr(uf.Disp.ColorName, names[color]) + ) + if color_index is None and transparency is None: + raise NXToolError("NX_INVALID_ARGUMENT", "Specify color_index or transparency") + if color_index is not None and ( + type(color_index) is not int or not 1 <= color_index <= 216 + ): + raise NXToolError("NX_INVALID_ARGUMENT", "color_index must be an integer from 1 to 216") + if transparency is not None and ( + type(transparency) is not int or not 0 <= transparency <= 100 + ): + raise NXToolError( + "NX_INVALID_ARGUMENT", "transparency must be an integer from 0 to 100" + ) + values = self._display_targets(objects, expand=True) + if transparency is not None and any( + not isinstance(x, (self.nxopen.Body, self.nxopen.Face)) for x in values + ): + raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Transparency requires bodies or faces") + before = self._display_records(values, True) + self._apply_appearance(values, color_index, transparency) + token = self._save_display_snapshot(before) + return { + "restore_id": token, + "objects": self._display_records(values, True), + "modified": [r["object"] for r in before], + "prototype_parts_modified": False, + "warnings": [ + "Appearance changes can be saved in the part; restore_id restores explicit attributes, not inherited override state." + ], + } + + def _set_visibility(self, objects, mode="show"): + part = self._visual_part() + if mode not in {"show", "hide", "isolate"}: + raise NXToolError("NX_INVALID_ARGUMENT", "mode must be show, hide or isolate") + selected = self._display_targets(objects) + if any(isinstance(x, (self.nxopen.Face, self.nxopen.Edge)) for x in selected): + raise NXToolError( + "NX_OBJECT_TYPE_MISMATCH", + "Visibility targets bodies, components or curves; faces/edges are unsupported", + ) + if mode == "isolate": + if any( + not isinstance(x, self.nxopen.Body) and not hasattr(x, "FindOccurrence") + for x in selected + ): + raise NXToolError( + "NX_OBJECT_TYPE_MISMATCH", "Isolation targets bodies or components" + ) + bodies = self._geometry(scope="assembly") + components = [c for c, _ in self._walk_components(part)] + values = list({int(x.Tag): x for x in bodies + components}.values()) + keep = set() + for ref in objects: + for obj in self._geometry(ref): + keep.add(int(obj.Tag)) + component = obj.OwningComponent if obj.IsOccurrence else None + while component: + keep.add(int(component.Tag)) + component = component.Parent + before = self._display_records(values) + for obj in values: + (obj.Unblank if int(obj.Tag) in keep else obj.Blank)() + else: + values = selected + before = self._display_records(values) + for obj in values: + (obj.Unblank if mode == "show" else obj.Blank)() + token = self._save_display_snapshot(before) + return { + "restore_id": token, + "mode": mode, + "objects": self._display_records(values), + "modified": [r["object"] for r in before], + "warnings": [ + "Isolation controls loaded body/component geometry; datum and reference-curve visibility is unchanged." + ] + if mode == "isolate" + else [], + } + + def _validate_display_restore(self, restore_id): + self._visual_part() + snapshots = getattr(self, "_display_snapshots", {}) + if restore_id not in snapshots: + raise NXToolError( + "NX_DISPLAY_SNAPSHOT_STALE", "Unknown or already restored display snapshot" + ) + snapshot = snapshots[restore_id] + pid = self._part_id(self._work_part()) + if snapshot["part_id"] != pid: + raise NXToolError("NX_DISPLAY_PART_MISMATCH", "Activate the snapshot owner part") + latest = next(k for k in reversed(snapshots) if snapshots[k]["part_id"] == pid) + if latest != restore_id: + raise NXToolError("NX_RESTORE_ORDER", "Restore display changes in reverse order") + resolved = [(self._resolve(r["object"]["id"]), r) for r in snapshot["records"]] + return resolved + + def _restore_display(self, restore_id): + resolved = self._validate_display_restore(restore_id) + snapshots = self._display_snapshots + for obj, record in resolved: + if "color_index" in record: + # Bodies precede their faces, preserving per-face colors afterward. + self._apply_appearance([obj], record["color_index"], record.get("transparency")) + (obj.Blank if record["blanked"] else obj.Unblank)() + del snapshots[restore_id] + return { + "restored": restore_id, + "count": len(resolved), + "modified": [r["object"] for _, r in resolved], + } + + def _clear_highlights(self): + cleared = 0 + for obj in getattr(self, "_highlighted_objects", []): + try: + obj.Unhighlight() + cleared += 1 + except Exception: + pass # NX may have deleted an entity since the highlight. + self._highlighted_objects = [] + return {"cleared_count": cleared, "scope": "MCP-owned highlights only"} + + def _highlight_collisions(self, obj1, obj2, include_contact=False): + self._visual_part() + if self.session.IsBatch: + raise NXToolError("NX_VIEWPORT_UNAVAILABLE", "Highlighting requires interactive NX") + result = self._check_interference(obj1, obj2) + pairs = [ + p + for p in result["pairs"] + if p["classification"] == "penetration" + or (include_contact and p["classification"] == "contact") + ] + refs = {r["id"]: r for p in pairs for r in p["objects"]} + values = [self._resolve(r) for r in refs] + self._clear_highlights() + try: + for obj in values: + obj.Highlight() + self._highlighted_objects.append(obj) + except Exception: + self._clear_highlights() + raise + return { + **result, + "highlighted": list(refs.values()), + "highlighted_count": len(values), + "highlight_style": "native NX selection highlight; no persistent color changes", + } + + def _list_sections(self): + from nx_mcp.hardened import xyz + + part = self._visual_part() + view = part.ModelingViews.WorkView + active = view.ActiveDynamicSection + enabled = bool(view.DisplaySectioningToggle) + sections = [] + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP inspect sections" + ) + try: + for section in part.DynamicSections: + builder = part.DynamicSections.CreateSectionBuilder(section, view) + try: + sections.append( + { + "object": self._reference(section, "section", part, "Section"), + "origin": xyz(builder.GetOrigin()), + "normal": xyz(builder.GetNormal()), + "active": active == section, + "clip_enabled": bool(builder.ShowClip), + "cap": bool(builder.ShowCap), + } + ) + finally: + builder.Destroy() + finally: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + return { + "sections": sections, + "count": len(sections), + "view_sectioning_enabled": enabled, + "coordinate_frame": "display_part", + } + + def _section_view(self, origin, normal, section=None, name="MCP section", cap=True): + import NXOpen.Display + + from nx_mcp.hardened import vector, xyz + + part = self._visual_part() + view = part.ModelingViews.WorkView + if self.session.IsBatch: + raise NXToolError("NX_VIEWPORT_UNAVAILABLE", "Section views require interactive NX") + point = vector(origin, "origin") + direction = unit_normal(normal) + if not isinstance(name, str) or not name.strip(): + raise NXToolError("NX_INVALID_ARGUMENT", "name cannot be empty") + target = self._resolve(section, {"section"}) if section else None + if target is None and view.ActiveDynamicSection: + raise NXToolError( + "NX_SECTION_EXISTS", + "Specify the existing section ID to edit it, or delete it first", + ) + builder = ( + part.DynamicSections.CreateSectionBuilder(target, view) + if target + else part.DynamicSections.CreateSectionBuilder(view) + ) + try: + builder.Type = NXOpen.Display.DynamicSectionTypes.Type.OnePlane + builder.CsysType = NXOpen.Display.DynamicSectionTypes.CoordinateSystem.Absolute + builder.SetName(name) + builder.SetNormal(self.nxopen.Vector3d(*direction)) + builder.SetOrigin(self.nxopen.Point3d(*point)) + builder.ClipType = NXOpen.Display.DynamicSectionTypes.Clip.Section + builder.ShowClip = True + builder.ShowCap = cap + builder.ShowViewer = False + builder.ShowGrid = False + result = builder.Commit() + view.ActiveDynamicSection = result + view.DisplaySectioningToggle = True + return { + "object": self._reference(result, "section", part, "Section"), + "origin": xyz(builder.GetOrigin()), + "normal": xyz(builder.GetNormal()), + "cap": cap, + "coordinate_frame": "display_part", + "retained_side": "negative_normal", + "retained_half_space": "dot(point - origin, normal) <= 0", + "geometry_changed": False, + "warnings": [ + "Native view clipping; model solids and measurements remain unchanged." + ], + } + finally: + builder.Destroy() + + def _section_control(self, section, action): + part = self._visual_part() + view = part.ModelingViews.WorkView + if action not in {"enable", "disable", "delete"}: + raise NXToolError("NX_INVALID_ARGUMENT", "action must be enable, disable or delete") + target = self._resolve(section, {"section"}) + if action == "delete": + part.DynamicSections.DeleteSections(False, [target]) + elif action == "enable": + view.ActiveDynamicSection = target + view.DisplaySectioningToggle = True + elif view.ActiveDynamicSection == target: + view.DisplaySectioningToggle = False + return {"section_id": section, "action": action, "geometry_changed": False} + + def _sketch_diagnostics(self, sketch_id): + sketch = self._resolve(sketch_id, {"sketch"}) + part = self._work_part() + active = self.session.ActiveSketch + if active and active != sketch: + raise NXToolError( + "NX_SKETCH_ACTIVE", "Finish the other active sketch before running diagnostics" + ) + mark = self.session.SetUndoMark( + self.nxopen.Session.MarkVisibility.Invisible, "NX MCP sketch diagnostics" + ) + activated = False + try: + if not active: + sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue) + activated = True + region = part.Sketches.CreateWorkRegionBuilder() + try: + # Evaluate all geometry regardless of the current work-region UI + # preference; the invisible undo mark restores this temporary change. + region.Scope = self.nxopen.SketchWorkRegionBuilder.ScopeType.EntireSketch + region.Commit() + finally: + region.Destroy() + sketch.CalculateStatus() + status, dof = sketch.GetStatus() + status_name = enum_name(status, self.nxopen.Sketch.Status) + constraints = list( + sketch.GetAllConstraintsOfType( + self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon + ) + ) + constraint_records = [] + for constraint in constraints: + ref = self._reference(constraint, "constraint", part, "Sketch constraint") + row = { + "object": ref, + "type": enum_name(constraint.ConstraintType, self.nxopen.Sketch.ConstraintType), + } + if hasattr(constraint, "AssociatedExpression") and constraint.AssociatedExpression: + exp = constraint.AssociatedExpression + row["expression"] = self._expression_record(exp) + constraint_records.append(row) + geometry = [] + for curve in sketch.GetAllGeometry(): + attached = sketch.GetConstraintsForGeometry( + curve, self.nxopen.Sketch.ConstraintClass.Any + ) + geometry.append( + { + "object": self._reference(curve, "curve", part, "Sketch geometry"), + "constraints": [ + self._reference(c, "constraint", part, "Constraint")["id"] + for c in attached + ], + } + ) + return { + "sketch": self._reference(sketch, "sketch", part, "Sketch"), + "solver_status": status_name, + "remaining_degrees_of_freedom": dof + if status_name in {"UnderConstrained", "WellConstrained"} + else None, + "native_dof_value": dof, + "constraints": constraint_records, + "constraint_count": len(constraints), + "geometry": geometry, + "geometry_count": len(geometry), + "evaluation": "native CalculateStatus, entire sketch", + "conflicting_constraints": None, + "work_region_handling": "Temporarily evaluate entire sketch; restore native state with undo", + "warnings": [ + "Persistent constraints are enumerated; inferred solver relations are not individual persistent constraints.", + "NX overall status does not identify a minimal conflicting constraint set.", + ], + } + finally: + try: + if activated and self.session.ActiveSketch == sketch: + sketch.Deactivate( + self.nxopen.Sketch.ViewReorient.FalseValue, + self.nxopen.Sketch.UpdateLevel.Model, + ) + finally: + try: + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) + except Exception as error: + raise NXToolError( + "NX_ROLLBACK_FAILED", + "Sketch diagnostic cleanup failed: " + str(error), + details={"mutation_outcome": "partial"}, + ) from error diff --git a/src/nx_mcp/workspace.py b/src/nx_mcp/workspace.py index 3dc77ba..bf4ddc5 100644 --- a/src/nx_mcp/workspace.py +++ b/src/nx_mcp/workspace.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pathlib import Path +from pathlib import Path, PureWindowsPath class WorkspaceViolation(ValueError): @@ -13,15 +13,22 @@ class Workspace: def __init__(self, root: str | Path) -> None: self.root = Path(root).resolve() - def resolve(self, relative_path: str) -> Path: - requested = Path(relative_path) - if requested.is_absolute(): - raise WorkspaceViolation("Path must stay inside the configured workspace") - - return self.ensure_inside(self.root / requested) + def resolve(self, path: str) -> Path: + """Resolve an NX-host absolute or workspace-relative path, never a client path.""" + requested = Path(path) + windows = PureWindowsPath(path) + # On POSIX a Windows drive would otherwise become an ordinary directory. + # On Windows reject drive-relative and root-relative paths (C:foo, \foo). + if windows.drive and not requested.is_absolute(): + raise WorkspaceViolation("Use an absolute NX-host path inside the workspace") + if windows.root and not requested.is_absolute(): + raise WorkspaceViolation("Use a complete NX-host path inside the workspace") + return self.ensure_inside(requested if requested.is_absolute() else self.root / requested) def ensure_inside(self, path: str | Path) -> Path: resolved = Path(path).resolve() if not resolved.is_relative_to(self.root): raise WorkspaceViolation("Path must stay inside the configured workspace") + if any(part.casefold() == ".nx-mcp" for part in resolved.relative_to(self.root).parts): + raise WorkspaceViolation("Internal NX MCP state is not a user artifact") return resolved diff --git a/tests/conftest.py b/tests/conftest.py index 914c794..8ddfa37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,6 +6,8 @@ import pytest +from tests.fakes import rig # noqa: F401 + def create_mock_nxopen_modules() -> dict[str, types.ModuleType]: """Create a full mock NXOpen module tree for testing.""" diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py new file mode 100644 index 0000000..bd8a991 --- /dev/null +++ b/tests/fakes/__init__.py @@ -0,0 +1,407 @@ +"""Stateful NX seams for failure/recovery tests; not a geometric kernel oracle.""" + +import itertools +from types import ModuleType +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.hardened import HardenedExecutor +from nx_mcp.workspace import Workspace + + +def point(x=0, y=0, z=0): + return NS(X=x, Y=y, Z=z) + + +def matrix(): + return NS(Xx=1, Xy=0, Xz=0, Yx=0, Yy=1, Yz=0, Zx=0, Zy=0, Zz=1) + + +class Object: + ids = itertools.count(1) + + def __init__(self, name=""): + self.Tag = next(self.ids) + self.Name = name + self.JournalIdentifier = f"OBJECT({self.Tag})" + self.IsBlanked = False + self.Color = 7 + self.transparency = 0 + self.highlighted = False + + def SetName(self, name): + self.Name = name + + def Blank(self): + self.IsBlanked = True + + def Unblank(self): + self.IsBlanked = False + + def Highlight(self): + self.highlighted = True + + def Unhighlight(self): + self.highlighted = False + + +class Face(Object): + pass + + +class Edge(Object): + pass + + +class Curve(Object): + pass + + +class Body(Object): + def __init__(self, name="Body", box=None): + super().__init__(name) + self.box = box or [0, 0, 0, 10, 10, 10] + self.IsSolidBody = True + self.IsOccurrence = False + self.faces = [Face("face")] + self.edges = [Edge("edge")] + self.volume = 1000 + + def GetFaces(self): + return self.faces + + def GetEdges(self): + return self.edges + + +class Feature(Object): + def __init__(self, name="Extrude", bodies=None): + super().__init__(name) + self.FeatureType = "EXTRUDE" + self.bodies = bodies or [] + self.expressions = [NS(Name="p1", RightHandSide="10", Value=10)] + + def GetBodies(self): + return self.bodies + + def GetExpressions(self): + return self.expressions + + def GetParents(self): + return [] + + def GetChildren(self): + return [] + + +class Sketch(Object): + Null = None + ViewReorient = NS(FalseValue=False, TrueValue=True) + UpdateLevel = NS(Model="model") + InferConstraintsOption = NS(InferNoConstraints=False) + Status = NS(UnderConstrained=1, WellConstrained=2, OverConstrained=3) + ConstraintClass = NS(Any=0) + ConstraintType = NS(NoCon=0, Distance=1) + + def __init__(self, session, name="Sketch"): + super().__init__(name) + self.session = session + self.Origin = point() + self.Orientation = NS(Element=matrix()) + self.geometry = [] + self.constraints = [] + self.status = (1, 4) + + def Activate(self, *_): + self.session.ActiveSketch = self + + def Deactivate(self, *_): + self.session.ActiveSketch = None + + def AddGeometry(self, curve, *_): + self.geometry.append(curve) + + def GetAllGeometry(self): + return self.geometry + + def GetAllConstraintsOfType(self, *_): + return self.constraints + + def GetConstraintsForGeometry(self, *_): + return self.constraints + + def CalculateStatus(self): + pass + + def GetStatus(self): + return self.status + + +class Component(Object): + def __init__(self, name, bodies=(), parent=None): + super().__init__(name) + self.Parent = parent + self.IsSuppressed = False + self.ReferenceSet = "Entire Part" + self.children = [] + self.Prototype = NS(Bodies=list(bodies), FullPath=name + ".prt") + self.position = point() + self.rotation = matrix() + self.occurrences = {} + for b in bodies: + o = Body(name + " occurrence", list(b.box)) + o.IsOccurrence = True + o.OwningComponent = self + self.occurrences[b.Tag] = o + if parent: + parent.children.append(self) + + def GetChildren(self): + return self.children + + def FindOccurrence(self, body): + return self.occurrences.get(body.Tag) + + def GetPosition(self): + return self.position, self.rotation + + +class Collection(list): + pass + + +class Session: + def __init__(self): + self.Parts = Collection() + self.Parts.Work = self.Parts.Display = None + self.ActiveSketch = None + self.IsBatch = False + self.marks = {} + self.mark_ids = itertools.count(1) + self.UpdateManager = NS(DoUpdate=Mock(return_value=0)) + + def SetUndoMark(self, *_): + mark = next(self.mark_ids) + states = [] + for p in self.Parts: + groups = [ + p.Bodies, + p.Features, + p.Curves, + p.Sketches, + p.DynamicSections, + p.Notes, + p.Labels, + ] + objs = list(itertools.chain.from_iterable(groups)) + attrs = [(o, o.IsBlanked, o.Color, o.transparency) for o in objs] + states.append((p, [list(g) for g in groups], attrs, p.IsModified)) + self.marks[mark] = (states, self.ActiveSketch) + return mark + + def UndoToMark(self, mark, *_): + states, active = self.marks[mark] + for p, groups, attrs, modified in states: + for dest, values in zip( + [p.Bodies, p.Features, p.Curves, p.Sketches, p.DynamicSections, p.Notes, p.Labels], + groups, + strict=True, + ): + dest[:] = values + for obj, blank, color, transparency in attrs: + obj.IsBlanked, obj.Color, obj.transparency = blank, color, transparency + p.IsModified = modified + self.ActiveSketch = active + + def DeleteUndoMark(self, mark, *_): + self.marks.pop(mark, None) + + def DoesUndoMarkExist(self, mark, *_): + return mark in self.marks + + +class Part(Object): + def __init__(self, session, path): + super().__init__(path.stem) + self.FullPath = str(path) + self.PartUnits = "mm" + self.IsModified = False + self.Bodies = Collection() + self.Features = Collection() + self.Curves = Collection() + self.Datums = Collection() + self.CoordinateSystems = Collection() + self.Sketches = Collection() + self.DynamicSections = Collection() + self.Notes = Collection() + self.Labels = Collection() + self.ComponentAssembly = NS(RootComponent=None) + self.WCS = NS(CoordinateSystem=NS(Orientation=NS(Element=matrix()))) + self.ModelingViews = NS( + WorkView=NS( + Name="Work", + Matrix=matrix(), + Origin=point(), + AbsoluteOrigin=point(), + Scale=1, + RenderingStyle="shaded", + ActiveDynamicSection=None, + DisplaySectioningToggle=False, + Fit=Mock(), + UpdateDisplay=Mock(), + ) + ) + self.UnitCollection = NS(FindObject=lambda name: name) + self.MeasureManager = NS( + NewMassProperties=lambda _u, _a, b: NS(Volume=sum(x.volume for x in b), Dispose=Mock()) + ) + self.session = session + session.Parts.append(self) + session.Parts.Work = session.Parts.Display = self + + def Save(self, *_): + from pathlib import Path + + Path(self.FullPath).parent.mkdir(parents=True, exist_ok=True) + Path(self.FullPath).write_bytes(b"native part fixture") + self.IsModified = False + self.session.marks.clear() + return NS(Dispose=Mock(), NumberUnsavedParts=0, NumberUnsavedObjects=0) + + def SaveAs(self, path): + self.FullPath = path + return NS(Dispose=Mock(), NumberUnsavedParts=0, NumberUnsavedObjects=0) + + def Close(self, *_): + self.session.Parts.remove(self) + if self.session.Parts.Work is self: + self.session.Parts.Work = None + if self.session.Parts.Display is self: + self.session.Parts.Display = None + + +@pytest.fixture +def rig(tmp_path, monkeypatch): + session = Session() + part = Part(session, tmp_path / "test.prt") + nx = ModuleType("NXOpen") + nx.Body = Body + nx.Face = Face + nx.Edge = Edge + nx.Sketch = Sketch + nx.Point3d = point + nx.Vector3d = point + nx.Matrix3x3 = matrix + nx.Features = NS(Feature=Feature) + nx.Session = NS(MarkVisibility=NS(Visible=1, Invisible=0)) + nx.MeasureBodies = NS(AnalysisUnit=NS(KilogramMillimeter=6)) + nx.BasePart = NS( + Units=NS(Millimeters="mm"), + SaveComponents=NS(TrueValue=True, FalseValue=False), + CloseAfterSave=NS(FalseValue=False), + CloseWholeTree=NS(FalseValue=False), + CloseModified=NS(CloseModified=0), + ) + nx.SketchWorkRegionBuilder = NS(ScopeType=NS(EntireSketch="all")) + nx.View = NS( + RenderingStyleType=NS(Shaded="shaded", ShadedWithEdges="edges", StaticWireframe="wire") + ) + session.Parts.SetWork = lambda p: setattr(session.Parts, "Work", p) + + def display(p, *_): + session.Parts.Display = p + return None, NS(Dispose=Mock()) + + session.Parts.SetDisplay = display + session.Parts.OpenBase = lambda path: ( + Part(session, __import__("pathlib").Path(path)), + NS(Dispose=Mock()), + ) + e = HardenedExecutor( + session, nx, "v2606 fake seam", Workspace(tmp_path), enable_experimental=True + ) + + def ref(obj, kind="body"): + return e._reference(obj, kind, part, kind)["id"] + + def object_for(tag): + return next(entry.value for entry in e.objects._objects.values() if entry.value.Tag == tag) + + uf = NS( + Obj=NS(AskTranslucency=lambda tag: object_for(tag).transparency), + Disp=NS( + ColorName=NS(RED_NAME=3, MEDIUM_GRAY_NAME=5), AskClosestColorInDisplayedPart=lambda x: x + ), + ModlGeneral=NS( + AskBoundingBox=lambda tag: object_for(tag).box, + AskBoundingBoxExact=lambda tag, _: ( + object_for(tag).box[:3], + [1, 0, 0, 0, 1, 0, 0, 0, 1], + [object_for(tag).box[i + 3] - object_for(tag).box[i] for i in range(3)], + ), + ), + ) + modules = { + "Preferences": NS( + Font=NS(Solid=1, Dashed=2, ValueOf=lambda value: value), + Width=NS( + Original=0, + Thin=1, + Normal=2, + Thick=3, + One=5, + Two=6, + Three=7, + Four=8, + Five=9, + Six=10, + Seven=11, + Eight=12, + Nine=13, + ), + ShadingRenderingStyleOption=NS(FullyShaded=0, PartiallyShaded=1, Wireframe=2), + ), + "UF": NS(UFSession=NS(GetUFSession=lambda: uf)), + "Display": NS( + DynamicSectionTypes=NS( + Type=NS(OnePlane=1), CoordinateSystem=NS(Absolute=1), Clip=NS(Section=1) + ) + ), + "Gateway": NS( + ImageExportBuilder=NS( + FileFormats=NS(Png=1), + BackgroundOptions=NS(CustomColor=1, Original=2, Transparent=3), + ) + ), + "GeometricAnalysis": NS( + SimpleInterference=NS( + InterferenceMethod=NS(InterferenceSolid=1), + Result=NS(InterferenceExists=1, OnlyEdgesOrFacesInterfere=2, NoInterference=3), + ) + ), + } + monkeypatch.setitem(__import__("sys").modules, "NXOpen", nx) + for name, mod in modules.items(): + setattr(nx, name, mod) + monkeypatch.setitem(__import__("sys").modules, "NXOpen." + name, mod) + modifications = [] + + def modification(): + m = NS(Dispose=Mock()) + + def apply(values): + for obj in values: + for target in [obj] + (obj.GetFaces() if isinstance(obj, Body) else []): + if hasattr(m, "NewColor"): + target.Color = m.NewColor + if hasattr(m, "NewTranslucency"): + target.transparency = m.NewTranslucency + + m.Apply = Mock(side_effect=apply) + modifications.append(m) + return m + + session.DisplayManager = NS(NewDisplayModification=modification) + return NS(e=e, session=session, part=part, nx=nx, uf=uf, ref=ref, modifications=modifications) diff --git a/tests/fixtures/sheet-metal-examples.json b/tests/fixtures/sheet-metal-examples.json new file mode 100644 index 0000000..87da5e3 --- /dev/null +++ b/tests/fixtures/sheet-metal-examples.json @@ -0,0 +1,30 @@ +{ + "nx_version": "v2606", + "stage": "pre-release native executor validation; public MCP acceptance is separate", + "historical_source": "https://github.com/xuio/NX_MCP/blob/a8ef3a270fbf4083b54ea48b06b64257dd910357/docs/sheet-metal-native-validation.json", + "operations": { + "advanced_flange": { + "native_type": "AdvancedFlange", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "edges": [ + "$input_1" + ], + "length": 20, + "angle": 90 + } + }, + "unbend": { + "native_type": "Unbend", + "source_fixture": "suite4/unbend_rebend", + "scope": "Native face collector initialized; actual bend flattened; body consistency and subsequent rebend verified" + }, + "rebend": { + "native_type": "Rebend", + "source_fixture": "suite4/unbend_rebend", + "scope": "Flattened bend reformed about the largest stationary web face; native geometry healthy" + } + } +} diff --git a/tests/test_advanced_authoring.py b/tests/test_advanced_authoring.py new file mode 100644 index 0000000..90153db --- /dev/null +++ b/tests/test_advanced_authoring.py @@ -0,0 +1,491 @@ +"""Failure boundaries and selection semantics; native fixtures test NX geometry.""" + +import copy +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.runtime import NXToolError +from tests.fakes import Component, Curve, Face, Object, Sketch, point +from tests.test_authoring_review import Expression +from tests.test_authoring_review import author as author_fixture + +pytestmark = pytest.mark.fake_nx + + +@pytest.fixture +def author(rig, monkeypatch): + import sys + from types import ModuleType + + r = author_fixture.__wrapped__(rig) + r.e._active_mark = None + module = ModuleType("NXOpen.GeometricUtilities") + module.PatternDefinition = NS(PatternEnum=NS(Linear="linear")) + monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", module) + r.nx.GeometricUtilities = module + return r + + +def test_exact_distance_ranks_surface_not_center(author): + a = author.body.faces[0] + b = Face() + b.box = [0, 0, 9, 1, 1, 9] + b.native_type, b.normal, b.radius = 22, [0, 0, 1], 0 + a.box = [-100, -100, 10, 100, 100, 10] + author.body.faces.append(b) + author.uf.Modeling.AskMinimumDist3.side_effect = lambda level, tag, *args: ( + 1 if tag == a.Tag else 5, + [0, 0, 10], + [0, 0, 11], + 0, + ) + result = author.e._find_geometry(near=[0, 0, 11]) + assert result["items"][0]["object"]["id"] == author.ref(a, "face") + assert result["items"][0]["distance"] == 1 + assert result["ranking"] == "native BREP minimum distance" + author.uf.Modeling.AskMinimumDist3.assert_called_with( + 2, b.Tag, 0, 0, [0.0, 0.0, 0.0], 1, [0.0, 0.0, 11.0] + ) + + +def test_selection_rule_requeries_and_rejects_ties(author): + result = author.e._find_geometry(near=[0, 0, 11]) + selector = result["selector"] + assert author.e._resolve_geometry(selector)["match"]["distance"] == 2 + author.uf.Modeling.AskMinimumDist3.return_value = (8, [0, 0, 3], [0, 0, 11], 0) + assert author.e._resolve_geometry(selector)["match"]["distance"] == 8 + author.body.faces.append(copy.copy(author.body.faces[0])) + author.body.faces[-1].Tag += 100000 + with pytest.raises(NXToolError, match="tied"): + author.e._resolve_geometry(selector) + + +@pytest.mark.parametrize("change", ["version", "extra", "query", "owner_part", "owner"]) +def test_malformed_and_wrong_owner_rules_rejected(author, change): + selector = author.e._find_geometry(near=[0, 0, 11])["selector"] + if change == "extra": + selector["extra"] = 1 + elif change == "version": + selector["version"] = 2 + elif change == "query": + selector["query"]["extra"] = 1 + elif change == "owner_part": + selector["owner_part"] = "other.prt" + else: + selector["owner"] = {"kind": "face"} + with pytest.raises(NXToolError): + author.e._resolve_geometry(selector) + + +def test_saved_owner_resolution_and_no_match(author): + owner = author.ref(author.body, "body") + selector = author.e._find_geometry(owner=owner, near=[0, 0, 11])["selector"] + assert selector["owner"]["kind"] == "body" + assert author.e._resolve_geometry(selector)["match"] + selector["query"]["geometry_type"] = "cylinder" + with pytest.raises(NXToolError, match="No geometry"): + author.e._resolve_geometry(selector) + + +def test_bore_groups_and_partial_faces(author): + faces = [ + { + "object": {"id": "a"}, + "cylindrical_role": "bore", + "axis": [0, 0, 1], + "axis_point": [0, 0, 0], + }, + { + "object": {"id": "b"}, + "cylindrical_role": "bore", + "axis": [0, 0, -1], + "axis_point": [0, 0, 10], + }, + { + "object": {"id": "c"}, + "cylindrical_role": "boss", + "axis": [0, 0, 1], + "axis_point": [10, 0, 0], + }, + ] + author.e._find_geometry = Mock(return_value={"items": faces, "next_offset": None}) + author.e._resolve = Mock(return_value=Object()) + author.uf.Modeling.AskFaceUvMinmax = Mock( + side_effect=[[0, 6.283185307179586, 0, 10], [0, 3.14, 0, 10]] + ) + r = author.e._recognize_holes() + assert r["total"] == 2 and len(r["coaxial_groups"]) == 1 + assert r["items"][0]["full_circumference"] and not r["items"][1]["full_circumference"] + + +@pytest.mark.parametrize( + "values", + [ + {}, + {"missing": "1"}, + {"height": ""}, + {"height": " " * 5}, + {"height": 123}, + {"height": "x" * 4097}, + ], +) +def test_feature_parameter_preflight(author, values): + with pytest.raises(NXToolError): + author.e._set_feature_parameters(author.ref(author.feature, "feature"), values) + author.part.Expressions.EditExpression.assert_not_called() + + +def test_feature_parameters_ownership_and_atomic_update(author): + r = author.e._feature_parameters(author.ref(author.feature, "feature")) + assert r["parameters"][0]["name"] == "height" + author.e._set_feature_parameters(author.ref(author.feature, "feature"), {"height": "18"}) + assert author.expression.RightHandSide == "18" + other = Expression("unrelated") + author.part.Expressions.append(other) + with pytest.raises(NXToolError, match="not owned"): + author.e._set_feature_parameters( + author.ref(author.feature, "feature"), {"height": "20", "unrelated": "30"} + ) + assert author.expression.RightHandSide == "18" + with pytest.raises(NXToolError, match="Duplicate"): + author.e._set_feature_parameters( + author.ref(author.feature, "feature"), + {"height": "20", author.ref(author.expression, "expression"): "30"}, + ) + author.expression.IsNoEdit = True + with pytest.raises(NXToolError, match="editable"): + author.e._set_feature_parameters(author.ref(author.feature, "feature"), {"height": "20"}) + + +@pytest.mark.parametrize( + "count,spacing", [(1, 1), (101, 1), (True, 1), (2.5, 1), (2, 0), (2, float("nan"))] +) +def test_native_pattern_invalid_inputs_precede_builder(author, count, spacing): + with pytest.raises(NXToolError): + author.e._native_component_pattern("missing", [1, 0, 0], spacing, count) + + +@pytest.fixture +def patterned(author): + r = author + root = Component("root") + r.part.ComponentAssembly.RootComponent = root + seed = Component("seed", parent=root) + r.nx.GeometricUtilities.PatternDefinition = NS(PatternEnum=NS(Linear="linear")) + r.nx.Vector3d = point + r.nx.SmartObject = NS(UpdateOption=NS(WithinModeling="model")) + r.part.Directions = NS(CreateDirection=Mock(return_value="direction")) + count = Expression("count") + count.Value = 4 + pitch = Expression("pitch") + pitch.Value = 20 + pattern = Object("pattern") + pattern.GetComponentsToPattern = lambda: [seed] + others = [Component("copy" + str(i), parent=root) for i in range(3)] + for i, c in enumerate(others): + c.position = point((i + 1) * 20, 0, 0) + pattern.GetAllPatternMembers = lambda: [NS(GetAllComponents=lambda: [seed, *others])] + b = NS( + Associative=True, + ComponentPatternSet=NS(Add=Mock()), + PatternService=NS( + PatternType="linear", + PatternEnum=NS(Linear="linear", Circular="circular"), + RectangularDefinition=NS( + UseYDirectionToggle=False, + XSpacing=NS(NCopies=count, PitchDistance=pitch), + YSpacing=NS(NCopies=Expression("y")), + ), + ), + Commit=Mock(return_value=pattern), + Destroy=Mock(), + ) + r.part.ComponentAssembly.CreateComponentPatternBuilder = Mock(return_value=b) + r.part.ComponentAssembly.ComponentPatterns = NS(GetAllComponentPatterns=lambda: [pattern]) + r.seed, r.pattern, r.pattern_builder = seed, pattern, b + return r + + +def test_native_pattern_creation_readback_edit_and_cleanup(patterned): + r = patterned + result = r.e._native_component_pattern(r.ref(r.seed, "component"), [1, 0, 0], 20, 4) + assert result["associative"] and result["total_instances"] == 4 + assert result["native_type"] == "NXOpen.Assemblies.ComponentPattern" + assert r.e._list_component_patterns()["count"] == 1 + r.e._edit_component_pattern(result["object"]["id"], spacing=22, count=4) + assert ( + r.pattern_builder.PatternService.RectangularDefinition.XSpacing.PitchDistance.RightHandSide + == "22.0" + ) + assert r.pattern_builder.Destroy.call_count == 5 + + +def test_pattern_failure_count_and_unsupported_edits(patterned): + r = patterned + with pytest.raises(NXToolError, match="count differs"): + r.e._native_component_pattern(r.ref(r.seed, "component"), [1, 0, 0], 20, 5) + with pytest.raises(NXToolError, match="Supply"): + r.e._edit_component_pattern(r.ref(r.pattern, "component_pattern")) + r.pattern_builder.Associative = False + with pytest.raises(NXToolError, match="associative"): + r.e._edit_component_pattern(r.ref(r.pattern, "component_pattern"), spacing=20) + r.seed.IsSuppressed = True + with pytest.raises(NXToolError, match="unsuppressed"): + r.e._native_component_pattern(r.ref(r.seed, "component"), [1, 0, 0], 20, 4) + + +@pytest.fixture +def constrained(author): + r = author + + class Line(Curve): + pass + + class Arc(Curve): + pass + + r.nx.Line, r.nx.Arc = Line, Arc + sk = Sketch(r.session) + r.part.Sketches.append(sk) + sk.Update = Mock() + a, b, arc = Line(), Line(), Arc() + for c in (a, b): + c.StartPoint, c.EndPoint = point(), point(10, 0, 0) + sk.geometry = [a, b, arc] + r.nx.Sketch.DimensionGeometry = lambda: NS(Geometry=None, AssocType=None) + r.nx.Sketch.ConstraintGeometry = lambda: NS(Geometry=None, PointType=None) + r.nx.Sketch.DimensionOption = NS(CreateAsReference="reference", CreateAsDriving="driving") + r.nx.Sketch.AssocType = NS(StartPoint="start", EndPoint="end") + r.nx.Sketch.ConstraintPointType = NS(StartVertex="start", EndVertex="end", ArcCenter="center") + for name in ["ParallelDim", "HorizontalDim", "VerticalDim"]: + setattr(r.nx.Sketch.ConstraintType, name, name) + constraint = Object() + constraint.AssociatedExpression = Expression("dim") + for name in [ + "CreateDimension", + "CreateRadialDimension", + "CreateDiameterDimension", + "CreateParallelConstraint", + "CreatePerpendicularConstraint", + "CreateEqualLengthConstraint", + "CreateEqualRadiusConstraint", + "CreateConcentricConstraint", + "CreateCoincidentConstraint", + ]: + setattr(sk, name, Mock(return_value=constraint)) + r.e._relation_residual = Mock(return_value=0.0) + r.e._sketch_diagnostics = Mock( + return_value={"solver_status": "UnderConstrained", "constraints": [], "constraint_count": 0} + ) + for name in [ + "CreateParallelConstraint", + "CreatePerpendicularConstraint", + "CreateEqualLengthConstraint", + "CreateEqualRadiusConstraint", + "CreateConcentricConstraint", + "CreateCoincidentConstraint", + ]: + + def create(*args): + sk.constraints.append(constraint) + return constraint + + getattr(sk, name).side_effect = create + constraint.ConstraintType = 99 + r.sk, r.lines, r.arc = sk, [a, b], arc + return r + + +@pytest.mark.parametrize("kind", ["length", "horizontal", "vertical", "radius", "diameter"]) +def test_dimensions_apply_formula_restore_activation(constrained, kind): + r = constrained + result = r.e._sketch_dimension( + r.ref(r.sk, "sketch"), + r.ref(r.arc if kind in {"radius", "diameter"} else r.lines[0], "curve"), + kind, + 12, + [0, 2], + ) + assert result["expression"]["formula"] == "12.0" + assert r.session.ActiveSketch is None + + +@pytest.mark.parametrize( + "kind,value,origin,reference", + [ + ("bad", 1, [0, 0], False), + ("radius", 1, [0, 0], False), + ("length", 0, [0, 0], False), + ("length", 1, [0], False), + ("length", 1, [0, 0], 1), + ("length", 15, [0, 0], True), + ], +) +def test_dimension_rejections(constrained, kind, value, origin, reference): + r = constrained + with pytest.raises(NXToolError): + r.e._sketch_dimension( + r.ref(r.sk, "sketch"), r.ref(r.lines[0], "curve"), kind, value, origin, reference + ) + assert r.session.ActiveSketch is None + + +@pytest.mark.parametrize("relation", ["parallel", "perpendicular", "equal_length", "coincident"]) +def test_line_relations_restore_activation(constrained, relation): + r = constrained + kw = {"point1": "end", "point2": "start"} if relation == "coincident" else {} + result = r.e._sketch_relation( + r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], relation, **kw + ) + assert result["constraint"] and r.session.ActiveSketch is None + + +@pytest.mark.parametrize( + "relation,points", + [ + ("bad", {}), + ("parallel", {"point1": "start"}), + ("equal_radius", {}), + ("coincident", {}), + ("coincident", {"point1": "center", "point2": "end"}), + ], +) +def test_relation_preflight(constrained, relation, points): + r = constrained + with pytest.raises(NXToolError): + r.e._sketch_relation( + r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], relation, **points + ) + r.sk.CreateParallelConstraint.assert_not_called() + + +def test_conflicting_solver_state_rejected(constrained): + r = constrained + r.e._sketch_diagnostics.return_value["solver_status"] = "OverConstrained" + with pytest.raises(NXToolError, match="conflicts"): + r.e._sketch_relation( + r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], "parallel" + ) + assert r.session.ActiveSketch is None + + +def test_other_active_sketch_and_foreign_curve_rejected(constrained): + r = constrained + r.session.ActiveSketch = Sketch(r.session) + with pytest.raises(NXToolError, match="other active"): + r.e._sketch_dimension( + r.ref(r.sk, "sketch"), r.ref(r.lines[0], "curve"), "length", 10, [0, 0] + ) + with pytest.raises(NXToolError, match="not owned"): + r.e._owned_curve(r.sk, r.ref(Curve(), "curve")) + + +def test_conflict_trials_are_bounded_and_restored(constrained): + r = constrained + constraints = [{"object": {"id": r.ref(Object(), "constraint")}} for _ in range(3)] + r.e._sketch_diagnostics.side_effect = [ + {"solver_status": "OverConstrained", "constraints": constraints, "constraint_count": 3}, + {"solver_status": "UnderConstrained"}, + {"solver_status": "OverConstrained"}, + ] + r.sk.DeleteObjects = Mock(return_value=None) + result = r.e._sketch_conflicts(r.ref(r.sk, "sketch"), 2) + assert result["checked"] == 2 and not result["complete"] + assert result["single_removal_relief"] == [constraints[0]["object"]] + assert not r.session.marks and r.session.ActiveSketch is None + + +@pytest.mark.parametrize("value", [0, 51, True]) +def test_conflict_limits(constrained, value): + with pytest.raises(NXToolError): + constrained.e._sketch_conflicts("missing", value) + + +def test_explicit_legacy_constraint_contradiction_even_if_native_status_undercounted(constrained): + r = constrained + r.nx.Sketch.ConstraintType.Horizontal = 10 + r.nx.Sketch.ConstraintType.Vertical = 11 + a, b = Object(), Object() + a.ConstraintType, b.ConstraintType = 10, 11 + r.sk.constraints = [a, b] + pairs = r.e._explicit_constraint_conflicts(r.sk) + assert len(pairs) == 2 + assert pairs[0]["reason"] == "A nonzero line cannot be both horizontal and vertical" + with pytest.raises(NXToolError, match="conflicts"): + r.e._check_sketch_result(r.ref(r.sk, "sketch")) + + +def test_conflict_cleanup_failure_reports_partial(constrained): + r = constrained + r.session.UndoToMark = Mock(side_effect=RuntimeError("undo failed")) + with pytest.raises(NXToolError) as error: + r.e._sketch_conflicts(r.ref(r.sk, "sketch")) + assert error.value.details["mutation_outcome"] == "partial" + + +def test_relation_noop_is_rejected(constrained): + r = constrained + r.e._relation_residual.return_value = 1.0 + with pytest.raises(NXToolError, match="did not satisfy"): + r.e._sketch_relation( + r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], "parallel" + ) + assert r.session.ActiveSketch is None + + +def test_geometric_relation_residuals(constrained): + from nx_mcp.advanced_authoring import AdvancedAuthoringMixin + + r = constrained + + def residual(a, b, rel, p1=None, p2=None): + return AdvancedAuthoringMixin._relation_residual(r.e, a, b, rel, p1, p2) + + a, b = r.lines + assert residual(a, b, "parallel") == 0 + assert residual(a, b, "perpendicular") == 1 + b.EndPoint = point(0, 20, 0) + assert residual(a, b, "perpendicular") == 0 + assert residual(a, b, "equal_length") == 10 + assert residual(a, b, "coincident", "start", "start") == 0 + r.arc.Radius = 5 + r.arc.CenterPoint = point() + other = type(r.arc)() + other.Radius = 7 + other.CenterPoint = point(0, 0, 2) + assert residual(r.arc, other, "equal_radius") == 2 + assert residual(r.arc, other, "concentric") == 2 + with pytest.raises(NXToolError): + r.e._relation_point(r.arc, "start") + + +@pytest.mark.parametrize( + "relation", + ["parallel", "perpendicular", "equal_length", "equal_radius", "concentric", "coincident"], +) +def test_modern_builder_configuration_and_cleanup(constrained, relation): + r = constrained + builder = NS( + StationaryObject=NS(SetValue=Mock()), + MotionObjects=NS(Add=Mock()), + MotionPoints=NS(Add=Mock()), + SetCreateConstraints=Mock(), + FindRelations=Mock(), + Commit=Mock(), + Destroy=Mock(), + ) + for name in ["Parallel", "Perpendicular", "Equal", "Coincident"]: + setattr(r.part.Sketches, "CreateSketchMake" + name + "Builder", lambda: builder) + r.nx.InferSnapType = NS(SnapType=NS(Start="start", End="end", Center="center")) + r.nx.SketchMakeEqualBuilder = NS(EqualTypes=NS(Radius="radius", Length="length")) + a, b = r.lines + if relation in ["concentric", "equal_radius"]: + a = r.arc + b = type(a)() + a.CenterPoint = b.CenterPoint = point() + r.e._modern_relation(r.sk, a, b, relation, "start", "end") + builder.SetCreateConstraints.assert_called_once_with(True) + builder.Commit.assert_called_once() + builder.Destroy.assert_called_once() diff --git a/tests/test_agent_improvements.py b/tests/test_agent_improvements.py new file mode 100644 index 0000000..753d711 --- /dev/null +++ b/tests/test_agent_improvements.py @@ -0,0 +1,189 @@ +import os +import time +from unittest.mock import AsyncMock + +import pytest + +from nx_mcp.agent_guidance import guidance +from nx_mcp.agent_surface import ResultStore, compact_payload +from nx_mcp.result_retention import maintain +from nx_mcp.server import create_server +from nx_mcp.workspace import Workspace + + +@pytest.mark.asyncio +async def test_guidance_examples_match_real_input_schemas(tmp_path): + from nx_mcp.agent_guidance import GUIDANCE + + server = create_server(AsyncMock(), Workspace(tmp_path), enable_experimental=True) + for name, (_, _, example) in GUIDANCE.items(): + tool = server._tool_manager.get_tool(name) + assert tool is not None + if example is not None: + tool.fn_metadata.arg_model.model_validate(example) + result = guidance(tool) + assert result["native_evidence"]["status"] in {"tested", "experimental", "unavailable"} + assert ( + guidance(server._tool_manager.get_tool("nx_export_step"))["effects"]["saves_part"] is True + ) + assert ( + guidance(server._tool_manager.get_tool("nx_measure_volume"))["effects"][ + "geometry_or_assembly" + ] + is False + ) + + +def test_next_actions_use_returned_ids_without_fabrication(): + ref = {"id": "body_live", "kind": "body", "part_id": "owner"} + result = compact_payload( + {"bodies": [ref], "warnings": ["review"], "mutation_outcome": "committed"}, + "result_id", + "nx_extrude", + ) + assert result["next_actions"][0]["arguments"] == {"body": "body_live"} + assert result["warnings"] == ["review"] + assert "next_actions" not in compact_payload({"status": "error"}, "result_id") + + +def test_retention_dry_run_protection_and_untouched_recovery(tmp_path, monkeypatch): + monkeypatch.setenv("NX_MCP_RESULT_MAX_BYTES", "1000") + store = ResultStore(tmp_path) + old = store.put({"old": "x" * 100}) + path = store.root / (old + ".json") + os.utime(path, (time.time() - 100, time.time() - 100)) + unrelated = store.root / "not-a-snapshot.json" + unrelated.write_text("keep") + receipt = tmp_path / ".nx-mcp/operations/operation.json" + receipt.parent.mkdir() + receipt.write_text("keep") + preview = maintain(store.root, max_age_seconds=10) + assert preview["selected_count"] == 1 and path.exists() + result = maintain(store.root, apply=True, max_age_seconds=10) + assert result["applied"] and not path.exists() + assert unrelated.read_text() == receipt.read_text() == "keep" + current = store.put({"current": "x" * 100}) + result = maintain(store.root, apply=True, max_bytes=1, protect=current) + assert result["selected_count"] == 0 + with pytest.raises(ValueError): + maintain(store.root, max_bytes=0) + with pytest.raises(OSError): + store.put({"large": "x" * 2000}) + + +@pytest.mark.asyncio +async def test_filtered_snapshot_pages_reuse_single_native_read(tmp_path): + refs = [ + {"id": str(i), "kind": "feature", "name": f"Bolt {i}", "part_id": "p"} for i in range(31) + ] + bridge = AsyncMock() + bridge.call.return_value = {"objects": refs, "units": "mm"} + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + first = await server.call_tool( + "nx_inspect", {"tool": "nx_list_features", "name_contains": "bolt", "limit": 10} + ) + value = first.structuredContent + assert not first.isError and value["count"] == 10 and value["total_count"] == 31 + second = await server.call_tool( + "nx_inspect", + { + "tool": "nx_list_features", + "result_id": value["result_id"], + "name_contains": "bolt", + "offset": 10, + "limit": 10, + }, + ) + assert second.structuredContent["items"][0]["id"] == "10" + assert bridge.call.await_count == 1 + assert (await server.call_tool("nx_inspect", {"tool": "nx_extrude"})).isError + assert ( + await server.call_tool( + "nx_inspect", {"tool": "nx_list_sketches", "result_id": value["result_id"]} + ) + ).isError + bad = await server.call_tool("nx_inspect", {"tool": "nx_list_features", "collection": "faces"}) + assert bad.isError + filtered = await server.call_tool( + "nx_inspect", + {"tool": "nx_list_features", "result_id": value["result_id"], "kind": "sketch"}, + ) + assert filtered.structuredContent["count"] == 0 + + +@pytest.mark.asyncio +async def test_annotation_capture_collects_all_pages_and_preserves_frame(tmp_path): + bridge = AsyncMock() + bridge.call.side_effect = [ + { + "items": [{"object": {"id": "a", "name": "First", "kind": "annotation"}}], + "total": 2, + "next_offset": 1, + "position_frame": "sheet", + }, + { + "items": [{"object": {"id": "b", "name": "Second", "kind": "annotation"}}], + "total": 2, + "next_offset": None, + }, + ] + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + response = await server.call_tool( + "nx_inspect", {"tool": "nx_list_annotations", "name_contains": "second"} + ) + assert not response.isError + assert response.structuredContent["total_count"] == 1 + assert response.structuredContent["unfiltered_count"] == 2 + assert response.structuredContent["coordinate_frame"] == "sheet" + + +@pytest.mark.asyncio +async def test_cleanup_tool_defaults_to_preview(tmp_path): + store = ResultStore(tmp_path) + key = store.put({"large": "x" * 100}) + server = create_server( + AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent" + ) + response = await server.call_tool("nx_result_cleanup", {"max_bytes": 1}) + assert response.structuredContent["selected_count"] == 1 + assert store.get(key) + response = await server.call_tool("nx_result_cleanup", {"max_bytes": 1, "dry_run": False}) + assert response.structuredContent["applied"] + with pytest.raises(FileNotFoundError): + store.get(key) + + +def test_backend_page_cardinality_is_not_silently_truncated(): + refs = [{"id": str(i), "kind": "part"} for i in range(38)] + payload = compact_payload( + {"parts": refs, "count": 38, "total_count": 38, "next_offset": None}, "result_id" + ) + assert len(payload["parts"]) == payload["count"] == 38 + assert payload["next_offset"] is None + assert "/parts" not in payload.get("omitted", {}) + assert len(compact_payload({"objects": refs}, "result_id")["objects"]) == 20 + + +def test_next_action_does_not_repeat_the_current_inspection(): + from nx_mcp.agent_guidance import next_actions + + assert not next_actions("nx_list_topology", {"body": {"id": "body", "kind": "body"}}) + assert not next_actions("nx_download_file", {"path": "a.png", "sha256": "x"}) + + +@pytest.mark.asyncio +async def test_spaced_discovery_and_optional_output_schema(tmp_path): + server = create_server( + AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent" + ) + result = await server.call_tool( + "nx_discover_tools", + {"query": "create part", "include_schema": True, "include_output_schema": False}, + ) + rows = result.structuredContent["tools"] + assert rows[0]["name"] == "nx_create_part" + assert "inputSchema" in rows[0] and "outputSchema" not in rows[0] + result = await server.call_tool( + "nx_discover_tools", {"query": "nx_create_part", "include_schema": True} + ) + assert "outputSchema" in result.structuredContent["tools"][0] diff --git a/tests/test_agent_surface.py b/tests/test_agent_surface.py new file mode 100644 index 0000000..0702490 --- /dev/null +++ b/tests/test_agent_surface.py @@ -0,0 +1,279 @@ +"""Agent-profile contracts across a real in-memory MCP boundary (no NX kernel).""" + +from unittest.mock import AsyncMock + +import pytest +from mcp.shared.memory import create_connected_server_and_client_session + +from nx_mcp.agent_surface import ResultStore, compact +from nx_mcp.server import create_server +from nx_mcp.workspace import Workspace + + +def test_projection_preserves_identity_warnings_and_marks_truncation(): + omitted = {} + ref = { + "id": "opaque", + "kind": "component", + "part_id": "owner", + "occurrence_path": "root/child", + "journal_id": "long", + } + value = compact( + {"objects": [ref] * 25, "warnings": ["w"] * 30, "data_base64": "secret"}, omitted=omitted + ) + assert len(value["objects"]) == 20 and len(value["warnings"]) == 30 + assert value["objects"][0]["occurrence_path"] == "root/child" + assert "journal_id" not in value["objects"][0] + assert "data_base64" not in value and omitted["/objects"]["total_count"] == 25 + + +def test_snapshot_persistence_and_path_rejection(tmp_path): + store = ResultStore(tmp_path) + identity = store.put({"answer": 42}) + assert ResultStore(tmp_path).get(identity) == {"answer": 42} + with pytest.raises(ValueError): + store.get("../../secret") + + +@pytest.mark.asyncio +async def test_discovery_defaults_dispatch_and_expansion(tmp_path): + bridge = AsyncMock() + bridge.call.return_value = { + "status": "success", + "parts": [], + "count": 0, + "warnings": [], + "units": "mm", + } + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + async with create_connected_server_and_client_session(server) as client: + tools = (await client.list_tools()).tools + assert len(tools) == 13 + assert "nx_extrude" not in {t.name for t in tools} + result = await client.call_tool( + "nx_discover_tools", {"query": "nx_extrude", "include_schema": True} + ) + assert result.structuredContent["total_count"] == 1 + assert "distance" in result.structuredContent["tools"][0]["inputSchema"]["properties"] + result = await client.call_tool("nx_list_open_parts", {}) + assert not result.isError + assert bridge.call.call_args.args[1]["limit"] == 20 + assert bridge.call.call_args.args[1]["compact"] is True + snapshot = await client.call_tool( + "nx_result", {"result_id": result.structuredContent["result_id"], "field": "/parts"} + ) + assert snapshot.structuredContent["items"] == [] + bridge.call.reset_mock() + bad = await client.call_tool( + "nx_invoke", {"tool": "nx_extrude", "arguments": {"bogus": True}} + ) + assert bad.isError and not bridge.call.called + good = await client.call_tool( + "nx_invoke", {"tool": "nx_list_open_parts", "arguments": {}, "detail": "full"} + ) + assert "result_id" not in good.structuredContent + assert bridge.call.call_args.args[1]["limit"] is None + + +@pytest.mark.asyncio +async def test_artifact_metadata_resource_and_reserved_path(tmp_path): + (tmp_path / "test.txt").write_bytes(b"binary bytes") + server = create_server( + AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent" + ) + async with create_connected_server_and_client_session(server) as client: + result = await client.call_tool("nx_download_file", {"path": "test.txt"}) + assert not result.isError and "data_base64" not in result.structuredContent + link = next(c for c in result.content if c.type == "resource_link") + artifact = await client.read_resource(link.uri) + assert artifact.contents[0].blob + assert len((await client.list_resource_templates()).resourceTemplates) == 1 + bad = await client.call_tool("nx_download_file", {"path": ".nx-mcp/secret"}) + assert bad.isError + + +@pytest.mark.asyncio +async def test_failed_mutation_retains_outcome_and_operation_id(tmp_path): + bridge = AsyncMock() + bridge.call.return_value = { + "status": "error", + "code": "NX_TEST", + "message": "failed", + "warnings": ["important"], + "operation_id": "test_operation", + "mutation_outcome": "rolled_back", + } + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + result = await server.call_tool( + "nx_invoke", + { + "tool": "nx_extrude", + "arguments": {"sketch_id": "x", "distance": 1, "operation_id": "test_operation"}, + }, + ) + assert result.isError and result.structuredContent["mutation_outcome"] == "rolled_back" + assert result.structuredContent["operation_id"] == "test_operation" + + +@pytest.mark.asyncio +async def test_compact_success_replay_receipt_and_full_snapshot(tmp_path): + bridge = AsyncMock() + ref = {"id": "a", "kind": "body", "part_id": "p", "session_id": "s"} + bridge.call.return_value = { + "status": "success", + "units": "mm", + "warnings": ["check"], + "operation_id": "operation_123", + "mutation_outcome": "committed", + "bodies": [ref] * 25, + "changes": {"created": [ref] * 25, "modified": None, "deleted": []}, + } + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + result = await server.call_tool( + "nx_invoke", + { + "tool": "nx_extrude", + "arguments": {"sketch_id": "s", "distance": 2, "operation_id": "operation_123"}, + }, + ) + payload = result.structuredContent + assert payload["change_counts"] == {"created": 25, "modified": None, "deleted": 0} + assert payload["mutation_outcome"] == "committed" and payload["warnings"] == ["check"] + bridge.call.reset_mock() + expanded = await server.call_tool( + "nx_result", {"result_id": payload["result_id"], "field": "/bodies", "offset": 20} + ) + assert len(expanded.structuredContent["items"]) == 5 + assert expanded.structuredContent["items"][0]["session_id"] == "s" + expanded = await server.call_tool( + "nx_result", {"result_id": payload["result_id"], "detail": "full"} + ) + assert len(expanded.structuredContent["value"]["bodies"]) == 25 + assert not bridge.call.called + for args in [{"field": "bodies"}, {"field": "/absent"}, {"offset": -1}, {"limit": 101}]: + bad = await server.call_tool("nx_result", {"result_id": payload["result_id"], **args}) + assert bad.isError + + +@pytest.mark.asyncio +async def test_storage_failure_does_not_relabel_committed_mutation(tmp_path, monkeypatch): + bridge = AsyncMock() + bridge.call.return_value = { + "status": "success", + "mutation_outcome": "committed", + "operation_id": "operation_123", + } + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + + def fail(*args): + raise OSError("full disk") + + monkeypatch.setattr(ResultStore, "put", fail) + result = await server.call_tool( + "nx_invoke", + { + "tool": "nx_extrude", + "arguments": {"sketch_id": "s", "distance": 2, "operation_id": "operation_123"}, + }, + ) + assert not result.isError and result.structuredContent["mutation_outcome"] == "committed" + + +@pytest.mark.asyncio +async def test_discovery_paging_and_argument_errors(tmp_path): + server = create_server( + AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent" + ) + for args in [{"limit": 21}, {"offset": -1}, {"domain": "wrong"}]: + assert (await server.call_tool("nx_discover_tools", args)).isError + result = await server.call_tool( + "nx_discover_tools", {"limit": 2, "offset": 2, "domain": "modeling"} + ) + assert len(result.structuredContent["tools"]) == 2 + assert result.structuredContent["next_offset"] == 4 + assert (await server.call_tool("nx_invoke", {"tool": "missing", "arguments": {}})).isError + assert (await server.call_tool("missing", {})).isError + + +def test_profile_validation(tmp_path): + with pytest.raises(ValueError): + create_server(surface="typo") + with pytest.raises(ValueError): + create_server(surface="agent", enable_experimental=False) + + +def test_dual_http_profiles(tmp_path, monkeypatch): + from starlette.testclient import TestClient + + from nx_mcp.http_surface import create_app + + monkeypatch.setenv("NX_MCP_ENABLE_EXPERIMENTAL", "1") + app = create_app(AsyncMock(), Workspace(tmp_path)) + with TestClient(app, base_url="http://127.0.0.1:8765") as client: + for path, count in [("/mcp", 189), ("/agent/mcp", 13)]: + response = client.post( + path, + headers={"Accept": "application/json, text/event-stream"}, + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + ) + assert response.status_code == 200 + assert len(response.json()["result"]["tools"]) == count + + +@pytest.mark.asyncio +async def test_resource_traversal_and_size_limit(tmp_path): + from mcp.server.fastmcp.exceptions import ResourceError + + server = create_server( + AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent" + ) + (tmp_path / ".nx-mcp").mkdir() + (tmp_path / ".nx-mcp" / "secret").write_text("secret") + large = tmp_path / "large.bin" + with large.open("wb") as stream: + stream.truncate(8 * 1024 * 1024 + 1) + for uri in [ + "nx-artifact://workspace/.nx-mcp%2Fsecret", + "nx-artifact://workspace/large.bin", + "nx-artifact://workspace/..%2Fsecret", + ]: + with pytest.raises((ResourceError, ValueError)): + await server.read_resource(uri) + + +@pytest.mark.asyncio +async def test_gateway_rejects_ignored_top_level_arguments(tmp_path): + bridge = AsyncMock() + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent") + result = await server.call_tool( + "nx_invoke", + { + "tool": "nx_extrude", + "arguments": {"sketch_id": "s", "distance": 2}, + "operation_id": "wrong_place", + }, + ) + assert result.isError and not bridge.call.called + + +def test_benchmark_uses_shared_projection_and_exposes_usage_limitations(): + import runpy + from pathlib import Path + from types import SimpleNamespace + + script = runpy.run_path( + str(Path(__file__).resolve().parents[1] / "scripts/benchmark_agent_surface.py") + ) + encoding = SimpleNamespace(name="test", encode=lambda value, **kwargs: list(value)) + result = script["benchmark"]( + [ + {"state": "submitted"}, + {"state": "response", "result": {"status": "success", "data_base64": "x" * 10000}}, + ], + encoding, + ) + assert result["recorded_calls"] == 1 and result["binary_transfer_calls"] == 1 + assert result["compact_response_tokens"] < result["full_response_tokens"] + assert result["provider_input_tokens"] == "unavailable" + assert result["model_calls"] == 0 diff --git a/tests/test_artifact_recovery.py b/tests/test_artifact_recovery.py new file mode 100644 index 0000000..fe0bdb4 --- /dev/null +++ b/tests/test_artifact_recovery.py @@ -0,0 +1,276 @@ +"""Artifact integrity, interrupted uploads and publication boundaries.""" + +import base64 +import hashlib +import json +import zipfile +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from nx_mcp.integration_server import artifact_call, package_assembly +from nx_mcp.recovery import OperationStore +from nx_mcp.runtime import NXToolError +from nx_mcp.server import create_server +from nx_mcp.workspace import Workspace + + +def upload_params(**overrides): + return { + "path": "fixture.step", + "offset": 0, + "total_size": 4, + "sha256": hashlib.sha256(b"abcd").hexdigest(), + "data_base64": base64.b64encode(b"ab").decode(), + **overrides, + } + + +@pytest.mark.parametrize( + "override,code", + [ + ({"path": "script.exe"}, "NX_UNSUPPORTED_FILE_TYPE"), + ({"offset": -1}, "NX_INVALID_ARGUMENT"), + ({"total_size": 300 * 1024 * 1024}, "NX_INVALID_ARGUMENT"), + ({"sha256": "x" * 64}, "NX_INVALID_ARGUMENT"), + ({"sha256": "a"}, "NX_INVALID_ARGUMENT"), + ({"data_base64": base64.b64encode(b"abcde").decode()}, "NX_INVALID_ARGUMENT"), + ({"offset": 2}, "NX_UPLOAD_GAP"), + ], +) +def test_upload_rejects_invalid_chunks_without_publishing(tmp_path, override, code): + with pytest.raises(NXToolError) as error: + artifact_call("nx_upload_file", upload_params(**override), Workspace(tmp_path)) + assert error.value.code == code + assert not (tmp_path / "fixture.step").exists() + + +def test_interrupted_upload_conflict_gap_and_checksum_do_not_publish(tmp_path): + w = Workspace(tmp_path) + p = upload_params() + assert not artifact_call("nx_upload_file", p, w)["committed"] + for override, code in [ + ({"data_base64": base64.b64encode(b"xx").decode()}, "NX_UPLOAD_CONFLICT"), + ({"offset": 3, "data_base64": base64.b64encode(b"d").decode()}, "NX_UPLOAD_GAP"), + ({"offset": 2, "data_base64": base64.b64encode(b"xx").decode()}, "NX_CHECKSUM_MISMATCH"), + ]: + with pytest.raises(NXToolError) as error: + artifact_call("nx_upload_file", dict(p, **override), w) + assert error.value.code == code and not (tmp_path / "fixture.step").exists() + # Recovery uses a corrected digest; bytes from an invalid upload cannot be silently reused. + corrected = b"zzzz" + q = upload_params( + sha256=hashlib.sha256(corrected).hexdigest(), + data_base64=base64.b64encode(corrected).decode(), + ) + assert artifact_call("nx_upload_file", q, w)["committed"] + assert (tmp_path / "fixture.step").read_bytes() == corrected + + +def test_workspace_metadata_and_cancellation_are_scoped(tmp_path): + w = Workspace(tmp_path) + (tmp_path / "a.txt").write_text("hello") + (tmp_path / "sub").mkdir() + store = OperationStore(tmp_path) + store.put({"operation_id": "batch-operation", "state": "running", "method": "nx_batch"}) + assert artifact_call("nx_cancel_operation", {"operation_id": "batch-operation"}, w)[ + "cancellation_requested" + ] + assert store.path("batch-operation").with_suffix(".cancel").exists() + with pytest.raises(NXToolError): + artifact_call("nx_cancel_operation", {"operation_id": "not-seen"}, w) + assert ( + artifact_call("nx_operation_status", {"operation_id": "batch-operation"}, w)["state"] + == "running" + ) + listed = artifact_call("nx_workspace_list", {"path": "."}, w) + assert [v["kind"] for v in listed["entries"]] == ["file", "directory"] + assert listed["entries"][0]["sha256"] == hashlib.sha256(b"hello").hexdigest() + for offset, length in [(-1, 1), (0, 0), (0, 262145)]: + with pytest.raises(NXToolError): + artifact_call( + "nx_download_file", {"path": "a.txt", "offset": offset, "length": length}, w + ) + + +@pytest.mark.asyncio +async def test_package_dependency_manifest_and_no_overwrite(tmp_path): + files = [tmp_path / "assembly.prt", tmp_path / "prototype.prt"] + for p in files: + p.write_text(p.stem) + part = {"path": str(files[0]), "work": True, "modified": False} + component = {"part_path": str(files[1]), "translation": [1, 2, 3]} + + async def call(method, params): + return ( + {"parts": [part]} + if method == "nx_list_open_parts" + else {"components": [component, component], "count": 2} + ) + + bridge = AsyncMock() + bridge.call.side_effect = call + result = await package_assembly(bridge, Workspace(tmp_path), "assembly.zip") + assert result["component_instances"] == 2 and result["prototype_files"] == 1 + with zipfile.ZipFile(tmp_path / "assembly.zip") as z: + manifest = json.loads(z.read("nx-assembly-manifest.json")) + assert len(manifest["files"]) == 2 and manifest["components"]["count"] == 2 + assert z.read("prototype.prt") == b"prototype" + before = (tmp_path / "assembly.zip").read_bytes() + with pytest.raises(FileExistsError): + await package_assembly(bridge, Workspace(tmp_path), "assembly.zip") + assert (tmp_path / "assembly.zip").read_bytes() == before + assert not list((tmp_path / ".nx-mcp").glob("package-*")) + part["modified"] = True + with pytest.raises(NXToolError, match="Save referenced"): + await package_assembly(bridge, Workspace(tmp_path), "dirty.zip") + part["modified"] = False + files[1].unlink() + with pytest.raises(NXToolError): + await package_assembly(bridge, Workspace(tmp_path), "missing.zip") + with pytest.raises(NXToolError): + await package_assembly(bridge, Workspace(tmp_path), "wrong.txt") + + +@pytest.mark.asyncio +async def test_mcp_paths_are_validated_before_bridge_dispatch(tmp_path): + bridge = AsyncMock() + bridge.call.return_value = {"status": "success"} + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True) + result = await server.call_tool("nx_import_geometry", {"path": "../secret.step"}) + assert result.isError and result.structuredContent["code"] == "NX_PATH_OUTSIDE_WORKSPACE" + bridge.call.assert_not_called() + result = await server.call_tool( + "nx_import_geometry", {"path": "part.step", "target": "new_part", "output_path": "new.prt"} + ) + assert not result.isError + params = bridge.call.call_args.args[1] + assert Path(params["output_path"]) == tmp_path / "new.prt" + await server.call_tool( + "nx_batch", + {"operations": [{"method": "nx_add_component", "params": {"part_path": "p.prt"}}]}, + ) + assert ( + Path(bridge.call.call_args.args[1]["operations"][0]["params"]["part_path"]) + == tmp_path / "p.prt" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("kind", ["valid", "changed", "large"]) +@pytest.mark.parametrize("method", ["nx_screenshot", "nx_render_view"]) +async def test_inline_capture_delivery_checks_committed_artifact(tmp_path, kind, method): + p = tmp_path / "capture.png" + data = b"png" if kind != "large" else b"x" * (8 * 1024 * 1024 + 1) + p.write_bytes(data) + result = { + "status": "success", + "path": str(p), + "sha256": hashlib.sha256(data).hexdigest() if kind != "changed" else "0" * 64, + } + bridge = AsyncMock() + bridge.call.return_value = result + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True) + response = await server.call_tool(method, {"path": "capture.png"}) + images = [v for v in response.content if v.type == "image"] + if kind == "valid": + assert base64.b64decode(images[0].data) == data + elif kind == "large": + assert not images and response.structuredContent["warnings"] + else: + assert ( + response.isError + and response.structuredContent["details"]["mutation_outcome"] == "committed" + ) + + +def test_folder_discovery_creation_and_safe_retry(tmp_path): + w = Workspace(tmp_path) + assert artifact_call("nx_workspace_info", {}, w)["root"] == str(tmp_path) + destination = tmp_path / "projects" / "controller" / "parts" + first = artifact_call("nx_create_directory", {"path": str(destination)}, w) + assert first["created"] and destination.is_dir() + repeated = artifact_call("nx_create_directory", {"path": "projects/controller/parts"}, w) + assert not repeated["created"] and repeated["path"] == first["path"] + (destination / "base.prt").write_bytes(b"fixture") + with pytest.raises(NXToolError) as error: + artifact_call("nx_create_directory", {"path": str(destination / "base.prt")}, w) + assert error.value.code == "NX_DIRECTORY_ERROR" + assert (destination / "base.prt").read_bytes() == b"fixture" + + +@pytest.mark.asyncio +async def test_folder_tools_are_exposed_and_absolute_part_paths_are_forwarded(tmp_path): + bridge = AsyncMock() + bridge.call.return_value = {"path": str(tmp_path / "projects" / "base.prt")} + server = create_server(bridge=bridge, workspace=Workspace(tmp_path), enable_experimental=True) + info = await server.call_tool("nx_workspace_info", {}) + assert info.structuredContent["root"] == str(tmp_path) + created = await server.call_tool("nx_create_directory", {"path": "projects/parts"}) + assert created.structuredContent["created"] + destination = str(tmp_path / "projects" / "base.prt") + opened = await server.call_tool("nx_open_part", {"path": destination}) + assert not opened.isError + assert bridge.call.call_args.args[1]["path"] == destination + + +@pytest.mark.asyncio +async def test_inline_image_metadata_paging_and_output_contract(tmp_path): + import struct + + from mcp.types import ImageContent + + png = b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", 1, 1) + (tmp_path / "view.png").write_bytes(png) + for name in ["a.txt", "b.txt", "c.txt"]: + (tmp_path / name).write_text("artifact") + bridge = AsyncMock() + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True) + tools = {t.name: t for t in await server.list_tools()} + schema = tools["nx_download_file"].inputSchema["properties"] + assert schema["length"]["maximum"] == 262144 and schema["offset"]["minimum"] == 0 + assert tools["nx_download_file"].outputSchema["required"] == ["status", "warnings", "units"] + preview = await server.call_tool("nx_download_file", {"path": "view.png", "delivery": "image"}) + assert not preview.isError and preview.structuredContent["resolution"] == [1, 1] + assert "data_base64" not in preview.structuredContent + images = [v for v in preview.content if isinstance(v, ImageContent)] + assert len(images) == 1 and base64.b64decode(images[0].data) == png + assert png.hex() not in preview.content[0].text + meta = await server.call_tool("nx_download_file", {"path": "view.png", "delivery": "metadata"}) + assert meta.structuredContent["size"] == len(png) and len(meta.content) == 1 + page = await server.call_tool("nx_workspace_list", {"limit": 2}) + assert page.structuredContent["count"] == 2 and page.structuredContent["next_offset"] == 2 + second = await server.call_tool("nx_workspace_list", {"offset": 2, "limit": 2}) + assert second.structuredContent["next_offset"] is None + filtered = await server.call_tool("nx_workspace_list", {"prefix": "v"}) + assert filtered.structuredContent["total_count"] == 1 + error = await server.call_tool("nx_workspace_list", {"path": "view.png"}) + assert error.isError and error.structuredContent["code"] == "NX_NOT_DIRECTORY" + for args in [ + {"length": 262145}, + {"delivery": "image", "offset": 1}, + {"delivery": "image", "path": "a.txt"}, + ]: + assert (await server.call_tool("nx_download_file", {"path": "view.png", **args})).isError + bridge.call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_missing_directory_and_boolean_operand_contract(tmp_path): + bridge = AsyncMock() + bridge.call.return_value = {"status": "success"} + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True) + missing = await server.call_tool("nx_workspace_list", {"path": "missing"}) + assert missing.isError and missing.structuredContent["code"] == "NX_DIRECTORY_NOT_FOUND" + tools = {t.name: t for t in await server.list_tools()} + schema = tools["nx_boolean"].inputSchema["properties"] + assert schema["boolean_type"]["enum"] == ["unite", "subtract", "intersect"] + assert schema["targets"]["minItems"] == 2 + invalid = await server.call_tool("nx_boolean", {"boolean_type": "subtract", "targets": ["a"]}) + assert invalid.isError + bridge.call.assert_not_awaited() + await server.call_tool( + "nx_boolean", {"boolean_type": "subtract", "targets": ["target", "tool"]} + ) + assert bridge.call.call_args.args[1]["targets"] == ["target", "tool"] diff --git a/tests/test_assembly_loading.py b/tests/test_assembly_loading.py new file mode 100644 index 0000000..f04b687 --- /dev/null +++ b/tests/test_assembly_loading.py @@ -0,0 +1,91 @@ +"""Regressions for unloaded prototypes and drawing-owned construction curves.""" + +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.assembly_loading import component_info, load_components, require_loaded +from nx_mcp.runtime import NXToolError +from tests.fakes import Component, Object, Part + + +def test_unloaded_component_inventory_retains_pose_and_path(rig): + root = Component("root") + child = Component("missing", parent=root) + child.Prototype = Object("unloaded") + root.children = [child] + rig.part.ComponentAssembly.RootComponent = root + rig.uf.Assem = NS(AskComponentData=lambda _: ("D:/model/child.prt", "", "", [], [], [])) + result = rig.e._list_components(compact=True) + row = result["components"][0] + assert row["part_path"] == "D:/model/child.prt" + assert row["load_state"] == "unloaded" + assert len(row["translation"]) == 3 + with pytest.raises(NXToolError) as error: + require_loaded(rig.e, rig.part) + assert error.value.details["mutation_outcome"] == "not_started" + + +def test_unknown_prototype_path_is_explicit(rig): + rig.uf.Assem = NS(AskComponentData=Mock(side_effect=RuntimeError("Missing part"))) + info = component_info(NS(Prototype=None, Tag=1)) + assert info["part_path"] is None and info["load_state"] == "unloaded" + assert info["load_diagnostic"] == "Missing part" + + +def test_close_referenced_prototype_rejected_before_save(rig, tmp_path): + parent = rig.part + child_part = Part(rig.session, tmp_path / "child.prt") + root = Component("root") + child = Component("child", parent=root) + child.Prototype = child_part + root.children = [child] + parent.ComponentAssembly.RootComponent = root + child_part.Save = Mock() + with pytest.raises(NXToolError) as error: + rig.e._close_part() + assert error.value.code == "NX_PART_IN_USE" + assert error.value.details["parent_assemblies"] == [parent.FullPath] + child_part.Save.assert_not_called() + assert child_part in rig.session.Parts + + +def test_component_loading_restores_options_on_failure(rig): + c = NS(Tag=2, IsSuppressed=False) + rig.e._walk_components = lambda _: [(c, ["missing"])] + options = NS(UsePartialLoading=True) + rig.session.Parts.LoadOptions = options + status = NS( + NumberUnloadedParts=1, + GetPartName=lambda _: "missing.prt", + GetStatus=lambda _: 123, + Dispose=Mock(), + ) + rig.part.ComponentAssembly.OpenOption = NS(ComponentOnly=1) + rig.part.ComponentAssembly.OpenComponents = Mock(return_value=(status, [])) + with pytest.raises(NXToolError) as error: + load_components(rig.e, rig.part) + assert options.UsePartialLoading + status.Dispose.assert_called_once() + assert error.value.code == "NX_COMPONENT_LOAD_FAILED" + assert error.value.details["mutation_outcome"] == "partial" + + +def test_construction_hiding_preserves_sheet_owned_section_curves(rig): + model, section = Object("model"), Object("section line") + rig.part.Curves = [model, section] + rig.e._datum_objects = lambda: [] + rig.uf.View = NS(AskViewDependentStatus=lambda tag: (int(tag == section.Tag), "Sheet")) + view = NS(DependentDisplay=NS(Erase=Mock()), SetAttribute=Mock()) + assert rig.e._drawing_construction_visibility(view, False) == 1 + view.DependentDisplay.Erase.assert_called_once_with([model]) + + +def test_invisible_drawing_font_is_discoverable(): + from nx_mcp.documentation_editing_server import ViewStyle + + style = ViewStyle(hidden_lines=True, self_hidden=True, hidden_font=0) + assert style.hidden_font == 0 + with pytest.raises(ValueError): + ViewStyle(hidden_font=-1) diff --git a/tests/test_authoring_contracts.py b/tests/test_authoring_contracts.py new file mode 100644 index 0000000..9ab3edd --- /dev/null +++ b/tests/test_authoring_contracts.py @@ -0,0 +1,328 @@ +"""Builder arguments, coordinate frames and transaction contracts, not kernel tests.""" + +import math +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.hardened import IDENTITY, matmul, rows +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Component, Curve, Feature, Sketch, point + +pytestmark = pytest.mark.fake_nx + + +def setup_sketch_builder(rig, mismatch=False, fail=False): + part = rig.part + built = [] + part.CoordinateSystems = NS(CreateCoordinateSystem=lambda p, m, _: NS(Origin=p, Orientation=m)) + + def builder(_): + b = NS(Destroy=Mock()) + + def commit(): + if fail: + raise RuntimeError("builder failed") + sk = Sketch(rig.session) + sk.Origin = b.Csystem.Origin + sk.Orientation.Element = b.Csystem.Orientation + if mismatch: + sk.Origin = point(99, 99, 99) + part.Sketches.append(sk) + return sk + + b.Commit = commit + built.append(b) + return b + + part.Sketches.CreateSketchInPlaceBuilder2 = builder + + def line(start, end): + c = Curve() + c.StartPoint = start + c.EndPoint = end + part.Curves.append(c) + return c + + def arc(center, x, y, r, start, end): + c = Curve() + c.CenterPoint = center + c.Radius = r + part.Curves.append(c) + return c + + part.Curves.CreateLine = line + part.Curves.CreateArc = arc + return built + + +@pytest.mark.parametrize( + "plane,normal,end", + [("XY", [0, 0, 1], [3, 4, 0]), ("XZ", [0, -1, 0], [3, 0, 4]), ("YZ", [1, 0, 0], [0, 3, 4])], +) +def test_principal_sketch_curve_mapping(rig, plane, normal, end): + built = setup_sketch_builder(rig) + result = rig.e.execute("nx_create_sketch", {"plane": plane, "name": "profile"}) + assert result["frame"]["normal"] == normal + sk = rig.session.ActiveSketch + rig.e._create_sketch_line(sk, rig.part, {"x": 0, "y": 0}, {"x": 3, "y": 4}) + rig.e._sketch_arc_legacy(1, 2, 3, 0, 360, result["object"]["id"]) + info = rig.e._sketch_info(result["object"]["id"]) + assert info["curves"][0]["end"] == end and info["curve_count"] == 2 + assert info["curves"][1]["radius"] == 3 + assert built[0].Destroy.call_count == 1 + + +def test_arbitrary_basis_and_failed_frame_verification_roll_back(rig): + setup_sketch_builder(rig) + result = rig.e._create_sketch(origin=[1, 2, 3], x_axis=[0, 1, 0], y_axis=[-1, 0, 0]) + sk = rig.session.ActiveSketch + p = rig.e._point_on_sketch(sk, {"x": 2, "y": 5}) + assert [p.X, p.Y, p.Z] == [-4, 4, 3] + assert result["frame"]["normal"] == [0, 0, 1] + before = len(rig.part.Sketches) + built = setup_sketch_builder(rig, mismatch=True) + with pytest.raises(NXToolError) as error: + rig.e.execute("nx_create_sketch", {}) + assert error.value.code == "NX_FRAME_MISMATCH" and len(rig.part.Sketches) == before + assert built[0].Destroy.call_count == 1 + + +@pytest.mark.parametrize( + "params", + [ + {"plane": "bad"}, + {"x_axis": [1, 0, 0]}, + {"x_axis": [2, 0, 0], "y_axis": [0, 1, 0]}, + {"x_axis": [1, 0, 0], "y_axis": [1, 0, 0]}, + {"origin": [0, float("inf"), 0]}, + ], +) +def test_sketch_basis_preflight(rig, params): + built = setup_sketch_builder(rig) + with pytest.raises(NXToolError): + rig.e._create_sketch(**params) + assert not built + + +def test_curve_owner_and_arc_limits(rig): + setup_sketch_builder(rig) + sk = Sketch(rig.session) + with pytest.raises(NXToolError): + rig.e._create_sketch_line(sk, rig.part, {"x": 0, "y": 0}, {"x": 1, "y": 1}) + with pytest.raises(NXToolError): + rig.e._sketch_arc_legacy(0, 0, 1, 0, 90) + rig.session.ActiveSketch = sk + for radius, end in [(0, 90), (1, 0), (1, 361), (math.inf, 90)]: + with pytest.raises(NXToolError): + rig.e._sketch_arc_legacy(0, 0, radius, 0, end) + with pytest.raises(NXToolError): + rig.e._extrude("unresolved", float("nan")) + + +def feature_builders(rig): + f = Feature() + rig.part.Features.append(f) + + def expr(): + return NS(RightHandSide="") + + spacing = NS(NCopies=expr(), PitchDistance=expr()) + b = NS( + Limits=NS(EndExtend=NS(Value=expr())), + PatternService=NS(RectangularDefinition=NS(XSpacing=spacing, YSpacing=NS(NCopies=expr()))), + FeatureList=NS(Add=Mock()), + CommitFeature=Mock(return_value=f), + Destroy=Mock(), + ) + rig.part.Features.CreateExtrudeBuilder = lambda _: b + rig.part.Features.CreatePatternFeatureBuilder = lambda _: b + rig.nx.Features.PatternFeatureBuilder = NS(PatternMethodOptions=NS(Simple="simple")) + rig.nx.Features.Feature.Null = None + rig.nx.GeometricUtilities = NS(PatternDefinition=NS(PatternEnum=NS(Linear="linear"))) + import sys + + sys.modules["NXOpen.GeometricUtilities"] = rig.nx.GeometricUtilities + rig.nx.SmartObject = NS(UpdateOption=NS(WithinModeling="model")) + rig.part.Directions = NS(CreateDirection=lambda p, v, _: v) + rig.e._active_mark = 1 + return f, b + + +def test_supported_feature_edits_and_native_pattern_parameters(rig): + f, b = feature_builders(rig) + ref = rig.ref(f, "feature") + info = rig.e._edit_feature(ref, {"distance": 46.25}) + assert b.Limits.EndExtend.Value.RightHandSide == "46.25" and info["modified"] == [ + info["feature"] + ] + f.FeatureType = "PATTERN_FEATURE" + rig.e._edit_feature(ref, {"count": 16, "spacing": 16.5}) + assert b.PatternService.RectangularDefinition.XSpacing.NCopies.RightHandSide == "16" + result = rig.e._pattern([ref], direction="-Y", count=16, spacing=16.5) + assert result["count_includes_seed"] and result["count"] == 16 + assert b.PatternService.RectangularDefinition.XDirection.Y == -1 + assert b.Destroy.call_count == 3 + rig.session.UpdateManager.DoUpdate.return_value = 1 + with pytest.raises(NXToolError, match="update errors"): + rig.e._edit_feature(ref, {"count": 3}) + + +@pytest.mark.parametrize("params", [{}, {"bad": 1}, {"distance": 0}, {"distance": float("inf")}]) +def test_unsupported_feature_edits_leave_builder_uncommitted(rig, params): + f, b = feature_builders(rig) + with pytest.raises(NXToolError): + rig.e._edit_feature(rig.ref(f, "feature"), params) + b.CommitFeature.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"pattern_type": "circular"}, + {"count": True}, + {"count": 1}, + {"spacing": 0}, + {"direction": "north"}, + ], +) +def test_pattern_validation_before_builder(rig, kwargs): + _, b = feature_builders(rig) + with pytest.raises(NXToolError): + rig.e._pattern([], **kwargs) + b.CommitFeature.assert_not_called() + + +def test_transform_readback_and_relative_composition(rig, tmp_path): + root = Component("root") + c = Component("instance", parent=root) + rig.part.ComponentAssembly.RootComponent = root + + def move(obj, delta, rotation): + obj.position = point( + obj.position.X + delta.X, obj.position.Y + delta.Y, obj.position.Z + delta.Z + ) + obj.rotation = rig.e._nx_matrix(matmul(rows(rotation), rows(obj.rotation))) + + rig.part.ComponentAssembly.MoveComponent = move + ref = rig.ref(c, "component") + r = [[0, -1, 0], [1, 0, 0], [0, 0, 1]] + result = rig.e._set_component_transform(ref, [1, 2, 3], r) + assert result["translation"] == [1, 2, 3] and result["rotation_matrix"] == r + result = rig.e._reposition_component(ref, dx=4, rz=90) + assert result["translation"] == [5, 2, 3] and abs(result["rotation_matrix"][0][0] + 1) < 1e-8 + rig.part.ComponentAssembly.MoveComponent = Mock() + with pytest.raises(NXToolError) as error: + rig.e._set_component_transform(ref, [9, 9, 9], IDENTITY) + assert error.value.code == "NX_PLACEMENT_MISMATCH" + c.Parent = Component("other") + with pytest.raises(NXToolError): + rig.e._set_component_transform(ref, [0, 0, 0], IDENTITY) + c.Parent = root + rig.part.ComponentAssembly.AddComponent = lambda *args: (c, NS(Dispose=Mock())) + assert rig.e._add_component(str(tmp_path / "proto.prt"), translation=[1, 2, 3])[ + "translation" + ] == [1, 2, 3] + assert rig.e._list_components()["count"] == 1 + new = rig.e._rename_object(ref, "renamed")["object"]["id"] + assert new != ref and c.Name == "renamed" + with pytest.raises(NXToolError): + rig.e._rename_object(new, "") + + +@pytest.mark.parametrize("rotation", [[], [[1, 0, 0]] * 3, [[1, 0, 0], [0, 1, 0], [0, 0, -1]]]) +def test_rotation_rejects_scale_shear_or_reflection(rig, rotation): + with pytest.raises(NXToolError): + rig.e._validate_rotation(rotation) + + +def test_batch_preflight_cancellation_and_progress(rig): + rig.e._current_operation = "batch-operation" + rig.e.store.put({"operation_id": "batch-operation", "state": "running"}) + calls = [] + rig.e._handlers["nx_sketch_line"] = lambda start, end: calls.append((start, end)) or {} + ops = [{"method": "nx_sketch_line", "params": {"start": 1, "end": 2}}] * 2 + assert rig.e._batch(ops)["count"] == 2 and len(calls) == 2 + assert rig.e.store.get("batch-operation")["progress"] == {"completed": 2, "total": 2} + for bad in [ + [], + [{"method": "nx_bad", "params": {}}], + [{"method": "nx_sketch_line", "params": {}}], + ]: + with pytest.raises((NXToolError, TypeError)): + rig.e._batch(bad) + assert len(calls) == 2 + rig.e.store.path("batch-operation").with_suffix(".cancel").touch() + with pytest.raises(NXToolError) as error: + rig.e._batch(ops) + assert error.value.code == "NX_CANCELLED" and len(calls) == 2 + + +def test_step_import_validates_conflicts_and_reports_no_output(rig, tmp_path): + source = tmp_path / "vendor.step" + source.write_text( + "ISO-10303-21; PRODUCT('test','test'); NEXT_ASSEMBLY_USAGE_OCCURRENCE END-ISO-10303-21;" + ) + with pytest.raises(NXToolError) as error: + rig.e._import_geometry(str(source)) + assert error.value.code == "NX_IMPORT_NAME_CONFLICT" + rig.nx.Step214Importer = NS(ImportToOption=NS(WorkPart="work")) + builder = NS(ObjectTypes=NS(), Commit=Mock(), Destroy=Mock()) + rig.session.DexManager = NS(CreateStep214Importer=lambda: builder) + rig.e._current_operation = "import-operation" + with pytest.raises(NXToolError, match="without imported"): + rig.e._import_geometry(str(source), flatten=True) + assert ( + builder.Destroy.call_count == 1 + and Path(builder.InputFile).read_text() == source.read_text() + ) + rig.e._current_operation = "import-operation-2" + builder.Commit = Mock(side_effect=lambda: rig.part.Bodies.append(Body("imported"))) + result = rig.e._import_geometry(str(source), flatten=True) + assert result["body_count"] == 1 and result["translator"].endswith("WorkPart") + for path, kwargs in [ + (str(tmp_path / "x.iges"), {}), + (str(tmp_path / "missing.step"), {}), + (str(source), {"target": "invalid"}), + (str(source), {"target": "new_part"}), + (str(source), {"output_path": "x.prt"}), + (str(source), {"target": "new_part", "output_path": str(source)}), + ]: + with pytest.raises(NXToolError): + rig.e._import_geometry(path, **kwargs) + + +def test_save_as_creates_parents_and_rejects_existing_destination(rig, tmp_path): + destination = tmp_path / "project" / "revisions" / "base_r02.prt" + + def save(path): + assert Path(path).parent.is_dir() + Path(path).write_bytes(b"saved fixture") + rig.part.FullPath = path + return NS(Dispose=Mock()) + + rig.part.SaveAs = Mock(side_effect=save) + result = rig.e._save_as(str(destination)) + assert result["path"] == str(destination) + with pytest.raises(NXToolError) as error: + rig.e._save_as(str(destination)) + assert error.value.code == "NX_FILE_EXISTS" + assert rig.part.SaveAs.call_count == 1 + assert destination.read_bytes() == b"saved fixture" + + +@pytest.mark.parametrize( + "contents", + ["ISO-10303-21; DATA; #1=BODY();", "garbage", "/* END-ISO-10303-21; */ ISO-10303-21;"], +) +def test_truncated_step_rejected_before_translator(rig, tmp_path, contents): + source = tmp_path / "broken.step" + source.write_text(contents) + translator = Mock() + rig.session.DexManager = NS(CreateStep214Importer=translator) + with pytest.raises(NXToolError) as error: + rig.e._import_geometry(str(source), flatten=True) + assert error.value.code == "NX_INVALID_STEP" + translator.assert_not_called() diff --git a/tests/test_authoring_review.py b/tests/test_authoring_review.py new file mode 100644 index 0000000..cb9f320 --- /dev/null +++ b/tests/test_authoring_review.py @@ -0,0 +1,863 @@ +"""Stateful contracts for authoring and review; native geometry is tested separately.""" + +import json +import zipfile +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.authoring import finite, page +from nx_mcp.hardened import IDENTITY, xyz +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Collection, Component, Curve, Feature, Object, Sketch, point + +pytestmark = pytest.mark.fake_nx + + +class Expression(Object): + def __init__(self, name="height", formula="10", unit=None): + super().__init__(name) + self.RightHandSide = formula + self.Value = 10.0 + self.Type = "Number" + self.Units = unit + self.IsNoEdit = self.IsRightHandSideLockedFromEdit = self.IsInterpartExpression = False + self.parents = [] + self.dependents = [] + + def GetValueUsingUnits(self, option): + return getattr(self, "expression_value", self.Value) + + def GetExpressionParents(self): + return self.parents + + def GetReferencingExpressions(self): + return self.dependents + + +@pytest.fixture +def author(rig): + p, s = rig.part, rig.session + rig.nx.Expression = NS(UnitsOption=NS(Expression="expression")) + p.Expressions = Collection() + p.Expressions.CreateNumberExpression = Mock(side_effect=lambda spec, unit: create(spec, unit)) + + def create(spec, unit): + name, formula = spec.split("=", 1) + exp = Expression(name, formula, unit) + p.Expressions.append(exp) + return exp + + def edit(exp, formula): + if formula == "invalid": + raise RuntimeError("native formula error") + exp.RightHandSide = formula + + p.Expressions.EditExpression = Mock(side_effect=edit) + p.UnitCollection.FindObject = lambda n: NS( + Name=n, + Abbreviation={"MilliMeter": "mm", "Inch": "in", "Degrees": "deg", "Radian": "rad"}.get( + n, n + ), + ) + rig.uf.Modeling = NS( + AskBodyConsistency=Mock(return_value=(0, [], [])), + AskFaceData=lambda tag: geom(tag), + AskMinimumDist3=Mock(return_value=(2.0, [0, 0, 10], [0, 0, 12], 0.0)), + ) + + def geom(tag): + face = next(f for b in p.Bodies for f in b.faces if f.Tag == tag) + return face.native_type, [0, 0, 0], face.normal, face.box, face.radius, 0, 1 + + rig.uf.Curve = NS(AskArcData=lambda tag: NS(Radius=5)) + b = Body() + p.Bodies.append(b) + for f in b.faces: + f.box = [0, 0, 10, 10, 10, 10] + f.native_type = 22 + f.normal = [0, 0, 1] + f.radius = 5 + for edge in b.edges: + edge.box = [0, 0, 0, 10, 10, 0] + edge.SolidEdgeType = 1 + rig.nx.Edge.EdgeType = NS(Circular=1, Linear=2) + f = Feature(bodies=[b]) + f.Suppressed = False + f.GetFeatureErrorMessages = lambda: [] + f.GetFeatureWarningMessages = lambda: [] + p.Features.append(f) + exp = Expression() + p.Expressions.append(exp) + f.expressions = [exp] + target = Expression("p_limit") + p.Expressions.append(target) + builder = NS( + Limits=NS(StartExtend=NS(Value=target), EndExtend=NS(Value=target)), + CommitFeature=Mock(), + Destroy=Mock(), + ) + p.Features.CreateExtrudeBuilder = lambda _: builder + view = p.ModelingViews.WorkView + + def camera(m, o, scale): + view.Matrix = m + view.Origin = o + view.Scale = scale + + view.SetRotationTranslationScale = Mock(side_effect=camera) + rig.ref(b) + rig.ref(f, "feature") + rig.ref(exp, "expression") + for face in b.faces: + rig.ref(face, "face") + for edge in b.edges: + rig.ref(edge, "edge") + # Save/restore expression and sketch state in addition to existing model seam state. + original_mark, original_undo = s.SetUndoMark, s.UndoToMark + extra = {} + + def mark(*args): + m = original_mark(*args) + extra[m] = ( + [(x, x.RightHandSide, x.Value) for x in p.Expressions], + [(sk, list(sk.geometry), list(sk.constraints)) for sk in p.Sketches], + ) + return m + + def undo(m, *args): + original_undo(m, *args) + expressions, sketches = extra[m] + p.Expressions[:] = [x for x, _, _ in expressions] + for x, formula, value in expressions: + x.RightHandSide = formula + x.Value = value + for sk, geometry, constraints in sketches: + sk.geometry[:] = geometry + sk.constraints[:] = constraints + + s.SetUndoMark = mark + s.UndoToMark = undo + rig.body, rig.feature, rig.expression, rig.builder = b, f, exp, builder + return rig + + +@pytest.mark.parametrize("value", [None, True, float("nan"), float("inf"), "1", -1, 0]) +def test_positive_number_validation(value): + with pytest.raises(NXToolError): + finite(value, "value", True) + + +@pytest.mark.parametrize("offset,limit", [(-1, 1), (0, 0), (0, 201), (True, 1), (0, False)]) +def test_page_rejects_unbounded_requests(offset, limit): + with pytest.raises(NXToolError): + page([1], offset, limit) + + +def test_pagination_is_stable_and_complete(): + assert page([1, 2, 3], 0, 2) == {"items": [1, 2], "total": 3, "offset": 0, "next_offset": 2} + assert page([1, 2, 3], 2, 2)["next_offset"] is None + + +def test_expressions_creation_binding_and_dependencies(author): + r = author + e = r.e + row = e.execute( + "nx_set_expression", {"expression": "width", "formula": "5", "create": True, "units": "mm"} + )["expression"] + assert row["units"] == "mm" and row["formula"] == "5" + exp = r.part.Expressions[-1] + exp.parents = [r.expression] + r.expression.dependents = [exp] + assert e._list_expressions("width")["items"][0]["parents"][0]["name"] == "height" + e.execute("nx_set_expression", {"expression": row["object"]["id"], "formula": "height*2"}) + assert exp.RightHandSide == "height*2" + f = r.ref(r.feature, "feature") + e.execute("nx_bind_parameter", {"feature": f, "parameter": "end", "expression": "width"}) + assert r.builder.Limits.EndExtend.Value.RightHandSide == "width" + r.builder.CommitFeature.assert_called_once() + r.builder.Destroy.assert_called_once() + assert e._expression_record(r.expression)["dependents"] + + +@pytest.mark.parametrize( + "params", + [ + {"expression": "height", "formula": "5", "create": True}, + {"expression": "a b", "formula": "5", "create": True}, + {"expression": "a", "formula": "", "create": True}, + {"expression": "a", "formula": "5", "create": True, "units": "bad"}, + {"expression": "height", "formula": "5", "units": "mm"}, + {"expression": "absent", "formula": "5"}, + ], +) +def test_expression_rejections_do_not_modify(author, params): + before = [(e.Name, e.RightHandSide) for e in author.part.Expressions] + with pytest.raises(NXToolError): + author.e.execute("nx_set_expression", params) + assert [(e.Name, e.RightHandSide) for e in author.part.Expressions] == before + + +@pytest.mark.parametrize( + "flag", ["IsNoEdit", "IsRightHandSideLockedFromEdit", "IsInterpartExpression"] +) +def test_locked_expression_is_never_changed(author, flag): + setattr(author.expression, flag, True) + with pytest.raises(NXToolError): + author.e.execute("nx_set_expression", {"expression": "height", "formula": "20"}) + assert author.expression.RightHandSide == "10" + + +def test_failed_native_update_restores_expression(author): + author.session.UpdateManager.DoUpdate.return_value = 1 + with pytest.raises(NXToolError) as error: + author.e.execute("nx_set_expression", {"expression": "height", "formula": "25"}) + assert error.value.details["mutation_outcome"] == "rolled_back" + assert author.expression.RightHandSide == "10" + + +@pytest.mark.parametrize( + "kind,parameter,type_", + [("OTHER", "end", "Number"), ("EXTRUDE", "spacing", "Number"), ("EXTRUDE", "end", "String")], +) +def test_parameter_binding_rejects_unsupported_targets(author, kind, parameter, type_): + author.feature.FeatureType = kind + author.expression.Type = type_ + with pytest.raises(NXToolError): + author.e._bind_parameter(author.ref(author.feature, "feature"), parameter, "height") + author.builder.CommitFeature.assert_not_called() + + +@pytest.mark.parametrize( + "params", + [ + {"kind": "curve"}, + {"geometry_type": "bad"}, + {"kind": "face", "geometry_type": "circle"}, + {"kind": "edge", "geometry_type": "plane"}, + {"normal": [0, 0, 1]}, + {"radius": 1}, + {"order": "bad"}, + {"axis": "A"}, + {"tolerance": 0}, + {"order": "nearest"}, + {"near": [0, 1]}, + {"normal": [0, 0, 0], "geometry_type": "plane"}, + ], +) +def test_selection_rejects_ambiguous_or_ignored_options(author, params): + with pytest.raises(NXToolError): + author.e._find_geometry(**params) + + +def test_selection_reports_plane_orientation_radius_and_rank(author): + e = author.e + r = e._find_geometry(geometry_type="plane", normal=[0, 0, 1], order="highest") + assert r["total"] == 1 and r["items"][0]["bounds_center"] == [5, 5, 10] + assert e._find_geometry(geometry_type="plane", normal=[0, 0, -1], order="lowest")["total"] == 0 + edge = e._find_geometry(kind="edge", geometry_type="circle", radius=5, near=[0, 0, 0])["items"][ + 0 + ] + assert edge["radius"] == 5 and edge["distance_to_bounds_center"] > 0 + assert ( + e._find_geometry(kind="edge", geometry_type="circle", radius=6, near=[0, 0, 0])["total"] + == 0 + ) + author.body.faces[0].native_type = 16 + assert e._find_geometry(geometry_type="cylinder", radius=5, order="lowest")["total"] == 1 + author.body.edges[0].SolidEdgeType = 2 + assert e._find_geometry(kind="edge", geometry_type="line", order="highest")["total"] == 1 + + +def test_query_highlight_failure_clears_partial_selection(author): + r = author + e = r.e + e._highlight_objects([r.ref(r.body)]) + assert r.body.highlighted + r.body.faces[0].Highlight = Mock(side_effect=RuntimeError("highlight failed")) + with pytest.raises(RuntimeError): + e._highlight_objects([r.ref(r.body), r.ref(r.body.faces[0], "face")]) + assert not r.body.highlighted and not e._highlighted_objects + + +def test_health_reports_faults_suppression_and_paginates(author): + r = author + e = r.e + assert e._model_health()["healthy"] + r.feature.GetFeatureErrorMessages = lambda: ["broken reference"] + r.feature.GetFeatureWarningMessages = lambda: ["out of date"] + r.feature.Suppressed = True + r.body.IsSolidBody = False + r.uf.Modeling.AskBodyConsistency.return_value = (1, [100], [r.body.faces[0].Tag]) + report = e._model_health(limit=1) + assert ( + report["error_count"] == 2 and report["warning_count"] == 1 and report["next_offset"] == 1 + ) + assert not report["healthy"] and report["bodies_checked"] == 1 + assert e._model_health(offset=99)["items"] == [] + + +def test_health_assembly_unique_prototypes_and_unloaded(author): + r = author + root = Component("root") + r.part.ComponentAssembly.RootComponent = root + a = Component("a", parent=root) + a.Prototype = r.part + b = Component("b", parent=root) + b.Prototype = r.part + b.IsSuppressed = True + Component("unloaded", parent=root).Prototype = None + report = r.e._model_health("assembly") + assert report["parts_checked"] == 1 and report["warning_count"] == 1 + assert {i["kind"] for i in report["items"]} == {"suppressed_component", "unloaded_component"} + with pytest.raises(NXToolError): + r.e._model_health("bad") + + +def test_health_missing_api_is_explicit(author): + del author.uf.Modeling.AskBodyConsistency + with pytest.raises(NXToolError, match="AskBodyConsistency"): + author.e._model_health() + + +def test_model_rebuild_propagates_failure(author): + assert author.e.execute("nx_rebuild_model", {})["health"]["healthy"] + author.session.UpdateManager.DoUpdate.return_value = 2 + with pytest.raises(NXToolError): + author.e.execute("nx_rebuild_model", {}) + + +class Line(Curve): + def __init__(self): + super().__init__("line") + self.StartPoint = point() + self.EndPoint = point(10, 0, 0) + + def SetEndpoints(self, a, b): + self.StartPoint, self.EndPoint = a, b + + +@pytest.fixture +def sketch(author): + r = author + s = Sketch(r.session) + r.part.Sketches.append(s) + line = Line() + s.geometry = [line] + r.part.Curves.append(line) + r.nx.Sketch.ConstraintGeometry = lambda: NS() + r.nx.Sketch.ConstraintPointType = NS() + + def constraint(g): + c = Object("constraint") + c.ConstraintType = 0 + s.constraints.append(c) + return c + + s.CreateFixedConstraint = s.CreateHorizontalConstraint = s.CreateVerticalConstraint = constraint + + def delete(values): + for v in values: + if v in s.geometry: + s.geometry.remove(v) + r.part.Curves.remove(v) + if v in s.constraints: + s.constraints.remove(v) + return NS(Length=0, Dispose=Mock()) + + s.DeleteObjects = delete + r.part.Sketches.CreateWorkRegionBuilder = lambda: NS(Scope=None, Commit=Mock(), Destroy=Mock()) + r.part.Curves.CreateLine = lambda a, b: create_line(a, b) + + def create_line(a, b): + v = Line() + v.SetEndpoints(a, b) + r.part.Curves.append(v) + return v + + r.sketch = s + r.line = line + r.sid = r.ref(s, "sketch") + r.cid = r.ref(line, "curve") + return r + + +def test_sketch_edits_constraints_and_deletes_owned_objects(sketch): + r = sketch + e = r.e + result = e.execute( + "nx_edit_sketch", + { + "sketch_id": r.sid, + "operations": [ + {"action": "line", "curve": r.cid, "start": [1, 2], "end": [8, 2]}, + {"action": "constraint", "curve": r.cid, "type": "horizontal"}, + {"action": "add_line", "start": [1, 2], "end": [1, 8]}, + ], + }, + ) + assert result["edit_count"] == 3 and r.session.ActiveSketch is None + assert xyz(r.line.EndPoint) == [8, 2, 0] and len(r.sketch.constraints) == 1 + constraint = r.ref(r.sketch.constraints[0], "constraint") + result = e.execute( + "nx_edit_sketch", + { + "sketch_id": r.sid, + "operations": [ + {"action": "delete", "object": constraint}, + {"action": "delete", "object": r.cid}, + ], + }, + ) + assert len(r.sketch.geometry) == 1 and not r.sketch.constraints + + +@pytest.mark.parametrize( + "op", + [ + {"action": "unknown"}, + {"action": "add_line", "start": [0, 0], "end": [0, 0]}, + {"action": "add_line", "start": [0], "end": [1, 1]}, + {"action": "add_line", "start": [0, 0], "end": [1, 1], "extra": True}, + ], +) +def test_sketch_preflight_rejections_preserve_activation(sketch, op): + with pytest.raises(NXToolError): + sketch.e.execute("nx_edit_sketch", {"sketch_id": sketch.sid, "operations": [op]}) + assert sketch.session.ActiveSketch is None and sketch.sketch.geometry == [sketch.line] + + +def test_sketch_wrong_owner_and_active_sketch_rejected(sketch): + r = sketch + foreign = r.ref(Line(), "curve") + with pytest.raises(NXToolError): + r.e._edit_sketch(r.sid, [{"action": "delete", "object": foreign}]) + r.session.ActiveSketch = Sketch(r.session) + with pytest.raises(NXToolError): + r.e._edit_sketch(r.sid, [{"action": "delete", "object": r.cid}]) + + +def test_sketch_edit_keeps_previously_active_sketch(sketch): + r = sketch + r.session.ActiveSketch = r.sketch + r.e.execute( + "nx_edit_sketch", + { + "sketch_id": r.sid, + "operations": [{"action": "constraint", "curve": r.cid, "type": "fixed"}], + }, + ) + assert r.session.ActiveSketch is r.sketch + + +def test_sketch_arc_edit_units_and_limits(sketch): + r = sketch + r.line.SetParameters = Mock() + op = { + "action": "arc", + "curve": r.cid, + "center": [2, 3], + "radius": 4, + "start_angle": 0, + "end_angle": 180, + } + r.e.execute("nx_edit_sketch", {"sketch_id": r.sid, "operations": [op]}) + args = r.line.SetParameters.call_args.args + assert ( + args[0] == 4 and xyz(args[1]) == [2, 3, 0] and args[3] == pytest.approx(3.141592653589793) + ) + with pytest.raises(NXToolError): + r.e._edit_sketch(r.sid, [{**op, "end_angle": 400}]) + + +@pytest.fixture +def assembly(author): + r = author + root = Component("root") + a = Component("seed", parent=root) + r.part.ComponentAssembly.RootComponent = root + a.Prototype.FullPath = str(r.e.workspace.root / "prototype.prt") + Path(a.Prototype.FullPath).write_bytes(b"part") + r.part.ComponentAssembly.SuppressComponents = lambda cs: suppress(cs, True) + r.part.ComponentAssembly.UnsuppressComponents = lambda cs: suppress(cs, False) + + def suppress(cs, value): + for c in cs: + c.IsSuppressed = value + return NS(Length=0, Dispose=Mock()) + + r.session.UpdateManager.AddToDeleteList = lambda c: root.children.remove(c) + + def add(path, refset, name, pos, matrix, layer): + c = Component(name, parent=root) + c.position = pos + c.rotation = matrix + c.Prototype.FullPath = path + return c, NS(Dispose=Mock()) + + r.part.ComponentAssembly.AddComponent = add + builder = NS( + ComponentsToReplace=NS(Add=Mock()), + ReplaceAllOccurrences=False, + MaintainRelationships=True, + ReplacementPart=None, + Commit=Mock(), + Destroy=Mock(), + GetErrorList=lambda: NS(Length=0, Dispose=Mock()), + ) + r.part.AssemblyManager = NS(CreateReplaceComponentBuilder=lambda: builder) + r.component = a + r.component_id = r.ref(a, "component") + r.replace_builder = builder + return r + + +def test_component_maintenance_preserves_pose_and_other_occurrences(assembly): + r = assembly + e = r.e + for action, args in [ + ("rename", {"name": "changed"}), + ("suppress", {}), + ("unsuppress", {}), + ("replace", {"part_path": r.component.Prototype.FullPath}), + ]: + result = e.execute( + "nx_component_action", {"component": r.component_id, "action": action, **args} + ) + assert result["placement_preserved"] + assert r.component.Name == "changed" and not r.component.IsSuppressed + r.replace_builder.Destroy.assert_called_once() + e.execute("nx_component_action", {"component": r.component_id, "action": "remove"}) + assert not r.part.ComponentAssembly.RootComponent.children + assert Path(r.component.Prototype.FullPath).exists() + + +@pytest.mark.parametrize( + "params", + [ + {"action": "bad"}, + {"action": "rename"}, + {"action": "rename", "name": ""}, + {"action": "suppress", "name": "ignored"}, + {"action": "replace", "part_path": "missing.prt"}, + ], +) +def test_component_action_invalid_args_do_not_mutate(assembly, params): + with pytest.raises(NXToolError): + assembly.e.execute("nx_component_action", {"component": assembly.component_id, **params}) + assert assembly.component.Name == "seed" + + +def test_component_nested_edits_rejected(assembly): + r = assembly + r.component.Parent = Component("nested") + with pytest.raises(NXToolError): + r.e._component_action(r.component_id, "remove") + with pytest.raises(NXToolError): + r.e._pattern_components(r.component_id, [1, 0, 0], 10, 4) + + +def test_component_pattern_count_pitch_and_placement(assembly): + r = assembly + result = r.e.execute( + "nx_pattern_components", + {"component": r.component_id, "direction": [2, 0, 0], "spacing": 16.5, "count": 16}, + ) + assert result["total_instances"] == 16 and not result["associative"] + positions = [xyz(c.position) for c in r.part.ComponentAssembly.RootComponent.children] + assert len(positions) == 16 and positions[-1] == [247.5, 0, 0] + for count in [1, 101, True]: + with pytest.raises(NXToolError): + r.e._pattern_components(r.component_id, [1, 0, 0], 1, count) + + +def test_native_error_lists_are_disposed_and_reported(author): + errors = NS(Length=1, GetErrorInfo=lambda i: "conflicting mate", Dispose=Mock()) + with pytest.raises(NXToolError, match="conflicting mate"): + author.e._error_list(errors) + errors.Dispose.assert_called_once() + author.e._error_list(None) + + +def sections_stub(r): + r.e._list_sections = lambda: {"sections": [], "view_sectioning_enabled": False} + + +def test_saved_presentation_restores_camera_and_face_colors(author): + r = author + e = r.e + sections_stub(r) + path = str(r.e.workspace.root / "view.json") + r.body.faces[0].Color = 33 + e.execute( + "nx_set_camera", + {"rotation": [[0, -1, 0], [1, 0, 0], [0, 0, 1]], "origin": [2, 3, 4], "scale": 2}, + ) + e.execute("nx_save_presentation", {"path": path}) + r.body.faces[0].Color = 5 + r.body.IsBlanked = True + e.execute("nx_set_camera", {"rotation": IDENTITY, "origin": [0, 0, 0], "scale": 1}) + e.execute("nx_restore_presentation", {"path": path}) + assert r.body.faces[0].Color == 33 and not r.body.IsBlanked + assert e._view_info()["origin"] == [2, 3, 4] and e._view_info()["scale"] == 2 + with pytest.raises(NXToolError): + e._save_presentation(path) + + +@pytest.mark.parametrize("fault", ["owner", "format", "missing_face", "bad_camera"]) +def test_presentation_preflight_leaves_display_unchanged(author, fault): + r = author + e = r.e + sections_stub(r) + p = r.e.workspace.root / "view.json" + e._save_presentation(str(p)) + d = json.loads(p.read_text()) + if fault == "owner": + d["part_path"] = "different.prt" + if fault == "format": + d["version"] = 999 + if fault == "missing_face": + d["display"][-1]["locator"]["journal_id"] = "removed" + r.part.FindObject = Mock(side_effect=RuntimeError("not found")) + if fault == "bad_camera": + d["camera"]["scale"] = 0 + p.write_text(json.dumps(d)) + r.body.Color = 13 + with pytest.raises(NXToolError): + e.execute("nx_restore_presentation", {"path": str(p)}) + assert r.body.Color == 13 + + +def test_camera_validation_and_readback(author): + e = author.e + with pytest.raises(NXToolError): + e._set_camera([[1, 0, 0]] * 3, [0, 0, 0], 1) + with pytest.raises(NXToolError): + e._set_camera(IDENTITY, [1, 2], 1) + assert e._set_camera(IDENTITY, [1, 2, 3], 3)["origin"] == [1, 2, 3] + + +def test_temporary_presentation_restores_on_exception(author): + e = author.e + before = e._view_info() + with pytest.raises(RuntimeError), e._temporary_view(): + author.body.Blank() + e._set_camera(IDENTITY, [5, 5, 5], 2) + raise RuntimeError("capture") + assert not author.body.IsBlanked and e._view_info()["origin"] == before["origin"] + + +def test_temporary_presentation_cleanup_failure_is_partial(author): + author.session.UndoToMark = Mock(side_effect=RuntimeError("undo")) + with pytest.raises(NXToolError) as err, author.e._temporary_view(): + pass + assert err.value.details["mutation_outcome"] == "partial" + + +@pytest.mark.parametrize( + "section", ["overview", "features", "components", "expressions", "sketches"] +) +def test_compact_summaries_have_counts_and_bounded_pages(author, section): + result = author.e._model_summary(section, limit=1) + if section == "overview": + assert result["counts"]["bodies"] == 1 and result["diagnostics"]["healthy"] + else: + assert len(result["items"]) <= 1 + + +def test_summary_empty_part_and_bad_section(author): + author.part.Bodies.clear() + assert author.e._model_summary()["bounds"] is None + with pytest.raises(NXToolError): + author.e._model_summary("bad") + + +def test_inspection_report_packages_evidence_and_restores_view(author): + r = author + e = r.e + e._check_clearance = lambda *args: { + "pairs": [ + { + "classification": "penetration", + "objects": [e._reference(r.body, "body", r.part, "Body")], + } + ], + "complete": True, + "counts": {"penetration": 1}, + } + + def capture(path=None, **kw): + p = Path(path) + p.write_bytes(b"fixture-image") + return {"path": str(p), "camera": e._view_info()} + + e._capture_view = capture + e._section_view = lambda **kw: None + path = str(e.workspace.root / "report.zip") + result = e.execute( + "nx_inspection_report", + {"path": path, "section_planes": [{"origin": [0, 0, 5], "normal": [0, 0, 1]}]}, + ) + assert result["capture_count"] == 3 and not r.body.IsBlanked + assert result["artifact_path"] == "report.zip" + with zipfile.ZipFile(path) as z: + assert { + "index.html", + "report.json", + "manifest.json", + "overview.png", + "pair-1.png", + "section-1.png", + } == set(z.namelist()) + assert json.loads(z.read("report.json"))["clearance"]["complete"] + with pytest.raises(NXToolError): + e._inspection_report(path) + + +@pytest.mark.parametrize( + "params", + [ + {"path": "bad.txt"}, + {"path": "a.zip", "section_planes": [{"normal": [0, 0, 1]}]}, + { + "path": "a.zip", + "capture": False, + "section_planes": [{"normal": [0, 0, 1], "origin": [0, 0, 0]}], + }, + {"path": "a.zip", "section_planes": [{}] * 7}, + ], +) +def test_report_rejects_invalid_plans_before_output(author, params): + with pytest.raises(NXToolError): + author.e._inspection_report( + **{**params, "path": str(author.e.workspace.root / params["path"])} + ) + assert not (author.e.workspace.root / "a.zip").exists() + + +def test_preview_rolls_back_parameters_and_accepts_exact_plan(author): + r = author + e = r.e + ref = r.ref(r.expression, "expression") + r1 = e.execute( + "nx_preview_change", + { + "operations": [ + {"method": "nx_set_expression", "params": {"expression": ref, "formula": "25"}} + ], + "capture": False, + }, + ) + assert r.expression.RightHandSide == "10" and r1["model_outcome"] == "rolled_back" + assert next(x for x in r1["after"]["parameters"] if x["name"] == "height")["formula"] == "25" + r2 = e.execute( + "nx_finish_preview", + {"preview_id": r1["preview_id"], "action": "accept", "operation_id": "accept-once"}, + ) + assert r.expression.RightHandSide == "25" and r2["geometry_changed"] + assert e.execute( + "nx_finish_preview", + {"preview_id": r1["preview_id"], "action": "accept", "operation_id": "accept-once"}, + )["replayed"] + + +def test_preview_failure_rolls_back_prior_edits(author): + r = author + e = r.e + with pytest.raises(NXToolError): + e.execute( + "nx_preview_change", + { + "operations": [ + { + "method": "nx_set_expression", + "params": {"expression": "height", "formula": "20"}, + }, + { + "method": "nx_set_expression", + "params": {"expression": "height", "formula": "invalid"}, + }, + ], + "capture": False, + }, + ) + assert r.expression.RightHandSide == "10" + + +def test_preview_expires_on_intervening_mutation(author): + e = author.e + r = e.execute( + "nx_preview_change", + { + "operations": [ + {"method": "nx_set_expression", "params": {"expression": "height", "formula": "20"}} + ], + "capture": False, + }, + ) + e.execute("nx_set_expression", {"expression": "height", "formula": "30"}) + with pytest.raises(NXToolError, match="changed"): + e.execute("nx_finish_preview", {"preview_id": r["preview_id"], "action": "accept"}) + assert author.expression.RightHandSide == "30" + assert ( + e.execute("nx_finish_preview", {"preview_id": r["preview_id"], "action": "discard"})[ + "geometry_changed" + ] + is False + ) + + +@pytest.mark.parametrize( + "operations", + [[], [{"method": "nx_save_part", "params": {}}], [{"method": "nx_edit_feature", "params": {}}]], +) +def test_preview_preflight_rejects_unrecoverable_or_invalid_operations(author, operations): + with pytest.raises((NXToolError, TypeError)): + author.e._preview_plan(operations) + assert author.expression.RightHandSide == "10" + + +def test_preview_discards_unknown_tokens_and_invalid_actions(author): + for action in ["accept", "unknown"]: + with pytest.raises(NXToolError): + author.e._finish_preview("missing", action) + + +def test_expression_value_uses_its_declared_unit_not_part_unit(author): + exp = author.expression + exp.Units = NS(Abbreviation="in") + exp.Value = 25.4 + exp.expression_value = 1.0 + record = author.e._expression_record(exp) + assert record["value"] == 1 and record["units"] == "in" + + +@pytest.mark.asyncio +async def test_authoring_tool_schemas_are_explicit_and_path_scoped(tmp_path): + from unittest.mock import AsyncMock + + from nx_mcp.server import create_server + from nx_mcp.workspace import Workspace + + bridge = NS(call=AsyncMock(return_value={"status": "success"})) + server = create_server(bridge, Workspace(tmp_path), enable_experimental=True) + tools = {t.name: t for t in await server.list_tools()} + variants = tools["nx_edit_sketch"].inputSchema["properties"]["operations"]["items"]["oneOf"] + assert {v["properties"]["action"]["const"] for v in variants} == { + "line", + "arc", + "add_line", + "delete", + "constraint", + } + assert all(v["additionalProperties"] is False for v in variants) + assert tools["nx_find_geometry"].annotations.readOnlyHint + assert not tools["nx_preview_change"].annotations.readOnlyHint + await server.call_tool( + "nx_component_action", + {"component": "obj_example", "action": "replace", "part_path": "replacement.prt"}, + ) + args = bridge.call.call_args.args[1] + assert args["part_path"] == str(tmp_path / "replacement.prt") and args["operation_id"] diff --git a/tests/test_bridge_import.py b/tests/test_bridge_import.py index 9a621b3..8847d1f 100644 --- a/tests/test_bridge_import.py +++ b/tests/test_bridge_import.py @@ -22,3 +22,14 @@ def test_nx_bridge_import_succeeds_without_site_packages() -> None: ) assert result.returncode == 0, result.stderr + + +def test_hardened_runtime_import_has_no_sidecar_dependencies(): + source_root = Path(__file__).parents[1] / "src" + result = subprocess.run( + [sys.executable, "-S", "-c", "import nx_mcp.hardened"], + capture_output=True, + text=True, + env=os.environ | {"PYTHONPATH": str(source_root)}, + ) + assert result.returncode == 0, result.stderr diff --git a/tests/test_capability_matrix.py b/tests/test_capability_matrix.py new file mode 100644 index 0000000..97f80eb --- /dev/null +++ b/tests/test_capability_matrix.py @@ -0,0 +1,53 @@ +"""Evidence labels must not promote local or missing evidence into native claims.""" + +import importlib.util +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts/generate_capability_matrix.py" +SPEC = importlib.util.spec_from_file_location("capability_matrix", SCRIPT) +assert SPEC and SPEC.loader +matrix = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(matrix) + + +@pytest.mark.parametrize( + ("status", "evidence", "expected"), + [ + ("tested", "real_NX_v2606_scoped_and_local_boundary_tests", "Native-tested"), + ("experimental", "real_NX_v2606", "Experimental"), + ("tested", "local_contract_test", "Contract/sidecar-tested"), + ("tested", "live_sidecar_and_contract_tests", "Contract/sidecar-tested"), + ("tested", None, "Experimental"), + ("tested", "unrecognized", "Experimental"), + ("unavailable", "real_NX_v2606", "Unavailable"), + ], +) +def test_classification_does_not_inflate_evidence(status, evidence, expected): + assert matrix.classification({"status": status, "evidence_type": evidence}) == expected + + +def test_render_preserves_caveats_and_is_order_independent(): + manifest = { + "revision": "fixture", + "nx_version": "v2606", + "bridge_protocol": 1, + "tools": { + "nx_z": {"status": "tested", "scope": "unknown | \nsecond line"}, + "nx_a": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "one case only", + }, + }, + "unavailable": ["missing_feature"], + "limitations": ["No general certification"], + } + first = matrix.render(manifest) + manifest["tools"] = dict(reversed(list(manifest["tools"].items()))) + assert first == matrix.render(manifest) + assert "unknown | <unsafe>
second line" in first + assert "one case only" in first + assert "- missing_feature" in first + assert "No general certification" in first diff --git a/tests/test_display_lifecycle.py b/tests/test_display_lifecycle.py new file mode 100644 index 0000000..f245ae1 --- /dev/null +++ b/tests/test_display_lifecycle.py @@ -0,0 +1,271 @@ +"""Stateful display, clipping and solver tests at the installed NX API seam.""" + +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import enum_name +from tests.fakes import Body, Component, Curve, Feature, Object, Part, Sketch, point + +pytestmark = pytest.mark.fake_nx + + +def test_appearance_restores_per_face_attributes_and_blank_flags(rig): + b = Body() + b.faces[0].Color = 21 + b.faces[0].transparency = 15 + b.IsBlanked = True + rig.part.Bodies.append(b) + ref = rig.ref(b) + before = rig.e._display_info([ref])["objects"] + result = rig.e.execute("nx_set_display", {"objects": [ref], "color": "red", "transparency": 60}) + assert b.Color == 3 and b.faces[0].Color == 3 and b.faces[0].transparency == 60 + assert all(not m.ApplyToOwningParts for m in rig.modifications) + rig.e.execute("nx_restore_display", {"restore_id": result["restore_id"]}) + assert rig.e._display_info([ref])["objects"] == before + assert all(m.Dispose.call_count == 1 for m in rig.modifications) + with pytest.raises(NXToolError): + rig.e._restore_display(result["restore_id"]) + + +@pytest.mark.parametrize( + "params", + [ + {}, + {"color": "invalid"}, + {"color": "red", "color_index": 3}, + {"color_index": True}, + {"color_index": 0}, + {"color_index": 217}, + {"transparency": -1}, + {"transparency": 101}, + {"transparency": 1.5}, + ], +) +def test_invalid_appearance_preflight_never_applies(rig, params): + with pytest.raises(NXToolError): + rig.e._set_display(["unresolved"], **params) + assert not rig.modifications + + +def test_snapshot_order_and_stale_member_fail_before_any_restore(rig): + b = Body() + rig.part.Bodies.append(b) + ref = rig.ref(b) + first = rig.e._set_visibility([ref], "hide")["restore_id"] + second = rig.e._set_display([ref], color="gray")["restore_id"] + with pytest.raises(NXToolError) as error: + rig.e._restore_display(first) + assert error.value.code == "NX_RESTORE_ORDER" and b.IsBlanked + face_ref = rig.e._display_snapshots[second]["records"][1]["object"]["id"] + rig.e.objects._objects.pop(face_ref) + n = len(rig.modifications) + with pytest.raises(NXToolError): + rig.e._restore_display(second) + assert len(rig.modifications) == n and b.Color == 5 + + +def test_isolation_keeps_nested_ancestors_and_restores_hidden_siblings(rig): + seed = Body() + root = Component("root") + nested = Component("nested", parent=root) + child = Component("child", [seed], nested) + other = Component("other", [Body()], root) + rig.part.ComponentAssembly.RootComponent = root + root.IsBlanked = True + other.IsBlanked = True + ref = rig.ref(child, "component") + body = child.FindOccurrence(seed) + result = rig.e._set_visibility([ref], "isolate") + assert not body.IsBlanked and not child.IsBlanked and not nested.IsBlanked + assert other.IsBlanked + rig.e._restore_display(result["restore_id"]) + assert other.IsBlanked and root.IsBlanked and not body.IsBlanked + assert rig.e._display_info([ref])["count"] == 2 + + +def test_display_target_kinds_and_part_guards(rig, tmp_path): + b = Body() + feature = Feature(bodies=[b]) + sk = Sketch(rig.session) + sk.geometry = [Curve()] + assert rig.e._display_targets([rig.ref(feature, "feature")]) == [b] + assert rig.e._display_targets([rig.ref(sk, "sketch")]) == sk.geometry + face = rig.ref(b.faces[0], "face") + with pytest.raises(NXToolError): + rig.e._set_visibility([face], "hide") + for mode in ["invalid", "isolate"]: + with pytest.raises(NXToolError): + rig.e._set_visibility([rig.ref(sk.geometry[0], "curve")], mode) + with pytest.raises(NXToolError): + rig.e._set_display([rig.ref(sk.geometry[0], "curve")], transparency=10) + for values in [[], None, ["a"] * 1001]: + with pytest.raises(NXToolError): + rig.e._display_targets(values) + with pytest.raises(NXToolError): + rig.e._display_records([Curve()] * 10001) + sk.geometry = [] + with pytest.raises(NXToolError): + rig.e._display_targets([rig.ref(sk, "sketch")]) + old = rig.part + Part(rig.session, tmp_path / "other.prt") + rig.session.Parts.Work = old + with pytest.raises(NXToolError): + rig.e._display_info([face]) + + +def section_builder(rig): + builders = [] + + def create(*args): + target = args[0] if len(args) == 2 else Object("section") + if not hasattr(target, "origin"): + target.origin = point() + target.normal = point(0, 0, 1) + b = NS( + GetOrigin=lambda: target.origin, + GetNormal=lambda: target.normal, + Destroy=Mock(), + ShowClip=True, + ShowCap=True, + ) + b.SetName = target.SetName + b.SetNormal = lambda v: setattr(target, "normal", v) + b.SetOrigin = lambda v: setattr(target, "origin", v) + + def commit(): + if target not in rig.part.DynamicSections: + rig.part.DynamicSections.append(target) + return target + + b.Commit = Mock(side_effect=commit) + builders.append(b) + return b + + rig.part.DynamicSections.CreateSectionBuilder = create + rig.part.DynamicSections.DeleteSections = lambda _, v: [ + rig.part.DynamicSections.remove(x) for x in v + ] + return builders + + +def test_sections_create_edit_toggle_delete_and_inspection_cleanup(rig): + builders = section_builder(rig) + result = rig.e._section_view([1, 2, 3], [0, 0, 4]) + ref = result["object"]["id"] + assert result["normal"] == [0, 0, 1] and not result["geometry_changed"] + assert result["retained_side"] == "negative_normal" + info = rig.e._list_sections() + assert info["count"] == 1 and info["sections"][0]["active"] + assert not rig.session.marks + with pytest.raises(NXToolError): + rig.e._section_view([0, 0, 0], [1, 0, 0]) + rig.e._section_view([4, 5, 6], [1, 0, 0], section=ref, cap=False) + rig.e._section_control(ref, "disable") + assert not rig.part.ModelingViews.WorkView.DisplaySectioningToggle + rig.e._section_control(ref, "enable") + assert rig.part.ModelingViews.WorkView.DisplaySectioningToggle + with pytest.raises(NXToolError): + rig.e._section_control(ref, "bad") + rig.e._section_control(ref, "delete") + assert not rig.part.DynamicSections + assert all(b.Destroy.call_count == 1 for b in builders) + + +@pytest.mark.parametrize( + "origin,normal,name", + [ + ([0, 0], [0, 0, 1], "x"), + ([0, 0, 0], [0, 0, 0], "x"), + ([0, 0, 0], [0, 0, 1], ""), + ([0, 0, 0], [float("nan"), 0, 0], "x"), + ], +) +def test_invalid_section_parameters_do_not_create_builder(rig, origin, normal, name): + builders = section_builder(rig) + with pytest.raises(NXToolError): + rig.e._section_view(origin, normal, name=name) + assert not builders + + +@pytest.mark.parametrize( + "status,dof,expected", [(1, 4, 4), (2, 0, 0), (3, -1, None), (999, 10, None)] +) +@pytest.mark.parametrize("active", [True, False]) +def test_solver_status_and_constraint_links_preserve_edit_state(rig, status, dof, expected, active): + sk = Sketch(rig.session) + sk.status = (status, dof) + curve = Curve() + sk.geometry = [curve] + constraint = Object() + constraint.ConstraintType = 1 + from tests.test_authoring_review import Expression + + rig.nx.Expression = NS(UnitsOption=NS(Expression="expression")) + constraint.AssociatedExpression = Expression("p1") + constraint.AssociatedExpression.Value = 254.0 + constraint.AssociatedExpression.expression_value = 10.0 + sk.constraints = [constraint] + rig.part.Sketches.append(sk) + region = NS(Commit=Mock(), Destroy=Mock()) + rig.part.Sketches.CreateWorkRegionBuilder = lambda: region + if active: + rig.session.ActiveSketch = sk + result = rig.e._sketch_diagnostics(rig.ref(sk, "sketch")) + assert result["remaining_degrees_of_freedom"] == expected + assert result["constraints"][0]["expression"]["value"] == 10 + assert result["geometry"][0]["constraints"] == [result["constraints"][0]["object"]["id"]] + assert rig.session.ActiveSketch == (sk if active else None) + assert not rig.session.marks and region.Destroy.call_count == 1 + assert region.Scope == "all" and result["conflicting_constraints"] is None + + +def test_diagnostics_native_failure_restores_activation_and_marks(rig): + sk = Sketch(rig.session) + region = NS(Commit=Mock(side_effect=RuntimeError("solver failed")), Destroy=Mock()) + rig.part.Sketches.CreateWorkRegionBuilder = lambda: region + with pytest.raises(RuntimeError): + rig.e._sketch_diagnostics(rig.ref(sk, "sketch")) + assert rig.session.ActiveSketch is None and not rig.session.marks + assert region.Destroy.call_count == 1 + rig.session.ActiveSketch = Sketch(rig.session) + with pytest.raises(NXToolError): + rig.e._sketch_diagnostics(rig.ref(sk, "sketch")) + + +def test_highlighting_only_includes_penetration_unless_contact_requested(rig): + a = Body("a") + b = Body("b") + refs = [rig.e._reference(v, "body", rig.part, "body") for v in (a, b)] + rig.e._check_interference = lambda *_: { + "pairs": [{"classification": "contact", "objects": refs}] + } + assert rig.e._highlight_collisions("a", "b")["highlighted_count"] == 0 + assert rig.e._highlight_collisions("a", "b", True)["highlighted_count"] == 2 + assert a.highlighted and b.highlighted + assert rig.e._clear_highlights()["cleared_count"] == 2 and not a.highlighted + b.Highlight = Mock(side_effect=RuntimeError("deleted")) + with pytest.raises(RuntimeError): + rig.e._highlight_collisions("a", "b", True) + assert not a.highlighted + b.Unhighlight = Mock(side_effect=RuntimeError("deleted")) + rig.e._highlighted_objects = [b] + assert rig.e._clear_highlights()["cleared_count"] == 0 + rig.session.IsBatch = True + with pytest.raises(NXToolError): + rig.e._highlight_collisions("a", "b") + with pytest.raises(NXToolError): + rig.e._section_view([0, 0, 0], [0, 0, 1]) + assert enum_name(999, NS(A=1)) == "unknown_999" + + +def test_failed_sketch_deactivation_still_rolls_back_work_region(rig): + sk = Sketch(rig.session) + sk.Deactivate = Mock(side_effect=RuntimeError("deactivate failed")) + region = NS(Commit=Mock(), Destroy=Mock()) + rig.part.Sketches.CreateWorkRegionBuilder = lambda: region + with pytest.raises(RuntimeError): + rig.e._sketch_diagnostics(rig.ref(sk, "sketch")) + assert rig.session.ActiveSketch is None and not rig.session.marks diff --git a/tests/test_documentation_editing.py b/tests/test_documentation_editing.py new file mode 100644 index 0000000..4f3bea1 --- /dev/null +++ b/tests/test_documentation_editing.py @@ -0,0 +1,163 @@ +"""Contracts, preflight and transaction regressions; native geometry tested separately.""" + +import inspect +from types import SimpleNamespace as NS +from unittest.mock import MagicMock, Mock + +import pytest + +from nx_mcp import documentation_editing_server +from nx_mcp.hardened import READ_ONLY +from nx_mcp.runtime import NXToolError +from tests.test_freeform_manufacturing import ff as _freeform_fixture + + +@pytest.fixture +def ff(rig, monkeypatch): + return _freeform_fixture.__wrapped__(rig, monkeypatch) + + +def test_contracts(rig): + for name, fn in vars(documentation_editing_server).items(): + if name.startswith("nx_") and inspect.isfunction(fn): + assert ( + inspect.signature(fn).parameters.keys() + == inspect.signature(rig.e._handlers[name]).parameters.keys() + ) + assert documentation_editing_server.READ_ONLY <= READ_ONLY + + +def test_catalog_reads_local_installed_table_only(ff, tmp_path, monkeypatch): + directory = tmp_path / "UGII" / "modeling_standards" + directory.mkdir(parents=True) + (directory / "NX_Thread_Standard.xml").write_text( + '' + ) + monkeypatch.setenv("UGII_BASE_DIR", str(tmp_path)) + assert ff.e._thread_catalog()["items"] == ["Fixture"] + assert ff.e._thread_catalog(standard="Fixture", limit=1)["next_offset"] == 1 + assert ff.e._thread_catalog(standard="Fixture", size="M6")["items"][0]["Pitch"] == "1" + with pytest.raises(NXToolError, match="Select a standard"): + ff.e._thread_catalog(size="M6") + with pytest.raises(NXToolError): + ff.e._thread_catalog(standard="absent") + monkeypatch.delenv("UGII_BASE_DIR") + with pytest.raises(NXToolError): + ff.e._thread_catalog() + + +def test_standard_thread_requires_unambiguous_catalog_selection(ff): + row = {"Standard": "Fixture", "Size": "M6", "Method": "CUT", "RadialEngage": "0.75"} + ff.e._thread_rows = lambda: [row, {**row, "RadialEngage": "0.5"}] + with pytest.raises(NXToolError) as error: + ff.e._standard_thread("face", "start", "Fixture", "M6", 4) + assert error.value.code == "NX_AMBIGUOUS_THREAD" + ff.e._freeform_builder.assert_not_called() + result = ff.e._standard_thread( + ff.ref(ff.faces[0], "face"), + ff.ref(ff.faces[1], "face"), + "Fixture", + "M6", + 4, + radial_engage="0.75", + ) + assert result["standard"] == "Fixture" + assert ff.b.ThreadInput == ff.nx.Features.ThreadBuilder.Input.ThreadTable + ff.b.Destroy.assert_called_once() + + +def test_column_preflight_prevents_mutations(ff): + ff.e._parts_list_object = lambda _: NS(Tag=123) + ff.uf.Tabnot.AskNmColumns.return_value = 3 + for args in [ + {"action": "append"}, + {"action": "edit", "index": 3}, + {"action": "remove", "index": 0, "title": "bad"}, + {"action": "append", "title": "x", "field": "x", "width": -1}, + ]: + with pytest.raises(NXToolError): + ff.e._parts_list_column("table", **args) + ff.uf.Plist.CreateColumn.assert_not_called() + ff.uf.Tabnot.RemoveColumn.assert_not_called() + + +def test_column_append_and_edit_use_native_preferences(ff, monkeypatch): + ff.e._parts_list_object = lambda _: NS(Tag=123) + ff.e._parts_list_info = lambda _: {"rows": [["one"]]} + ff.uf.Tabnot.AskNmColumns.return_value = 3 + ff.uf.Plist.CreateColumn.return_value = 88 + ff.uf.Tabnot.AskNmHeaderRows.return_value = 1 + ff.nx.UF.Plist = NS(ColumnType=NS(COLUMN_TYPE_GENERAL=1)) + assert ff.e._parts_list_column("table", "append", title="Part", field="native field", width=30)[ + "rows" + ] == [["one"]] + ff.uf.Tabnot.AddColumn.assert_called_once_with(123, 88, 3) + ff.uf.Plist.Update.assert_called_once_with(123) + + +def test_annotation_conflicting_edits_are_rejected(ff): + ff.e._resolve = lambda *_: NS(AnnotationOrigin=None) + for args in [{"delete": True, "name": "x"}, {}, {"name": ""}, {"position": [0, 1]}]: + with pytest.raises(NXToolError): + ff.e._edit_annotation("annotation", **args) + ff.e._update_model.assert_not_called() + + +def test_managed_pmi_refresh_and_missing_source(ff): + obj = Mock() + obj.HasUserAttribute.return_value = True + obj.GetStringAttribute.return_value = ( + '{"kind":"body","body":"body-handle","faces":[],"measured":{"thickness":2}}' + ) + obj.AnnotationOrigin = ff.nx.Point3d(1, 2, 3) + ff.nx.NXObject = NS(AttributeType=NS(String=1)) + ff.e._documentation_annotations = lambda _: [obj] + ff.uf.Tag.AskTagOfHandle.return_value = ff.body.Tag + ff.e._sm_manager = lambda: NS(GetBodyThickness=lambda _: 3) + ff.e._sheet_metal_annotation = Mock(return_value={"modified": ["updated"]}) + assert ff.e._refresh_annotations()["updated"] == ["updated"] + assert ff.e._sheet_metal_annotation.call_args.kwargs["automatic"] + ff.uf.Tag.AskTagOfHandle.side_effect = RuntimeError("stale") + with pytest.raises(NXToolError) as error: + ff.e._refresh_annotations() + assert error.value.code == "NX_STALE_ANNOTATION_SOURCE" + obj.GetStringAttribute.return_value = "disabled" + assert ff.e._refresh_annotations()["updated_count"] == 0 + + +def test_annotation_refresh_failure_rolls_back_the_model_edit(rig): + rig.e._handlers["nx_extrude"] = Mock(return_value={}) + rig.e._refresh_annotations = Mock( + side_effect=NXToolError("NX_STALE_ANNOTATION_SOURCE", "stale source") + ) + rig.session.UndoToMark = Mock() + with pytest.raises(NXToolError) as error: + rig.e.execute("nx_extrude", {}) + assert error.value.details["mutation_outcome"] == "rolled_back" + rig.session.UndoToMark.assert_called_once() + + +def test_automatic_bend_table_rebuild_and_opt_out(ff): + from tests.fakes import Object + + table = Object("bend table") + builder = MagicMock() + builder.Style.BendTable.AutomaticUpdate = True + builder.Commit.return_value = table + + class Tables(list): + def CreateBendTableBuilder(self, existing): + assert existing is table + return builder + + ff.part.Annotations = NS(BendTables=Tables([table])) + ff.e._documentation_annotations = lambda _: [] + ff.e._table_cells = Mock(side_effect=[[["80"]], [["85"]]]) + assert ff.e._refresh_annotations()["updated_count"] == 1 + builder.Commit.assert_called_once() + builder.Destroy.assert_called_once() + builder.reset_mock() + builder.Style.BendTable.AutomaticUpdate = False + assert ff.e._refresh_annotations()["updated_count"] == 0 + builder.Commit.assert_not_called() + builder.Destroy.assert_called_once() diff --git a/tests/test_engineering.py b/tests/test_engineering.py new file mode 100644 index 0000000..7bdc6d5 --- /dev/null +++ b/tests/test_engineering.py @@ -0,0 +1,877 @@ +"""Regression contracts for observed NX failures; native receipts validate geometry. + +These seams verify validation, result cardinality, transform semantics, and cleanup. +They do not claim to emulate the NX geometric kernel. +""" + +import contextlib +import struct +import sys +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.hardened import IDENTITY +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Edge, Feature, Object, Sketch, point + +pytestmark = pytest.mark.fake_nx + + +@pytest.fixture +def eng(rig, monkeypatch): + r = rig + r.body = Body() + r.feature = Feature(bodies=[r.body]) + r.sketch = Sketch(r.session) + monkeypatch.setattr(Sketch, "Feature", r.feature, raising=False) + r.part.Bodies.append(r.body) + r.part.Features.append(r.feature) + r.part.Sketches.append(r.sketch) + for o in [r.body, r.feature, r.sketch, *r.body.faces, *r.body.edges]: + o.OwningPart = r.part + o.IsOccurrence = False + for o in [*r.body.faces, *r.body.edges]: + o.GetBody = lambda: r.body + r.nx.SmartObject = NS(UpdateOption=NS(WithinModeling=1)) + r.nx.Features.MoveObject = Feature + r.part.Directions = NS(CreateDirection=lambda p, v, _: NS(origin=p, vector=v)) + r.part.CoordinateSystems = NS( + CreateCoordinateSystem=lambda p, m, _: NS(Origin=p, Orientation=m) + ) + r.part.ScRuleFactory = NS(CreateRuleOptions=lambda: NS(Dispose=Mock())) + for kind in ["Edge", "Body", "Face"]: + setattr(r.part.ScRuleFactory, "CreateRule" + kind + "Dumb", lambda objects: objects) + r.part.ScRuleFactory.CreateRuleCurveFeature = lambda objects, *_: objects + + class Collector: + def ReplaceRules(self, rules, *_): + self.rules = rules + + def GetRules(self): + return self.rules + + r.part.ScCollectors = NS(CreateCollector=Collector) + r.part.Sections = NS(CreateSection=lambda: NS(AddToSection=Mock())) + r.nx.Section = NS(Mode=NS(Create=1)) + g = NS( + Extend=NS(ExtendType=NS(Value="value", ThroughAll="all", UntilSelected="face")), + BooleanOperation=NS( + BooleanType=NS( + Create="create", Unite="unite", Subtract="subtract", Intersect="intersect" + ) + ), + ) + monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", g) + r.nx.GeometricUtilities = g + r.e._update_model = Mock() + r.e._check_sketch_result = Mock(return_value={"valid": True}) + r.e._editing_sketch = lambda _: contextlib.nullcontext() + r.builders = [] + + def builder(*_): + def expression(): + return NS(RightHandSide="0") + + b = NS( + Destroy=Mock(), + CommitFeature=Mock(return_value=r.feature), + Commit=Mock(return_value=r.feature), + DefaultThickness=expression(), + StationaryReference=Collector(), + FaceSetAngleExpressionList=NS(Append=Mock()), + Type=NS(Face=1), + SectionsList=NS(Append=Mock()), + BodyPreferenceTypes=NS(Solid=1, Sheet=2), + Limits=NS(StartExtend=NS(Value=expression()), EndExtend=NS(Value=expression())), + BooleanOperation=NS(SetTargetBodies=Mock()), + BooleanOption=NS(SetTargetBodies=Mock()), + Diameter=expression(), + Height=expression(), + FirstOffsetExp=expression(), + ChamferOption=NS(SymmetricOffsets=1), + AddChainset=Mock(), + ExtractType=NS(Body=1), + ExtractBodyCollector=Collector(), + ObjectToMoveObject=NS(Add=Mock()), + MoveObjectResultOptions=NS(MoveOriginal=1), + TransformMotion=NS(Options=NS(CsysToCsys=1)), + MirrorBodyCollector=Collector(), + Plane=NS(), + SectionList=NS(Append=Mock()), + GuideList=NS(Append=Mock()), + ) + r.builders.append(b) + return b + + r.builder = builder + for name in [ + "Extrude", + "Shell", + "ThroughCurves", + "Draft", + "EdgeBlend", + "Chamfer", + "Cylinder", + "ExtractFace", + "MirrorBody", + "Swept", + ]: + setattr(r.part.Features, "Create" + name + "Builder", builder) + r.part.BaseFeatures = NS(CreateMoveObjectBuilder=builder) + r.part.Datums = NS(CreateFixedDatumPlane=Mock(return_value=Object("plane"))) + r.part.CreateExpressionCollectorSet = lambda *args: args + return r + + +def test_owned_geometry_rejects_occurrences_and_other_parts(eng): + ref = eng.ref(eng.body) + eng.body.IsOccurrence = True + with pytest.raises(NXToolError, match="owned"): + eng.e._engineering_owned(ref, "body") + eng.body.IsOccurrence = False + eng.body.OwningPart = object() + with pytest.raises(NXToolError, match="owned"): + eng.e._engineering_owned(ref, "body") + + +@pytest.mark.parametrize( + "params", + [ + {"distance": 8, "start": -2}, + {"distance": 8, "symmetric": True}, + {"distance": 8, "direction": [0, 1, 1], "reverse": True}, + {"end_type": "through_all", "boolean": "subtract"}, + {"end_type": "up_to_face"}, + ], +) +def test_extended_extrude_preserves_limits_normal_and_multibody_results(eng, params): + r = eng + if params.get("boolean"): + params = {**params, "targets": [r.ref(r.body)]} + if params.get("end_type") == "up_to_face": + params = {**params, "target_face": r.ref(r.body.faces[0], "face")} + second = Body() + r.feature.bodies.append(second) + out = r.e._extrude(r.ref(r.sketch, "sketch"), **params) + assert out["body_count"] == 2 and len(out["bodies"]) == 2 + b = r.builders[-1] + assert b.Destroy.call_count == 1 + if params.get("symmetric"): + assert float(b.Limits.StartExtend.Value.RightHandSide) == -4 + assert float(b.Limits.EndExtend.Value.RightHandSide) == 4 + if params.get("reverse"): + assert out["limits"]["direction"][1] < 0 + if params.get("boolean"): + b.BooleanOperation.SetTargetBodies.assert_called_once_with([r.body]) + + +@pytest.mark.parametrize( + "params", + [ + {}, + {"start": 1, "end_type": "bogus"}, + {"start": 1, "boolean": "Create"}, + {"start": 1, "symmetric": True, "distance": 4}, + {"end_type": "up_to_face"}, + {"start": 5, "distance": 4}, + {"end_type": "through_all", "distance": 2}, + {"end_type": "through_all"}, + {"start": 1, "distance": 4, "boolean": "subtract"}, + {"distance": float("nan"), "start": 1}, + {"start": 1, "distance": 4, "direction": [0, 0, 0]}, + ], +) +def test_invalid_extrude_arguments_do_not_allocate_builders(eng, params): + with pytest.raises(NXToolError): + eng.e._extrude(eng.ref(eng.sketch, "sketch"), **params) + assert not eng.builders + + +@pytest.mark.parametrize("outward,flipped", [(False, True), (True, False)]) +def test_shell_direction_regression_and_removed_face_ownership(eng, outward, flipped): + r = eng + r.e._shell(r.ref(r.body), 1, [r.ref(r.body.faces[0], "face")], outward) + assert r.builders[-1].DefaultThicknessFlip is flipped + assert r.builders[-1].Tolerance > 0 + r.body.faces[0].GetBody = lambda: Body() + with pytest.raises(NXToolError, match="belong"): + r.e._shell(r.ref(r.body), 1, [r.ref(r.body.faces[0], "face")]) + assert len(r.builders) == 1 + + +@pytest.mark.parametrize( + "method,args", + [ + ("_shell", {"thickness": 0}), + ("_blend", {"radius": 0}), + ("_chamfer", {"offset": -1}), + ("_draft", {"angle": 90}), + ("_loft", {"sketches": []}), + ], +) +def test_invalid_feature_dimensions_rejected(eng, method, args): + if method == "_shell": + args["body"] = eng.ref(eng.body) + elif method in {"_blend", "_chamfer"}: + args["edges"] = [] + elif method == "_draft": + args.update(faces=[], stationary_face="", direction=[0, 0, 1]) + with pytest.raises(NXToolError): + getattr(eng.e, method)(**args) + assert not eng.builders + + +@pytest.mark.parametrize("solid", [True, False]) +def test_loft_sections_are_ordered_and_all_results_reported(eng, solid): + other = Sketch(eng.session) + other.IsOccurrence = False + other.OwningPart = eng.part + eng.part.Sketches.append(other) + result = eng.e._loft([eng.ref(eng.sketch, "sketch"), eng.ref(other, "sketch")], solid) + assert result["body_count"] == 1 + assert eng.builders[-1].SectionsList.Append.call_count == 2 + assert eng.builders[-1].BodyPreference == (1 if solid else 2) + + +def test_builder_failure_always_destroys_native_handle(eng): + b = eng.builder() + b.CommitFeature.side_effect = RuntimeError("native failed") + eng.part.Features.CreateShellBuilder = lambda _: b + with pytest.raises(RuntimeError, match="native failed"): + eng.e._shell(eng.ref(eng.body), 1) + b.Destroy.assert_called_once() + + +@pytest.mark.parametrize("method,arg", [("_blend", "radius"), ("_chamfer", "offset")]) +def test_edge_features_resolve_real_edges_and_reject_mixed_bodies(eng, method, arg): + r = eng + edge = r.body.edges[0] + out = getattr(r.e, method)([r.ref(edge, "edge")], **{arg: 1}) + assert out["modified"][0]["id"] == r.ref(r.body) + assert r.builders[-1].Destroy.call_count == 1 + foreign = Edge() + foreign.OwningPart = r.part + foreign.IsOccurrence = False + foreign.GetBody = lambda: Body() + with pytest.raises(NXToolError, match="one owned body"): + getattr(r.e, method)([r.ref(edge, "edge"), r.ref(foreign, "edge")], **{arg: 1}) + + +def test_hole_numeric_conversion_explicit_target_and_direction(eng): + r = eng + out = r.e._hole(2, 5, 1, 2, 3, body=r.ref(r.body), direction=[0, 0, -2]) + assert out["location"] == [1.0, 2.0, 3.0] and out["direction"] == [0, 0, -1] + assert r.builders[-1].Origin.Z == 3.0 + r.part.Bodies.append(Body()) + with pytest.raises(NXToolError, match="one owned solid"): + r.e._hole(2, 5, 1, 2, 3) + + +def test_draft_excludes_stationary_face_and_sets_tolerances(eng): + from tests.fakes import Face + + r = eng + fixed = r.body.faces[0] + side = Face() + side.IsOccurrence = False + side.OwningPart = r.part + side.GetBody = lambda: r.body + r.e._draft([r.ref(side, "face")], r.ref(fixed, "face"), [0, 0, 1], 5) + assert r.builders[-1].AngleTolerance > 0 and r.builders[-1].DistanceTolerance > 0 + with pytest.raises(NXToolError, match="exclude"): + r.e._draft([r.ref(fixed, "face")], r.ref(fixed, "face"), [0, 0, 1], 5) + + +@pytest.mark.parametrize("copy", [False, True]) +def test_body_transform_is_absolute_and_copy_uses_associative_extract(eng, copy): + r = eng + out = r.e._transform_bodies([20, 30, 40], IDENTITY, [r.ref(r.body)], copy) + move = r.builders[-1] + assert move.MoveParents is False and move.Associative is True + assert move.TransformMotion.FromCsys.Origin.X == 0 + assert move.TransformMotion.ToCsys.Origin.X == 20 + assert bool(out["copy_feature"]) == copy + edited = r.e._transform_bodies([50, 30, 40], IDENTITY, feature=out["feature"]["id"]) + assert edited["translation"] == [50, 30, 40] + assert r.builders[-1].TransformMotion.ToCsys.Origin.X == 50 + with pytest.raises(NXToolError, match="omit"): + r.e._transform_bodies([0, 0, 0], IDENTITY, [r.ref(r.body)], feature=out["feature"]["id"]) + with pytest.raises(NXToolError, match="Select bodies"): + r.e._transform_bodies([0, 0, 0], IDENTITY) + + +def test_nonassociative_copy_result_is_rejected(eng): + eng.nx.Features.MoveObject = type("MoveObject", (Feature,), {}) + with pytest.raises(NXToolError, match="editable MoveObject"): + eng.e._transform_bodies([1, 0, 0], IDENTITY, [eng.ref(eng.body)]) + assert eng.builders[-1].Destroy.call_count == 1 + + +@pytest.mark.parametrize("plane", ["XY", "XZ", "YZ"]) +def test_mirror_preserves_original_and_uses_origin_plane(eng, plane): + result = eng.e._mirror_body(eng.ref(eng.body), plane) + assert result["body_count"] == 1 and eng.builders[-1].DeleteSourceBody is False + assert eng.part.Datums.CreateFixedDatumPlane.call_args.args[0].X == 0 + with pytest.raises(NXToolError): + eng.e._mirror_body(eng.ref(eng.body), "custom") + + +def test_sweep_requires_distinct_owned_sketches_and_explicit_boolean_target(eng): + r = eng + other = Sketch(r.session) + other.IsOccurrence = False + other.OwningPart = r.part + a, b = r.ref(r.sketch, "sketch"), r.ref(other, "sketch") + out = r.e._sweep(a, b) + assert out["body_count"] == 1 and r.builders[-1].GuideList.Append.call_count == 1 + for args in [ + (a, a, "none", None), + (a, b, "bad", None), + (a, b, "subtract", None), + (a, b, "none", [r.ref(r.body)]), + (a, b, "subtract", []), + ]: + with pytest.raises(NXToolError): + r.e._sweep(*args) + + +@pytest.fixture +def rendering(eng): + r = eng + r.nx.View.RenderingStyleType.Studio = "studio" + view = NS(RenderingStyle="original", UpdateDisplay=Mock()) + r.part.ModelingViews = NS(WorkView=view) + r.e._view_info = lambda: {"camera": "unchanged"} + lights = NS( + LightsShadedViewsLightingCollection="old", + LightingCollectionType=NS(**{"Lighting" + str(i): i for i in range(1, 6)}), + Commit=Mock(), + Destroy=Mock(), + ) + capture = NS( + SourceType=NS(WorkView=1), + UnitsEnumType=NS(Pixels=1), + BackgroundOptions=NS(Original=1, CustomColor=2, Transparent=3), + SetCustomBackgroundColor=Mock(), + Destroy=Mock(), + ) + + def dimensions(v): + capture.dimensions = v + + capture.SetImageDimensionsInteger = dimensions + + def commit(): + height, width = capture.dimensions + data = b"\x89PNG\r\n\x1a\n" + b"\0" * 8 + struct.pack(">II", width, height) + Path(capture.NativeFileBrowser).write_bytes(data) + + capture.Commit = Mock(side_effect=commit) + r.part.Views = NS( + CreateStudioImageCaptureBuilder=lambda: capture, CreateLighting=lambda _: lights + ) + r.view, r.lights, r.capture = view, lights, capture + return r + + +@pytest.mark.parametrize( + "background,color", + [("white", None), ("original", None), ("transparent", None), ("color", [0.2, 0.3, 0.4])], +) +def test_render_artifact_dimensions_checksum_and_view_restoration(rendering, background, color): + import hashlib + + r = rendering + out = r.e._render_view(width=640, height=480, background=background, color=color, lighting=2) + assert out["resolution"] == [640, 480] + assert out["sha256"] == hashlib.sha256(Path(out["path"]).read_bytes()).hexdigest() + assert r.view.RenderingStyle == "original" + assert r.lights.LightsShadedViewsLightingCollection == "old" + r.lights.Destroy.assert_called_once() + r.capture.Destroy.assert_called_once() + with pytest.raises(NXToolError): + r.e._render_view(path=out["path"]) + + +@pytest.mark.parametrize( + "params", + [ + {"width": True}, + {"height": 100}, + {"background": "bad"}, + {"style": "bad"}, + {"color": [1, 1, 1]}, + {"background": "color"}, + {"background": "color", "color": [0, 0, 2]}, + {"lighting": 0}, + {"lighting": True}, + {"path": "bad.jpg"}, + ], +) +def test_invalid_render_requests_have_no_side_effects(rendering, params): + if "path" in params: + params = {**params, "path": str(rendering.e.workspace.root / params["path"])} + with pytest.raises(NXToolError): + rendering.e._render_view(**params) + rendering.capture.Commit.assert_not_called() + assert rendering.view.RenderingStyle == "original" + + +@pytest.mark.parametrize("failure", ["native", "invalid_png", "wrong_resolution"]) +def test_render_failure_removes_partial_file_and_restores_view(rendering, failure): + r = rendering + file = r.e.workspace.root / "bad.png" + + def commit(): + file.write_bytes(b"partial") + if failure == "native": + raise RuntimeError("failed") + if failure == "wrong_resolution": + file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\0" * 8 + struct.pack(">II", 1, 1)) + + r.capture.Commit.side_effect = commit + with pytest.raises((RuntimeError, NXToolError)): + r.e._render_view(path=str(file), lighting=1) + assert not file.exists() and r.view.RenderingStyle == "original" + assert r.lights.LightsShadedViewsLightingCollection == "old" + + +def test_render_requires_interactive_display_part(rendering): + r = rendering + r.session.IsBatch = True + with pytest.raises(NXToolError, match="interactive"): + r.e._render_view() + r.session.IsBatch = False + r.session.Parts.Display = None + with pytest.raises(NXToolError, match="display part"): + r.e._render_view() + + +@pytest.fixture +def material(eng): + r = eng + r.nx.PhysicalMaterial = NS(Type=NS(Isotropic=1)) + materials = [] + + class Materials(list): + def CreatePhysicalMaterialBuilder(self, _): + b = NS( + PropertyTable=NS( + SetBaseScalarWithDataPropertyValue=lambda key, value, unit: setattr( + r, "density", value + ) + ), + Destroy=Mock(), + ) + + def commit(): + m = NS(Name=b.Name, AssignObjects=Mock()) + self.append(m) + return m + + b.Commit = commit + r.material_builder = b + return b + + def AskMaterialOfObject(self, _): + return self[0] if self else None + + r.materials = Materials(materials) + r.part.MaterialManager = NS(PhysicalMaterials=r.materials) + r.part.UnitCollection = NS(FindObject=lambda name: name) + r.nx.UF.Modl = NS(DensityUnits=NS(KILOGRAMS_METERS=1)) + r.density = 1000 + r.uf.Modeling = NS(AskBodyDensity=lambda *_: r.density) + return r + + +def test_material_assignment_checks_native_density_and_names(material): + r = material + assert r.e._material_info()["bodies"][0]["material_name"] is None + out = r.e._set_material([r.ref(r.body)], "Aluminum", 2700) + assert out["verified_densities_kg_m3"] == [2700] + assert r.e._material_info()["bodies"][0]["material_name"] == "Aluminum" + r.materials[0].AssignObjects.assert_called_once_with([r.body]) + with pytest.raises(NXToolError, match="new material name"): + r.e._set_material([r.ref(r.body)], "ALUMINUM", 1000) + r.material_builder.Destroy.assert_called_once() + + +@pytest.mark.parametrize( + "name,density,bodies", [("", 1000, True), ("a", -1, True), ("a", 1000, False)] +) +def test_invalid_material_assignment_precedes_creation(material, name, density, bodies): + r = material + with pytest.raises(NXToolError): + r.e._set_material([r.ref(r.body)] if bodies else [], name, density) + assert not r.materials + + +def test_material_density_readback_mismatch_is_error(material): + r = material + r.uf.Modeling.AskBodyDensity = lambda *_: 42 + with pytest.raises(NXToolError, match="densities differ"): + r.e._set_material([r.ref(r.body)], "Aluminum", 2700) + r.material_builder.Destroy.assert_called_once() + + +def test_mass_tensor_uses_centroidal_values_and_product_signs(material): + from tests.fakes import matrix + + r = material + values = list(range(47)) + r.uf.Modeling.AskMassProps3d = Mock(return_value=(values, [0] * 13)) + r.part.WCS = NS(CoordinateSystem=NS(Origin=point(1, 2, 3), Orientation=NS(Element=matrix()))) + out = r.e._mass_properties() + assert out["center_of_gravity_m"] == [3, 4, 5] + assert out["inertia_tensor_centroid_kg_m2"] == [[12, -19, -21], [-19, 13, -20], [-21, -20, 14]] + assert out["wcs_origin_in_part_units"] == [1, 2, 3] + assert out["semantics"] == "sum_of_included_bodies_with_assigned_densities" + + +def test_pdf_export_reports_actual_artifact_and_refuses_overwrite(eng): + r = eng + from contextlib import nullcontext + + r.e._drawing_save_context = lambda *_, **__: nullcontext() + sheet = Object("Sheet1") + sheet.Open = Mock() + sheet.GetDraftingViews = Mock(return_value=[Object("View")]) + r.part.DrawingSheets = [sheet] + b = NS( + ActionOption=NS(Native=1), + SizeOption=NS(FullScale=1), + UnitsOption=NS(Metric=1), + OutputTextOption=NS(Text=1), + ImageResolutionOption=NS(High=3), + Color=NS(AsDisplayed=0), + SourceBuilder=NS(SetSheets=Mock()), + Destroy=Mock(), + ) + b.Commit = lambda: Path(b.Filename).write_bytes(b"%PDF-1.7\nfixture") + r.e._active_mark = None + r.e._update_model = Mock(side_effect=AssertionError("Export has no modeling mark")) + r.part.PlotManager = NS(CreatePrintPdfbuilder=lambda: b) + r.part.DraftingViews = NS(UpdateViews=Mock()) + file = r.e.workspace.root / "drawings" / "test.pdf" + result = r.e._export_drawing_pdf(str(file)) + assert result["sheet_count"] == 1 and result["size"] == file.stat().st_size + b.SourceBuilder.SetSheets.assert_called_once_with([sheet]) + assert b.RasterImages and not b.ShadedGeometry + assert b.ImageResolution == b.ImageResolutionOption.High + r.part.DraftingViews.UpdateViews.assert_called_once_with(sheet.GetDraftingViews()) + b.Destroy.assert_called_once() + with pytest.raises(NXToolError): + r.e._export_drawing_pdf(str(file)) + file.unlink() + r.part.DrawingSheets = [] + with pytest.raises(NXToolError, match="drawing sheet"): + r.e._export_drawing_pdf(str(file)) + + +def test_invalid_pdf_output_is_removed(eng): + r = eng + from contextlib import nullcontext + + r.e._drawing_save_context = lambda *_, **__: nullcontext() + sheet = Object("sheet") + sheet.Open = Mock() + sheet.GetDraftingViews = Mock(return_value=[Object("View")]) + r.part.DrawingSheets = [sheet] + b = NS( + ActionOption=NS(Native=1), + SizeOption=NS(FullScale=1), + UnitsOption=NS(Metric=1), + OutputTextOption=NS(Text=1), + ImageResolutionOption=NS(High=3), + Color=NS(AsDisplayed=0), + SourceBuilder=NS(SetSheets=Mock()), + Destroy=Mock(), + ) + b.Commit = lambda: Path(b.Filename).write_bytes(b"not PDF") + r.e._active_mark = None + r.e._update_model = Mock(side_effect=AssertionError("Export has no modeling mark")) + r.part.PlotManager = NS(CreatePrintPdfbuilder=lambda: b) + r.part.DraftingViews = NS(UpdateViews=Mock()) + file = r.e.workspace.root / "bad.pdf" + with pytest.raises(NXToolError, match="did not produce"): + r.e._export_drawing_pdf(str(file)) + assert not file.exists() + b.Destroy.assert_called_once() + + +@pytest.fixture +def project(eng): + import shutil + + from tests.fakes import Component, Part + + r = eng + source = Path(r.part.FullPath) + source.write_bytes(b"original assembly") + prototype = source.parent / "prototype" / "body.prt" + prototype.parent.mkdir() + prototype.write_bytes(b"original body") + root = Component("root") + child = Component("child", parent=root) + child.Prototype = NS(FullPath=str(prototype)) + root.children = [child] + r.part.ComponentAssembly.RootComponent = root + naming = {} + clone = NS( + OperationClass=NS(CLONE_OPERATION=1), + Action=NS(CLONE=1), + NamingTechnique=NS(USER_NAME=1), + Initialise=Mock(), + SetDefAction=Mock(), + AddAssembly=Mock(), + InitNamingFailures=lambda: None, + Terminate=Mock(), + ) + clone.SetNaming = lambda src, _, dst: naming.update({src: dst}) + clone.PerformClone = lambda _: [shutil.copyfile(src, dst) for src, dst in naming.items()] + r.uf.Clone = clone + + def open_part(path): + part = Part(r.session, Path(path)) + part.IsModified = False + parent = Component("root") + kid = Component("child", parent=parent) + kid.Prototype = NS(FullPath=naming[str(prototype.resolve())]) + parent.children = [kid] + part.ComponentAssembly.RootComponent = parent + return {} + + r.e._open_part = open_part + r.part.IsModified = False + r.clone, r.naming, r.prototype = clone, naming, prototype + return r + + +@pytest.mark.parametrize("activate", [False, True]) +def test_project_copy_preserves_subfolders_sources_and_rewrites_dependencies(project, activate): + r = project + dest = r.e.workspace.root / "new" / "project" + result = r.e._copy_project(str(dest), "COPY_", activate) + assert result["references_verified"] and result["dependency_count"] == 1 + assert (dest / "prototype" / "COPY_body.prt").read_bytes() == b"original body" + assert Path(r.part.FullPath).read_bytes() == b"original assembly" + assert r.prototype.read_bytes() == b"original body" + assert (dest / "nx-project-manifest.json").is_file() + assert (r.session.Parts.Work == r.part) is (not activate) + r.clone.Terminate.assert_called_once() + + +def test_project_copy_failure_removes_only_new_destination(project): + r = project + dest = r.e.workspace.root / "failed" + + def failure(_): + first = next(iter(r.naming.values())) + Path(first).write_bytes(b"partial") + raise RuntimeError("translator stopped") + + r.clone.PerformClone = failure + with pytest.raises(RuntimeError, match="translator stopped"): + r.e._copy_project(str(dest), "COPY_") + assert not dest.exists() and r.prototype.read_bytes() == b"original body" + r.clone.Terminate.assert_called_once() + + +@pytest.mark.parametrize( + "failure", ["existing", "prefix", "unsaved", "missing", "unloaded", "collision"] +) +def test_project_copy_preflight_prevents_unsafe_clone(project, failure): + r = project + dest = r.e.workspace.root / "new" + prefix = "COPY_" + if failure == "existing": + dest.mkdir() + if failure == "prefix": + prefix = "../" + if failure == "unsaved": + r.part.IsModified = True + if failure == "missing": + r.prototype.unlink() + if failure == "unloaded": + r.part.ComponentAssembly.RootComponent.children[0].Prototype = None + if failure == "collision": + prefix = "" + prefix = Path(r.part.FullPath).name.split(".")[0] # explicit loaded collision below + if failure == "collision": + from tests.fakes import Part + + Part(r.session, r.e.workspace.root / ("COPY_" + Path(r.part.FullPath).name)) + prefix = "COPY_" + with pytest.raises(NXToolError): + r.e._copy_project(str(dest), prefix) + r.clone.Initialise.assert_not_called() + + +@pytest.fixture +def assembly_constraints(eng, monkeypatch): + from tests.fakes import Component, Face + + r = eng + + class Constraint(Object): + Type = NS(Fix=1, Distance=2, Angle=3, Touch=4, Parallel=5, Perpendicular=6, Concentric=7) + Alignment = NS(InferAlign=1, CoAlign=2, ContraAlign=3) + SolverStatus = NS(Solved=1, NotSolved=2) + + def __init__(self): + super().__init__("constraint") + self.Suppressed = False + self.Expression = 5 + self.refs = [] + self.status = 1 + + def CreateConstraintReference(self, obj, geom, *_): + self.refs.append(NS(GetMovableObject=lambda: obj, GetGeometry=lambda: geom)) + + def SetExpression(self, value): + self.Expression = float(value) + + def GetConstraintStatus(self): + return self.status + + def GetReferences(self): + return self.refs + + module = NS(Constraint=Constraint) + monkeypatch.setitem(sys.modules, "NXOpen.Positioning", module) + r.nx.Positioning = module + r.nx.Assemblies = NS(Component=Component) + root = Component("root") + moving = Component("moving", parent=root) + target = Component("target", parent=root) + root.children = [moving, target] + r.part.ComponentAssembly.RootComponent = root + faces = [] + for c in [moving, target]: + f = Face() + f.IsOccurrence = True + f.OwningPart = r.part + f.OwningComponent = c + faces.append(f) + pos = NS( + Constraints=[], + BeginAssemblyConstraints=Mock(), + EndAssemblyConstraints=Mock(), + ClearNetwork=Mock(), + ) + + def create(_): + c = Constraint() + pos.Constraints.append(c) + return c + + pos.CreateConstraint = create + networks = [] + + def network(): + captured = {c.Tag: c.Expression for c in pos.Constraints} + net = NS(AddConstraint=Mock(), Solve=Mock(), ApplyToModel=Mock(), captured=captured) + networks.append(net) + return net + + pos.EstablishNetwork = network + r.part.ComponentAssembly.Positioner = pos + r.e._expression_record = lambda e: {"value": e} + r.pos, r.networks, r.Constraint = pos, networks, Constraint + r.moving, r.target, r.faces = moving, target, faces + return r + + +def test_assembly_distance_edit_builds_network_after_changing_expression(assembly_constraints): + r = assembly_constraints + result = r.e._assembly_constraint( + "distance", + r.ref(r.moving, "component"), + r.ref(r.faces[0], "face"), + r.ref(r.target, "component"), + r.ref(r.faces[1], "face"), + 5, + "opposite", + ) + ref = result["object"]["id"] + constraint = r.pos.Constraints[0] + result = r.e._edit_assembly_constraint(ref, value=12, alignment="same") + assert r.networks[-1].captured[constraint.Tag] == 12, "Network captured the old expression" + assert result["expression"]["value"] == 12 and result["alignment"] == "CoAlign" + r.e._edit_assembly_constraint(ref, suppressed=True) + assert r.e._list_assembly_constraints()["constraints"][0]["suppressed"] + assert r.pos.ClearNetwork.call_count == 3 and r.pos.EndAssemblyConstraints.call_count == 3 + + +def test_assembly_fix_and_solver_failure_cleanup(assembly_constraints): + r = assembly_constraints + result = r.e._assembly_constraint("fix", r.ref(r.moving, "component")) + constraint = r.pos.Constraints[0] + constraint.status = 2 + with pytest.raises(NXToolError, match="not solved"): + r.e._edit_assembly_constraint(result["object"]["id"], suppressed=False) + assert r.pos.ClearNetwork.call_count == 2 and r.pos.EndAssemblyConstraints.call_count == 2 + with pytest.raises(NXToolError): + r.e._edit_assembly_constraint(result["object"]["id"], value=12) + with pytest.raises(NXToolError): + r.e._edit_assembly_constraint(result["object"]["id"]) + + +@pytest.mark.parametrize( + "change", + [ + "same_component", + "wrong_owner", + "bad_type", + "bad_alignment", + "missing_value", + "negative", + "fix_extra", + "suppressed", + ], +) +def test_invalid_assembly_constraints_do_not_create_network(assembly_constraints, change): + r = assembly_constraints + args = { + "constraint_type": "distance", + "component": r.ref(r.moving, "component"), + "geometry": r.ref(r.faces[0], "face"), + "target_component": r.ref(r.target, "component"), + "target_geometry": r.ref(r.faces[1], "face"), + "value": 5, + } + if change == "same_component": + args["target_component"] = args["component"] + if change == "wrong_owner": + args["geometry"] = args["target_geometry"] + if change == "bad_type": + args["constraint_type"] = "bogus" + if change == "bad_alignment": + args["alignment"] = "bogus" + if change == "missing_value": + args["value"] = None + if change == "negative": + args["value"] = -5 + if change == "fix_extra": + args["constraint_type"] = "fix" + if change == "suppressed": + r.moving.IsSuppressed = True + with pytest.raises(NXToolError): + r.e._assembly_constraint(**args) + r.pos.BeginAssemblyConstraints.assert_not_called() + + +@pytest.mark.parametrize("handle", ["capture", "lights"]) +def test_render_cleanup_failure_still_restores_style_and_removes_artifact(rendering, handle): + r = rendering + getattr(r, handle).Destroy.side_effect = RuntimeError("cleanup failed") + path = r.e.workspace.root / "cleanup.png" + with pytest.raises(NXToolError) as exc: + r.e._render_view(path=str(path), lighting=1) + assert exc.value.details["mutation_outcome"] == "partial" + assert r.view.RenderingStyle == "original" and not path.exists() + r.lights.Destroy.assert_called_once() diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py new file mode 100644 index 0000000..381893d --- /dev/null +++ b/tests/test_exploded_views.py @@ -0,0 +1,601 @@ +"""Observed NX explosion contracts; native fixtures separately verify NX geometry.""" + +import copy +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.hardened import IDENTITY, matmul, matvec, rows, transpose, xyz +from nx_mcp.runtime import NXToolError +from tests.fakes import Collection, Component, Object, point + +pytestmark = pytest.mark.fake_nx + + +def compose(a, b): + ar, at = a + br, bt = b + return matmul(ar, br), [x + y for x, y in zip(matvec(ar, bt), at, strict=True)] + + +def inverse(pose): + rotation, position = pose + inv = transpose(rotation) + return inv, matvec(inv, [-x for x in position]) + + +@pytest.fixture +def explosions(rig): + r = rig + r.e._sheet_units = lambda _: "mm" + r.e._view_sheet = lambda _: None + r.e._place_drawing_view = Mock() + r.uf.Disp = NS(RegenerateDisplay=Mock()) + r.uf.View = NS(AskViewDependentStatus=lambda _: (0, "")) + root = Component("root") + parent = Component("parent", parent=root) + leaf = Component("leaf", parent=parent) + base = Component("base", parent=root) + for c, pos in [(parent, [10, 20, 30]), (leaf, [10, 40, 30])]: + c.position = point(*pos) + c.rotation = r.e._nx_matrix([[0, -1, 0], [1, 0, 0], [0, 0, 1]]) + r.part.ComponentAssembly.RootComponent = root + for c in (root, parent, leaf, base): + c.OwningPart = r.part + r.parent, r.leaf, r.base = parent, leaf, base + exs = Collection() + r.part.ComponentAssembly.Explosions = exs + r.part.DrawingSheets = Collection() + r.part.DrawingSheets.CurrentDrawingSheet = None + r.part.Drafting = NS(ExitDraftingApplication=Mock()) + r.part.ModelingViews = Collection() + work = Object("Work") + work.OwningPart = r.part + work.Fit = Mock() + r.part.ModelingViews.append(work) + r.part.ModelingViews.WorkView = work + r.part.ModelingViews.FindObject = lambda name: work + r.part.DraftingViews = Collection() + r.part.DraftingViews.UpdateViews = Mock() + association = {} + + class ExplodedComponent: + def __init__(self, component, explosion): + self.component, self.explosion = component, explosion + + def GetComponent(self): + return self.component + + def GetChildren(self): + return [ExplodedComponent(c, self.explosion) for c in self.component.GetChildren()] + + def GetPosition(self): + c = self.component + baseline = rows(c.rotation), xyz(c.position) + if c.Parent: + pp, pr = ExplodedComponent(c.Parent, self.explosion).GetPosition() + inherited = compose( + (rows(pr), xyz(pp)), inverse((rows(c.Parent.rotation), xyz(c.Parent.position))) + ) + baseline = compose(inherited, baseline) + delta = self.explosion.deltas.get(c.Tag, IDENTITY4) + rotation, position = compose( + baseline, ([v[:3] for v in delta[:3]], [v[3] for v in delta[:3]]) + ) + return point(*position), r.e._nx_matrix(rotation) + + class Explosion(Object): + def __init__(self, name): + super().__init__(name) + self.OwningPart = r.part + self.deltas = {} + self.RootComponent = ExplodedComponent(root, self) + + def Delete(self): + exs.remove(self) + + def create(name): + ex = Explosion(name) + exs.append(ex) + return ex + + exs.Create = Mock(side_effect=create) + r.ex = create("Service") + + def ex_by_tag(tag): + return next(e for e in exs if e.Tag == tag) + + r.uf.Assem = NS( + AskViewExplosion=lambda tag: association.get(tag, 0), + SetViewExplosion=Mock(side_effect=lambda view, ex: association.__setitem__(view, ex)), + UnexplodeComponent=Mock(side_effect=lambda ex, c: ex_by_tag(ex).deltas.pop(c, None)), + ExplodeComponent=Mock( + side_effect=lambda ex, c, transform: ex_by_tag(ex).deltas.__setitem__(c, transform) + ), + ) + r.exref = r.ref(r.ex, "explosion") + r.pref, r.lref, r.bref = [r.ref(c, "component") for c in (parent, leaf, base)] + r.association = association + original_mark, original_undo = r.session.SetUndoMark, r.session.UndoToMark + snapshots = {} + + def mark(*args): + value = original_mark(*args) + snapshots[value] = list(exs), [(e, copy.deepcopy(e.deltas)) for e in exs], dict(association) + return value + + def undo(value, *args): + original_undo(value, *args) + saved, poses, views = snapshots[value] + exs[:] = saved + for e, delta in poses: + e.deltas = delta + association.clear() + association.update(views) + + r.session.SetUndoMark = mark + r.session.UndoToMark = undo + return r + + +IDENTITY4 = [IDENTITY[i] + [0] for i in range(3)] + [[0, 0, 0, 1]] + + +def test_native_local_transform_converted_to_absolute_nested_pose(explosions): + r = explosions + placements = [ + { + "component": r.lref, + "translation": [70, 60, 50], + "rotation_matrix": [[-1, 0, 0], [0, -1, 0], [0, 0, 1]], + }, + {"component": r.pref, "translation": [50, 20, 30]}, + ] + result = r.e.execute( + "nx_edit_explosion", + {"explosion": r.exref, "placements": placements, "operation_id": "layout-01"}, + ) + assert result["assembled_placements_unchanged"] + assert result["affected_component_count"] == 2 + actual = {c["occurrence_path"][-1]: c for c in result["components"]} + assert actual["leaf"]["translation"] == [70, 60, 50] + assert actual["parent"]["translation"] == [50, 20, 30] + assert xyz(r.parent.position) == [10, 20, 30] + assert r.ex.deltas[r.parent.Tag][:3] == [[1, 0, 0, 0], [0, 1, 0, -40], [0, 0, 1, 0]] + calls = r.uf.Assem.ExplodeComponent.call_count + assert r.e.execute( + "nx_edit_explosion", + {"explosion": r.exref, "placements": placements, "operation_id": "layout-01"}, + )["replayed"] + assert r.uf.Assem.ExplodeComponent.call_count == calls + assert r.e._edit_explosion(r.exref, placements)["affected_component_count"] == 0 + reset = r.e._edit_explosion(r.exref, reset_components=[r.lref]) + assert reset["components"][0]["translation"] == [50, 40, 30] + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"placements": []}, + {"placements": True}, + {"reset_components": "bad"}, + {"placements": [{}]}, + {"placements": [{"component": "p", "translation": [1, 2, 3], "ignored": 1}]}, + {"placements": [{"component": "p", "translation": [1, 2]}]}, + {"placements": [{"component": "p", "translation": [1, 2, float("inf")]}]}, + { + "placements": [ + { + "component": "p", + "translation": [1, 2, 3], + "rotation_matrix": [[1, 0, 0], [0, 1, 0], [0, 0, -1]], + } + ] + }, + { + "placements": [ + {"component": "p", "translation": [1, 2, 3]}, + {"component": "p", "translation": [2, 3, 4]}, + ] + }, + {"placements": [{"component": "p", "translation": [1, 2, 3]}], "reset_components": ["p"]}, + {"reset_components": [False]}, + {"reset_components": ["missing"]}, + {"reset_components": ["p"] * 1001}, + ], +) +def test_preflight_rejects_all_invalid_items_before_native_mutation(explosions, payload): + r = explosions + + def substitute(v): + if isinstance(v, list): + return [substitute(x) for x in v] + if isinstance(v, dict): + return {k: substitute(x) for k, x in v.items()} + return r.pref if v == "p" else v + + with pytest.raises(NXToolError): + r.e._edit_explosion(r.exref, **substitute(payload)) + r.uf.Assem.UnexplodeComponent.assert_not_called() + r.uf.Assem.ExplodeComponent.assert_not_called() + + +def test_native_partial_failure_rolls_back_all_placements(explosions): + r = explosions + apply = r.uf.Assem.ExplodeComponent.side_effect + + def fail(ex, component, matrix): + if component == r.leaf.Tag: + raise RuntimeError("native failure") + apply(ex, component, matrix) + + r.uf.Assem.ExplodeComponent.side_effect = fail + with pytest.raises(NXToolError) as error: + r.e.execute( + "nx_edit_explosion", + { + "explosion": r.exref, + "placements": [ + {"component": r.pref, "translation": [50, 20, 30]}, + {"component": r.lref, "translation": [70, 60, 50]}, + ], + }, + ) + assert error.value.details["mutation_outcome"] == "rolled_back" + assert r.ex.deltas == {} + + +def test_native_readback_mismatch_is_not_reported_as_success(explosions): + r = explosions + r.uf.Assem.ExplodeComponent.side_effect = None + with pytest.raises(NXToolError, match="differs"): + r.e._edit_explosion(r.exref, [{"component": r.pref, "translation": [100, 20, 30]}]) + + +def test_changed_real_assembly_is_not_reported_as_safe(explosions): + r = explosions + apply = r.uf.Assem.ExplodeComponent.side_effect + + def invalid(ex, component, transform): + apply(ex, component, transform) + r.base.position = point(100, 0, 0) + + r.uf.Assem.ExplodeComponent.side_effect = invalid + with pytest.raises(NXToolError, match="assembled placements"): + r.e._edit_explosion(r.exref, [{"component": r.pref, "translation": [50, 20, 30]}]) + + +def test_listing_pagination_and_read_only_history(explosions): + r = explosions + result = r.e.execute("nx_explosion_info", {"explosion": r.exref, "limit": 1}) + assert result["total"] == 3 and result["next_offset"] == 1 + assert result["items"][0]["assembled_translation"] == [10, 20, 30] + assert r.e._history == [] + assert r.e._list_explosions()["explosions"][0]["name"] == "Service" + with pytest.raises(NXToolError): + r.e._explosion_info(r.exref, limit=201) + + +@pytest.mark.parametrize( + "name", ["", " ", " Service", "A/Other", "A\\Other", "A\nB", "a" * 133, "service"] +) +def test_invalid_or_duplicate_names_do_not_create(explosions, name): + r = explosions + with pytest.raises(NXToolError): + r.e._create_explosion(name) + r.part.ComponentAssembly.Explosions.Create.assert_not_called() + + +def test_create_and_delete_track_typed_reference_lifecycle(explosions): + r = explosions + result = r.e.execute("nx_create_explosion", {"name": "Maintenance"}) + ref = result["object"]["id"] + assert result["component_count"] == 3 + assert any(x["kind"] == "explosion" for x in result["changes"]["created"]) + deleted = r.e.execute("nx_delete_explosion", {"explosion": ref}) + assert deleted["deleted"][0]["id"] == ref + with pytest.raises(NXToolError): + r.e._explosion(ref) + + +def test_show_hide_and_guarded_deletion(explosions): + r = explosions + shown = r.e._show_explosion(r.exref) + assert shown["view_kind"] == "modeling" + with pytest.raises(NXToolError, match="Detach"): + r.e._delete_explosion(r.exref) + r.e._show_explosion() + r.e._delete_explosion(r.exref) + assert not r.part.ComponentAssembly.Explosions + + +def test_saved_model_and_drawing_view_targets(explosions): + r = explosions + drawing = Object("Assembly view") + drawing.OwningPart = r.part + r.part.DraftingViews.append(drawing) + ref = r.ref(drawing, "drawing_view") + r.e._show_explosion(r.exref, drawing_view=ref) + r.part.DraftingViews.UpdateViews.assert_called_with([drawing]) + r.e._edit_explosion(r.exref, [{"component": r.pref, "translation": [50, 20, 30]}]) + assert r.e._explosion_info(r.exref)["views"][0]["object"]["id"] == ref + saved = r.ref(r.part.ModelingViews.WorkView, "modeling_view") + r.e._show_explosion(r.exref, model_view=saved) + with pytest.raises(NXToolError): + r.e._show_explosion(r.exref, ref, saved) + r.uf.Assem.SetViewExplosion.side_effect = None + with pytest.raises(NXToolError, match="did not accept"): + r.e._show_explosion(None, model_view=saved) + + +@pytest.mark.parametrize("case", ["sketch", "display", "owner", "suppressed", "foreign"]) +def test_context_and_occurrence_ownership_guards(explosions, case): + r = explosions + if case == "sketch": + r.session.ActiveSketch = object() + elif case == "display": + r.session.Parts.Display = object() + elif case == "owner": + r.ex.OwningPart = object() + elif case == "suppressed": + r.parent.IsSuppressed = True + else: + r.lref = r.ref(Component("foreign"), "component") + with pytest.raises(NXToolError): + r.e._edit_explosion(r.exref, [{"component": r.lref, "translation": [0, 0, 0]}]) + r.uf.Assem.UnexplodeComponent.assert_not_called() + + +def test_empty_assembly_explosion_rejected(explosions): + r = explosions + r.part.ComponentAssembly.RootComponent.children.clear() + with pytest.raises(NXToolError, match="assembly part"): + r.e._create_explosion("Empty") + + +@pytest.fixture +def drawing_save(rig): + r = rig + sheet = Object("Sheet") + r.part.DrawingSheets = Collection([sheet]) + r.part.DrawingSheets.CurrentDrawingSheet = None + r.part.SaveOptions = NS(DrawingCgmData=True) + + def show(): + r.part.DrawingSheets.CurrentDrawingSheet = sheet + + def exit_drawing(): + r.part.DrawingSheets.CurrentDrawingSheet = None + + sheet.Open = Mock(side_effect=show) + r.part.Drafting = NS(ExitDraftingApplication=Mock(side_effect=exit_drawing)) + r.sheet = sheet + return r + + +def test_save_preserves_cgm_and_restores_modeling_without_dirtying(drawing_save): + r = drawing_save + original = r.part.Save + + def save(*args): + assert r.part.DrawingSheets.CurrentDrawingSheet is r.sheet + assert r.part.SaveOptions.DrawingCgmData is True + return original(*args) + + r.part.Save = save + r.e._save_part() + assert r.part.DrawingSheets.CurrentDrawingSheet is None + assert not r.part.IsModified + r.part.Drafting.ExitDraftingApplication.assert_called_once() + + +def test_save_restores_presentation_on_save_error(drawing_save): + r = drawing_save + r.part.Save = Mock(side_effect=RuntimeError("disk error")) + with pytest.raises(NXToolError) as error: + r.e._save_part() + assert error.value.details["native_save_errors"] == [{"message": "disk error"}] + assert r.part.DrawingSheets.CurrentDrawingSheet is None + + +def test_save_preserves_existing_active_drawing(drawing_save): + r = drawing_save + r.part.DrawingSheets.CurrentDrawingSheet = r.sheet + r.e._save_part() + assert r.part.DrawingSheets.CurrentDrawingSheet is r.sheet + r.part.Drafting.ExitDraftingApplication.assert_not_called() + + +def test_save_does_not_change_disabled_cgm_preference(drawing_save): + r = drawing_save + r.part.SaveOptions.DrawingCgmData = False + r.e._save_part() + r.sheet.Open.assert_not_called() + assert r.part.SaveOptions.DrawingCgmData is False + + +def test_save_restore_failure_is_explicitly_partial(drawing_save): + r = drawing_save + r.part.Drafting.ExitDraftingApplication.side_effect = RuntimeError("restore failure") + with pytest.raises(NXToolError) as error: + r.e.execute("nx_save_part", {}) + assert error.value.code == "NX_SAVE_VIEW_RESTORE_FAILED" + assert error.value.details["mutation_outcome"] == "partial" + + +def test_drawing_save_active_sketch_rejected_before_open(drawing_save): + r = drawing_save + r.session.ActiveSketch = object() + with pytest.raises(NXToolError): + r.e._save_part() + r.sheet.Open.assert_not_called() + + +def test_save_as_and_close_use_drawing_preview_context(drawing_save, tmp_path): + r = drawing_save + r.e._save_as(str(tmp_path / "copy.prt")) + assert r.part.DrawingSheets.CurrentDrawingSheet is None + r.e._close_part(save=True) + assert r.sheet.Open.call_count == 2 + assert r.part not in r.session.Parts + + +def test_save_hidden_part_restores_original_work_and_display(drawing_save, monkeypatch): + r = drawing_save + other = Object("Original") + other.FullPath = "original.prt" + r.session.Parts.Work = r.session.Parts.Display = other + + def activate(ref, work, display): + obj = r.e.objects.resolve(ref, expected_kind="part") + if work: + r.session.Parts.Work = obj + if display: + r.session.Parts.Display = obj + + monkeypatch.setattr(r.e, "_activate_part", activate) + with r.e._drawing_save_context(r.part): + assert r.session.Parts.Work is r.part and r.session.Parts.Display is r.part + assert r.session.Parts.Work is other and r.session.Parts.Display is other + + +def test_modified_component_drawing_saved_once_before_parent(drawing_save): + r = drawing_save + prototype = r.part + prototype.IsModified = True + parent = NS() + r.e._walk_components = lambda p: [ + (NS(Prototype=prototype), ["a"]), + (NS(Prototype=prototype), ["b"]), + ] + save = Mock(wraps=prototype.Save) + prototype.Save = save + r.e._save_component_drawing_previews(parent) + save.assert_called_once() + assert not prototype.IsModified + + +@pytest.fixture +def assembly_drawing(explosions): + r = explosions + sheet = Object("Sheet") + sheet.OwningPart = r.part + sheet.GetScale = lambda: (2.0, 1.0) + sheet.Open = Mock() + r.part.DrawingSheets.append(sheet) + r.sheetref = r.ref(sheet, "drawing_sheet") + r.builder = NS( + SelectModelView=NS(), + Placement=NS(Placement=NS(SetValue=Mock())), + Destroy=Mock(), + Scale=NS(Type=NS(Ratio=1)), + Style=NS(ViewStyleHiddenLines=NS(), ViewStyleVisibleLines=NS()), + ) + + def commit(): + view = Object("Base") + view.SetAttribute = Mock() + view.OwningPart = r.part + r.part.DraftingViews.append(view) + return view + + r.builder.Commit = Mock(side_effect=commit) + r.part.DraftingViews.CreateBaseViewBuilder = lambda _: r.builder + return r + + +def test_assembly_drawing_explosion_is_explicit_and_verified(assembly_drawing): + r = assembly_drawing + result = r.e._add_base_view(r.sheetref, scope="assembly", explosion=r.exref, position=[80, 100]) + view = r.e._resolve(result["object"]["id"], {"drawing_view"}) + assert r.association[view.Tag] == r.ex.Tag + r.part.DraftingViews.UpdateViews.assert_called_with([view]) + r.builder.Destroy.assert_called_once() + assert result["scope"] == "assembly" + assert result["explosion"]["id"] == r.exref + + +@pytest.mark.parametrize( + "kw", + [ + {}, + {"scope": "invalid"}, + {"scope": "assembly", "body": "body"}, + {"scope": "body", "body": "body", "explosion": "ex"}, + {"scope": "assembly", "view": "unknown"}, + {"scope": "assembly", "position": [1]}, + ], +) +def test_drawing_scope_validation_precedes_builder(assembly_drawing, kw): + r = assembly_drawing + with pytest.raises(NXToolError): + r.e._add_base_view(r.sheetref, **kw) + r.builder.Commit.assert_not_called() + + +def test_drawing_builder_destroyed_on_failure(assembly_drawing): + r = assembly_drawing + r.builder.Commit.side_effect = RuntimeError("native view failure") + with pytest.raises(RuntimeError): + r.e._add_base_view(r.sheetref, scope="assembly") + r.builder.Destroy.assert_called_once() + + +def test_projected_view_inherits_explosion(assembly_drawing, monkeypatch): + from nx_mcp.engineering import EngineeringMixin + + r = assembly_drawing + parent = r.e._add_base_view(r.sheetref, scope="assembly", explosion=r.exref)["object"]["id"] + child = Object("Projected") + child.OwningPart = r.part + r.part.DraftingViews.append(child) + child_ref = r.ref(child, "drawing_view") + monkeypatch.setattr( + EngineeringMixin, "_add_projection_view", lambda *args: {"object": {"id": child_ref}} + ) + result = r.e._add_projection_view(parent, "right") + assert result["exploded"] and r.association[child.Tag] == r.ex.Tag + r.part.DraftingViews.UpdateViews.assert_called_with([child]) + + +@pytest.mark.parametrize( + "extra", + [ + {"translation": [True, 0, 0]}, + {"translation": ["1", 0, 0]}, + {"rotation_matrix": None}, + {"rotation_matrix": [1, 2, 3]}, + {"rotation_matrix": [[True, 0, 0], [0, 1, 0], [0, 0, 1]]}, + ], +) +def test_strict_pose_types(explosions, extra): + r = explosions + ex = r.e._reference(r.ex, "explosion", r.part, "Explosion")["id"] + component = r.e._reference(r.base, "component", r.part, "Component")["id"] + with pytest.raises(NXToolError, match="numeric"): + r.e._edit_explosion(ex, [{"component": component, "translation": [1, 2, 3], **extra}]) + assert not r.ex.deltas + + +def test_forced_drawing_display_restores_sheet_on_failure(drawing_save): + r = drawing_save + r.part.SaveOptions.DrawingCgmData = False + original = r.sheet + r.part.DrawingSheets.CurrentDrawingSheet = original + with ( + pytest.raises(RuntimeError, match="plot failed"), + r.e._drawing_save_context(r.part, force_display=True), + ): + r.part.DrawingSheets.CurrentDrawingSheet = Object("Other sheet") + raise RuntimeError("plot failed") + assert r.part.DrawingSheets.CurrentDrawingSheet is original + + +def test_forced_drawing_display_restores_modeling_view(drawing_save): + r = drawing_save + r.part.SaveOptions.DrawingCgmData = False + with r.e._drawing_save_context(r.part, force_display=True): + assert r.part.DrawingSheets.CurrentDrawingSheet is r.sheet + assert r.part.DrawingSheets.CurrentDrawingSheet is None diff --git a/tests/test_freeform_manufacturing.py b/tests/test_freeform_manufacturing.py new file mode 100644 index 0000000..5b80293 --- /dev/null +++ b/tests/test_freeform_manufacturing.py @@ -0,0 +1,469 @@ +"""Boundary/cleanup regressions; native geometry evidence is kept separately.""" + +import inspect +import math +import sys +from types import SimpleNamespace as NS +from unittest.mock import MagicMock, Mock + +import pytest + +from nx_mcp import assembly_documentation_server, freeform_server, manufacturing_server +from nx_mcp.animation import interpolate_rotation +from nx_mcp.freeform import points3 +from nx_mcp.hardened import IDENTITY, READ_ONLY +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Face, Feature, Object, point + + +@pytest.fixture +def ff(rig, monkeypatch): + r = rig + r.body = Body() + r.body.IsOccurrence = False + r.body.OwningPart = r.part + r.part.Bodies.append(r.body) + r.feature = Feature(bodies=[r.body]) + r.feature.FeatureType = "STUDIO_SPLINE" + r.feature.OwningPart = r.part + r.feature.IsOccurrence = False + r.part.Features.append(r.feature) + r.body.faces.append(Face("other")) + r.faces = list(r.body.GetFaces()) + for obj in [*r.faces, *r.body.GetEdges()]: + obj.IsOccurrence = False + obj.OwningPart = r.part + obj.GetBody = lambda: r.body + r.nx.SmartObject = NS(UpdateOption=NS(WithinModeling=1)) + r.nx.Point3d = lambda *coords: point(*coords) + r.part.Points = NS(CreatePoint=Mock(return_value=Object())) + r.part.ScRuleFactory = MagicMock() + r.part.Sections = MagicMock() + r.nx.Section = NS(AllowTypes=NS(OnlyCurves=1), Mode=NS(Create=1)) + f = NS( + StudioSplineBuilderEx=NS(Types=NS(ThroughPoints=1, ByPoles=2)), + ThroughCurveMeshBuilder=NS(BodyPreferenceTypes=NS(Sheet=1)), + BridgeSurfaceBuilder=NS(EndObjectType=NS(Edge=1)), + SewBuilder=NS(Types=NS(Sheet=1), BodyPreferenceTypes=NS(Solid=2, Sheet=1)), + DeleteFaceBuilder=NS(SelectTypes=NS(Face=1)), + TrimSheetBuilder=NS(KeepDiscardOption=NS(Keep=1, Discard=2)), + ThreadBuilder=NS( + Input=NS(Manual=1, ThreadTable=2), + Type=NS(Detailed=2, Symbolic=1), + LimitOption=NS(Value=1), + Handedness=NS(LeftHand=1, RightHand=2), + ), + ) + g = NS( + Continuity=NS(ContinuityTypes=NS(G0=0, G1=1, G2=2)), ModlMotion=NS(Options=NS(Distance=1)) + ) + monkeypatch.setitem(sys.modules, "NXOpen.Features", f) + monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", g) + r.nx.Features = f + r.nx.GeometricUtilities = g + r.uf = MagicMock() + r.uf.Modeling.AskFaceData.return_value = [16, None, None, None, 1.0] + u = NS(UFSession=NS(GetUFSession=lambda: r.uf)) + monkeypatch.setitem(sys.modules, "NXOpen.UF", u) + r.nx.UF = u + r.b = MagicMock() + r.b.Validate.return_value = True + r.b.CommitFeature.return_value = r.feature + r.b.Curve = Object() + r.b.Curve.Order = 4 + r.b.Curve.Periodic = False + r.b.GetUnsewnBodies.return_value = [] + r.e._freeform_builder = Mock(return_value=r.b) + r.e._engineering_direction = Mock(return_value=Object()) + r.e._update_model = Mock() + r.e._model_health = Mock(return_value={"healthy": True, "issues": []}) + r.ref = lambda o, kind: r.e._reference(o, kind, r.part, kind)["id"] + return r + + +def test_new_contracts_match_native_handlers(rig): + for module in [freeform_server, manufacturing_server, assembly_documentation_server]: + for name, function in vars(module).items(): + if name.startswith("nx_") and inspect.isfunction(function): + assert name in rig.e._handlers + assert ( + inspect.signature(function).parameters.keys() + == inspect.signature(rig.e._handlers[name]).parameters.keys() + ) + assert module.READ_ONLY <= READ_ONLY + + +@pytest.mark.parametrize("value", [[], [[0, 1]], [[0, 0, math.inf]], [[True, 0, 0]], "points"]) +def test_invalid_point_sets(value): + with pytest.raises(NXToolError): + points3(value) + + +@pytest.mark.parametrize("axis", range(3)) +def test_rotation_interpolation_stays_orthonormal_at_half_turn(axis): + end = [[float(i == j) * (1 if i == axis else -1) for j in range(3)] for i in range(3)] + for fraction in [0, 0.25, 0.5, 0.75, 1]: + matrix = interpolate_rotation(IDENTITY, end, fraction) + for i in range(3): + for j in range(3): + assert sum(matrix[i][k] * matrix[j][k] for k in range(3)) == pytest.approx( + float(i == j) + ) + assert interpolate_rotation(IDENTITY, IDENTITY, 0.5) == IDENTITY + for row, expected in zip(interpolate_rotation(IDENTITY, end, 1), end, strict=True): + assert row == pytest.approx(expected) + + +@pytest.mark.parametrize("method", ["through_points", "poles"]) +def test_spline_edit_replaces_constraints_and_returns_curve(ff, method): + result = ff.e._spline( + [[0, 0, 0], [1, 0, 1], [2, 1, 2], [3, 0, 4]], + method=method, + feature=ff.ref(ff.feature, "feature"), + ) + assert result["curve"]["kind"] == "curve" + ff.b.ConstraintManager.Clear.assert_called_once() + assert ff.b.ConstraintManager.Append.call_count == 4 + assert ff.b.HasPlaneConstraint is False + ff.b.Destroy.assert_called_once() + + +@pytest.mark.parametrize( + "kwargs", [{"degree": True}, {"degree": 8}, {"degree": 4}, {"method": "unknown"}] +) +def test_spline_rejects_invalid_parameters_before_builder(ff, kwargs): + with pytest.raises(NXToolError): + ff.e._spline([[0, 0, 0], [1, 0, 1], [2, 1, 2], [3, 0, 4]], **kwargs) + ff.e._freeform_builder.assert_not_called() + + +def test_builder_rejection_always_destroys_and_does_not_commit(ff): + ff.b.Validate.return_value = False + with pytest.raises(NXToolError): + ff.e._thicken([ff.ref(ff.faces[0], "face")], 2) + ff.b.CommitFeature.assert_not_called() + ff.b.Destroy.assert_called_once() + + +@pytest.mark.parametrize( + "action,kwargs", + [ + ("move", {"distance": 2, "direction": [0, 0, 4]}), + ("offset", {"distance": -2}), + ("replace", {}), + ("heal", {}), + ], +) +def test_native_face_edit_configuration_and_cleanup(ff, action, kwargs): + if action == "replace": + kwargs["replacement"] = ff.ref(ff.faces[1], "face") + result = ff.e._edit_faces([ff.ref(ff.faces[0], "face")], action, **kwargs) + assert result["body_count"] == 1 + ff.b.Destroy.assert_called_once() + if action == "move": + assert ff.b.Motion.DistanceValue.RightHandSide == "2.0" + elif action == "offset": + assert ff.b.Distance.RightHandSide == "2.0" and ff.b.Direction is True + elif action == "heal": + assert ff.b.Heal is True and ff.b.AllowPartialDelete is False + + +@pytest.mark.parametrize( + "action,kwargs", + [ + ("move", {}), + ("heal", {"distance": 2}), + ("replace", {"direction": [0, 0, 1]}), + ("unsupported", {}), + ], +) +def test_ignored_face_edit_arguments_are_rejected(ff, action, kwargs): + with pytest.raises(NXToolError): + ff.e._edit_faces([ff.ref(ff.faces[0], "face")], action, **kwargs) + ff.e._freeform_builder.assert_not_called() + + +def test_thicken_uses_explicit_native_tolerance(ff): + ff.e._thicken([ff.ref(ff.faces[0], "face")], 2, -1) + assert ff.b.Tolerance == 0.001 + assert ff.b.FirstOffset.RightHandSide == "2.0" + assert ff.b.SecondOffset.RightHandSide == "-1.0" + with pytest.raises(NXToolError): + ff.e._thicken([ff.ref(ff.faces[0], "face")], 2, 2) + + +@pytest.mark.parametrize("detailed,left", [(False, False), (True, True)]) +def test_thread_uses_actual_cylinder_size_and_explicit_start(ff, detailed, left): + result = ff.e._thread( + ff.ref(ff.faces[0], "face"), + ff.ref(ff.faces[1], "face"), + 0.4, + 2.4, + 1.9, + 4, + detailed=detailed, + left_hand=left, + ) + assert result["representation"] == ("detailed" if detailed else "symbolic") + assert ff.b.TapDrillDiameterExp.RightHandSide == "2.0" + assert ff.b.CylindricalFace.Value is ff.faces[0] + assert ff.b.StartObject.Value is ff.faces[1] + ff.b.Destroy.assert_called_once() + + +def test_sampled_thickness_retains_unresolved_samples(ff): + source = ff.faces[0] + data = ([0.0, 0.0, 5.0], None, None, None, None, [0.0, 0.0, 1.0], [1e30, 1e30]) + ff.e._face_samples = Mock(return_value=([(source, [0, 0], data)] * 2, 1)) + ff.uf.Modeling.AskPointContainment.side_effect = [1, 2] + ff.uf.Modeling.TraceARay.return_value = (1, [NS(HitPoint=[0.0, 0.0, 0.0], HitFace=source.Tag)]) + result = ff.e._wall_thickness(ff.ref(ff.body, "body")) + assert result["minimum_sampled_thickness"] == 5 + assert result["measured_count"] == 1 + assert result["sample_count"] == 2 + assert result["samples"][1]["status"] == "unresolved" + assert result["skipped_outside_trim"] == 1 + + +def test_face_sampling_reports_signed_draft_and_ignores_trimmed_outside(ff): + ff.uf.Modeling.AskFaceUvMinmax.return_value = [0.0, 1.0, 0.0, 1.0] + ff.uf.Modeling.AskFaceProps.return_value = ( + [0.0, 0.0, 0.0], + None, + None, + None, + None, + [0.0, 0.0, -1.0], + [1e30, 2.0], + ) + ff.uf.Modeling.AskPointContainment.side_effect = [1, 2, 1, 1] + result = ff.e._face_analysis( + [ff.ref(ff.faces[0], "face")], samples_per_axis=2, pull_direction=[0, 0, 1] + ) + assert result["sample_count"] == 3 and result["skipped_outside_trim"] == 1 + assert result["samples"][0]["signed_draft_degrees"] == -90 + assert result["samples"][0]["principal_curvatures"] == [0, 0.5] + + +def test_shape_operator_is_parameterization_invariant(): + from nx_mcp.surface_math import continuity_difference, shape_operator + + plane = shape_operator([1, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]) + reversed_plane = shape_operator([0, 3, 0], [2, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]) + assert continuity_difference(plane, reversed_plane) == (0, 0) + cylinder = shape_operator([0, 2, 0], [0, 0, 1], [-2, 0, 0], [0, 0, 0], [0, 0, 0]) + scaled = shape_operator([0, 4, 0], [0, 0, 3], [-8, 0, 0], [0, 0, 0], [0, 0, 0]) + assert continuity_difference(cylinder, scaled) == pytest.approx((0, 0)) + assert abs(cylinder[1][1][1]) == 0.5 + with pytest.raises(NXToolError): + shape_operator([0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]) + + +def test_sew_rejects_partial_or_sheet_fallback(ff): + other = Body("other") + other.OwningPart = ff.part + ff.part.Bodies.append(other) + target, tool = ff.ref(ff.body, "body"), ff.ref(other, "body") + assert ff.e._sew(target, [tool])["body_count"] == 1 + ff.b.GetUnsewnBodies.return_value = [other] + with pytest.raises(NXToolError, match="could not be sewn"): + ff.e._sew(target, [tool]) + ff.b.GetUnsewnBodies.return_value = [] + ff.body.IsSolidBody = False + with pytest.raises(NXToolError, match="instead of"): + ff.e._sew(target, [tool], solid=True) + with pytest.raises(NXToolError, match="also"): + ff.e._sew(target, [target]) + + +def test_surface_sections_and_trim_use_native_section_objects(ff): + curve = Object("curve") + curve.IsOccurrence = False + curve.OwningPart = ff.part + ref = ff.ref(curve, "curve") + ff.e._surface_mesh([[ref], [ref]], [[ref], [ref]]) + assert ff.b.PrimaryCurvesList.Append.call_count == 2 + assert ff.b.CrossCurvesList.Append.call_count == 2 + with pytest.raises(NXToolError): + ff.e._surface_mesh([[ref]], [[ref], [ref]]) + ff.body.IsSolidBody = False + ff.part.CreateRegionPoint = Mock(return_value=Object()) + ff.e._trim_sheet(ff.ref(ff.body, "body"), [ref], [0, 0, 0], keep=False) + ff.b.BoundaryObjects.Add.assert_called_with(ff.part.Sections.CreateSection.return_value) + ff.b.Regions.Append.assert_called_once() + + +def test_bridge_explicit_continuity_and_reverse(ff): + edge = ff.body.GetEdges()[0] + result = ff.e._bridge_surface( + ff.ref(edge, "edge"), ff.ref(edge, "edge"), continuity="G2", reverse_second=True + ) + assert result["body_count"] == 1 + assert ff.b.FirstEdgeContinuity.ContinuityType == 2 + assert ff.b.IsSecondEdgeReversed is True + with pytest.raises(NXToolError): + ff.e._bridge_surface("x", "y", continuity="C9") + + +def test_curve_analysis_keeps_singular_samples(ff): + edge = ff.body.GetEdges()[0] + ff.nx.Edge = type(edge) + ff.uf.ModlGeneral.EvaluateCurve.side_effect = [ + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0, 0.0], + ] + result = ff.e._curve_analysis(ff.ref(edge, "curve"), samples=2) + assert result["samples"][0]["singular"] + assert result["samples"][1]["curvature"] == 2 + assert result["samples"][1]["radius"] == 0.5 + with pytest.raises(NXToolError): + ff.e._curve_analysis("x", samples=1) + + +def test_pmi_association_and_existing_edit(ff, monkeypatch): + datum = Object("datum") + datum.OwningPart = ff.part + datum.IsOccurrence = False + fcf = Object("fcf") + fcf.OwningPart = ff.part + fcf.IsOccurrence = False + ff.part.Annotations = MagicMock() + db, fb = MagicMock(), MagicMock() + db.Letter = "A" + db.Commit.return_value = datum + fb.Commit.return_value = fcf + ff.part.Annotations.Datums.CreatePmiDatumFeatureSymbolBuilder.return_value = db + ff.part.Annotations.CreatePmiFeatureControlFrameBuilder.return_value = fb + a = NS( + FeatureControlFrameBuilder=NS( + FcfCharacteristic=NS(Parallelism=1, Flatness=2, Position=3), + FcfFrameStyle=NS(SingleFrame=1), + ), + FeatureControlFrameDataBuilder=NS( + ToleranceMaterialModifier=NS( + NotSet=0, + MaximumMaterialCondition=1, + LeastMaterialCondition=2, + RegardlessOfFeatureSize=3, + ), + ToleranceZoneShape=NS(NotSet=0, Diameter=1, SphericalDiameter=2, Square=3), + ), + DatumReferenceBuilder=NS( + DatumReferenceMaterialCondition=NS( + NotSet=0, + MaximumMaterialCondition=1, + LeastMaterialCondition=2, + RegardlessOfFeatureSize=3, + ) + ), + ) + monkeypatch.setitem(sys.modules, "NXOpen.Annotations", a) + ff.nx.Annotations = a + face = ff.ref(ff.faces[0], "face") + result = ff.e._pmi_datum([face], "A", [10, 10, 0]) + assert result["geometry_associated"] + db.AssociatedObjects.Nxobjects.Add.assert_called_with([ff.faces[0]]) + fc = ff.e._pmi_fcf([face], "Parallelism", 0.1, [10, 10, 0], datums=[result["annotation"]["id"]]) + assert fc["datum_letters"] == ["A"] + assert fb.FeatureControlFrameDataList.FindItem.return_value.ToleranceValue == "0.1" + updated = ff.e._pmi_fcf([face], "Flatness", 0.2, [10, 10, 0], annotation=fc["annotation"]["id"]) + assert updated["created"] == [] and len(updated["modified"]) == 1 + with pytest.raises(NXToolError): + ff.e._pmi_fcf([face], "Flatness", 0.1, [0, 0, 0], datums=[result["annotation"]["id"]]) + with pytest.raises(NXToolError): + ff.e._pmi_datum([face], "a", [0, 0, 0]) + db.Validate.return_value = False + with pytest.raises(NXToolError): + ff.e._pmi_datum([face], "B", [0, 0, 0]) + + +def test_animation_renders_absolute_poses_and_restores_after_failure(ff, tmp_path): + from pathlib import Path + + ff.e._explosion_context = lambda: ff.part + ff.e._explosion = lambda _: NS(Tag=99) + ff.e._exploded_tree = lambda _: {1: (None, None, None, False)} + ff.e._exploded_record = lambda _: { + "component": {"id": "component"}, + "assembled_translation": [0, 0, 0], + "translation": [20, 10, 0], + "assembled_rotation_matrix": IDENTITY, + "rotation_matrix": IDENTITY, + } + ff.e._explosion_uf = lambda: ff.uf.Assem + ff.uf.Assem.AskViewExplosion.return_value = 0 + ff.session.IsBatch = False + ff.part.DrawingSheets = NS(CurrentDrawingSheet=None) + ff.part.ModelingViews = NS(WorkView=NS(Tag=2, UpdateDisplay=Mock())) + ff.session.UndoToMark = Mock() + ff.session.DeleteUndoMark = Mock() + ff.e._edit_explosion = Mock() + + def render(path, **_): + Path(path).write_bytes(b"native png fixture") + return {"camera": {"scale": 1}} + + ff.e._render_view = Mock(side_effect=render) + destination = ff.e.workspace.root / "animation.html" + result = ff.e._export_explosion_animation("ex", str(destination), frames=3) + assert result["model_restored"] and result["frame_count"] == 3 + assert "data:image/png;base64," in destination.read_text() + placements = ff.e._edit_explosion.call_args_list + assert placements[1].kwargs["placements"][0]["translation"] == [10, 5, 0] + ff.session.UndoToMark.assert_called_once() + ff.e._render_view.side_effect = RuntimeError("capture failed") + failure_path = ff.e.workspace.root / "failed.html" + with pytest.raises(RuntimeError, match="capture failed"): + ff.e._export_explosion_animation("ex", str(failure_path), frames=2) + assert not failure_path.exists() + assert not list(ff.e.workspace.root.glob(".nx-animation-*")) + assert ff.session.UndoToMark.call_count == 2 + + +def test_trace_anchor_maps_assembled_to_exploded_coordinates(ff): + source = NS(Coordinates=point(10, 25, 0)) + ff.part.Points.CreatePoint.return_value = source + ff.part.Scalars = NS(CreateScalar=Mock(return_value=Object())) + ff.nx.Scalar = NS(DimensionalityType=NS(NotSet=0)) + ff.nx.PointCollection = NS(PointOnCurveLocationOption=NS(PercentArcLength=1)) + ff.session.UpdateManager.DoUpdate = Mock(return_value=0) + component = NS( + Tag=123, + JournalIdentifier="component", + GetPosition=lambda: (point(10, 20, 0), ff.e._nx_matrix([[0, -1, 0], [1, 0, 0], [0, 0, 1]])), + ) + edge = ff.body.GetEdges()[0] + edge.JournalIdentifier = "edge" + ff.uf.Tag.AskTagOfHandle.side_effect = lambda name: { + "component": 123, + "edge": int(edge.Tag), + }.get(name, 0) + component.Prototype = NS(Bodies=[ff.body]) + component.FindOccurrence = lambda _: edge + exploded = NS(GetPosition=lambda: (point(100, 0, 0), ff.e._nx_matrix(IDENTITY))) + ff.e._exploded_tree = lambda _: {1: (exploded, component, ["component"], False)} + assert ff.e._trace_position( + "ex", {"edge": "edge", "percent": 50, "component": "component"} + ) == [105, 0, 0] + with pytest.raises(NXToolError, match="missing"): + ff.e._trace_position("ex", {"edge": "edge", "percent": 50, "component": "missing"}) + + +def test_managed_trace_refresh_handles_collapsed_and_expanded_states(ff): + import json + + line = MagicMock() + line.AskExplosion.return_value = "ex" + line.HasUserAttribute.return_value = True + line.GetStringAttribute.return_value = json.dumps([{"point": "first"}, {"point": "second"}]) + ff.part.Tracelines = [line] + ff.nx.NXObject = NS(AttributeType=NS(String=1)) + ff.session.UpdateManager.DoUpdate = Mock(return_value=0) + ff.e._trace_position = Mock(side_effect=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + ff.e._refresh_explosion_traces("ex") + line.Blank.assert_called_once() + line.StartPoint.SetCoordinates.assert_not_called() + ff.e._trace_position.side_effect = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]] + ff.e._refresh_explosion_traces("ex") + line.Unblank.assert_called_once() + assert line.EndPoint.SetCoordinates.call_args.args[0].X == 10 diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..5d8ad9a --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,215 @@ +"""Safety and MCP contract regressions; these do not substitute for real NX tests.""" + +import asyncio +import base64 +import hashlib +from threading import Event +from types import SimpleNamespace + +import pytest + +from nx_mcp.bridge import BridgeClient, BridgeServer +from nx_mcp.hardened import HardenedExecutor +from nx_mcp.integration_server import artifact_call +from nx_mcp.runtime import NXToolError +from nx_mcp.server import create_server +from nx_mcp.workspace import Workspace, WorkspaceViolation + + +class FakeSession: + def __init__(self): + self.Parts = SimpleNamespace(Work=None) + self.values = [] + self.marks = {} + self.next_mark = 0 + + def SetUndoMark(self, *args): + self.next_mark += 1 + self.marks[self.next_mark] = list(self.values) + return self.next_mark + + def UndoToMark(self, mark, *args): + self.values[:] = self.marks[mark] + + def DeleteUndoMark(self, mark, *args): + self.marks.pop(mark, None) + + def DoesUndoMarkExist(self, mark, *args): + return mark in self.marks + + +@pytest.fixture +def executor(tmp_path): + session = FakeSession() + e = HardenedExecutor( + session, + SimpleNamespace(Session=SimpleNamespace(MarkVisibility=SimpleNamespace(Visible=1))), + "test", + Workspace(tmp_path), + enable_experimental=True, + ) + + def mutate(value=1, fail=False): + session.values.append(value) + if fail: + raise NXToolError("TEST_FAIL", "failure after a model change") + return {"values": list(session.values)} + + e._handlers["nx_test_mutate"] = mutate + return e + + +def test_retry_committed_mutation_is_deduplicated(executor): + p = {"value": 1, "operation_id": "same-request-123"} + a = executor.execute("nx_test_mutate", p) + b = executor.execute("nx_test_mutate", p) + assert executor.session.values == [1] + assert b["replayed"] and a["operation_id"] == b["operation_id"] + with pytest.raises(NXToolError, match="different arguments"): + executor.execute("nx_test_mutate", dict(p, value=2)) + + +def test_failed_partial_mutation_rolls_back_and_cannot_reapply(executor): + p = {"value": 4, "fail": True, "operation_id": "failed-request-123"} + with pytest.raises(NXToolError) as error: + executor.execute("nx_test_mutate", p) + assert error.value.details["mutation_outcome"] == "rolled_back" + assert executor.session.values == [] + assert executor.store.get(p["operation_id"])["state"] == "failed" + with pytest.raises(NXToolError): + executor.execute("nx_test_mutate", p) + assert executor.session.values == [] + + +def test_restarted_pending_receipt_becomes_unknown(executor): + store = executor.store + store.put( + { + "operation_id": "crashed-request-123", + "session_id": "old", + "state": "running", + "fingerprint": "a", + } + ) + store.recover(executor.session_id) + assert store.get("crashed-request-123")["state"] == "unknown" + assert store.get("never-seen-request")["mutation_outcome"] == "unknown" + + +@pytest.mark.asyncio +async def test_transport_timeout_does_not_duplicate_mutation(executor): + handler = executor._handlers["nx_test_mutate"] + entered, release = Event(), Event() + + def delayed(**params): + entered.set() + assert release.wait(15), "Test did not release the received mutation" + return handler(**params) + + executor._handlers["nx_test_mutate"] = delayed + server = BridgeServer(executor.execute, token="test-token") + server.start() + p = {"value": 9, "operation_id": "lost-response-123"} + first = asyncio.create_task( + BridgeClient("127.0.0.1", server.port, token="test-token", timeout=10).call( + "nx_test_mutate", p + ) + ) + try: + # Start the response deadline only after the request reached the server. + # A 20ms connect deadline can expire before receipt on Windows CI. + assert await asyncio.to_thread(entered.wait, 5), "Server did not receive first request" + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(first, timeout=0.02) + release.set() + result = await BridgeClient("127.0.0.1", server.port, token="test-token", timeout=10).call( + "nx_test_mutate", p + ) + assert result["replayed"] and executor.session.values == [9] + finally: + release.set() + first.cancel() + await asyncio.gather(first, return_exceptions=True) + server.stop() + + +@pytest.mark.parametrize( + "path", ["../outside.step", "/tmp/outside.step", ".nx-mcp/operations/x.json"] +) +def test_artifacts_reject_outside_or_reserved_paths(tmp_path, path): + with pytest.raises((WorkspaceViolation, NXToolError)): + artifact_call( + "nx_download_file", {"path": path, "offset": 0, "length": 1}, Workspace(tmp_path) + ) + + +def test_chunk_upload_retry_checksum_download_and_no_overwrite(tmp_path): + w = Workspace(tmp_path) + data = b"0123456789" * 100 + digest = hashlib.sha256(data).hexdigest() + + def upload(offset, chunk, sha=digest): + return artifact_call( + "nx_upload_file", + { + "path": "vendor/test.step", + "offset": offset, + "total_size": len(data), + "sha256": sha, + "data_base64": base64.b64encode(chunk).decode(), + }, + w, + ) + + assert not upload(0, data[:400])["committed"] + assert not upload(0, data[:400])["committed"] + assert upload(400, data[400:])["committed"] + assert upload(400, data[400:])["replayed"] + result = artifact_call( + "nx_download_file", {"path": "vendor/test.step", "offset": 0, "length": 262144}, w + ) + assert base64.b64decode(result["data_base64"]) == data + assert result["sha256"] == digest and result["eof"] + with pytest.raises(NXToolError): + upload(0, b"x" * 1000, "0" * 64) + assert (tmp_path / "vendor/test.step").read_bytes() == data + + +@pytest.mark.asyncio +async def test_uniform_structured_success_failure_and_schema(tmp_path): + class Caller: + async def call(self, method, params): + if method == "nx_edit_feature": + raise NXToolError("NX_UNSUPPORTED_EDIT", "unsupported edit") + return {"status": "success", "method": method, "params": params} + + server = create_server(Caller(), Workspace(tmp_path), enable_experimental=True) + tools = await server.list_tools() + pattern = next(t for t in tools if t.name == "nx_pattern") + assert pattern.inputSchema["properties"]["pattern_type"]["const"] == "linear" + assert "operation_id" in pattern.inputSchema["properties"] + success = await server.call_tool("nx_status", {}) + assert success.structuredContent["status"] == "success" and not success.isError + for name, args in [ + ("nx_edit_feature", {"name": "f", "params": {"x": 1}}), + ("nx_pattern", {"features": [], "ignored": 123}), + ]: + result = await server.call_tool(name, args) + assert result.isError and result.structuredContent["status"] == "error" + assert not next(t for t in tools if t.name == "nx_sketch_rectangle").annotations.readOnlyHint + + +def test_reference_namespace_rejects_previous_session(executor, tmp_path): + obj = SimpleNamespace(Tag=123, Name="Body") + first = executor.objects.register(obj, kind="body", name="Body", part_id="part_test") + assert first.id.startswith("obj_" + executor.session_id + "_") + another = HardenedExecutor( + FakeSession(), + SimpleNamespace(Session=SimpleNamespace(MarkVisibility=SimpleNamespace(Visible=1))), + "test", + Workspace(tmp_path / "other"), + enable_experimental=True, + ) + with pytest.raises(NXToolError) as error: + another.objects.resolve(first.id) + assert error.value.code == "NX_OBJECT_STALE" diff --git a/tests/test_interactive.py b/tests/test_interactive.py new file mode 100644 index 0000000..294aabf --- /dev/null +++ b/tests/test_interactive.py @@ -0,0 +1,191 @@ +import threading + +import pytest + +from nx_mcp.bridge import MainThreadDispatcher +from nx_mcp.inspection import envelope_gap +from nx_mcp.runtime import NXToolError + + +def test_expired_queued_mutation_never_executes(): + calls = [] + dispatcher = MainThreadDispatcher(lambda m, p: calls.append(m) or {}, timeout=0.01) + with pytest.raises(NXToolError) as error: + dispatcher.call("nx_reposition_component", {}) + assert error.value.details["mutation_outcome"] == "not_started" + dispatcher.drain() + assert calls == [] + + +def test_timeout_of_running_mutation_reports_unknown_without_duplicate(): + started = threading.Event() + release = threading.Event() + errors = [] + calls = [] + + def execute(m, p): + calls.append(m) + started.set() + release.wait(2) + return {"done": True} + + dispatcher = MainThreadDispatcher(execute, timeout=0.05) + + def caller(): + try: + dispatcher.call("mutation", {}) + except NXToolError as e: + errors.append(e) + + client = threading.Thread(target=caller) + client.start() + worker = threading.Thread(target=lambda: dispatcher.drain(timeout=1)) + worker.start() + assert started.wait(1) + client.join(1) + assert errors and errors[0].details["mutation_outcome"] == "unknown" + release.set() + worker.join(1) + assert calls == ["mutation"] + + +def test_conservative_bounds_gap(): + assert envelope_gap([0, 0, 0, 10, 10, 10], [12, 0, 0, 22, 10, 10]) == 2 + assert envelope_gap([0, 0, 0, 10, 10, 10], [5, 0, 0, 15, 10, 10]) == 0 + assert envelope_gap([0, 0, 0, 10, 10, 10], [13, 14, 10, 23, 24, 20]) == 5 + + +@pytest.mark.asyncio +async def test_mcp_points_cross_bridge_as_json_objects(tmp_path): + from nx_mcp.server import create_server + from nx_mcp.workspace import Workspace + + class Bridge: + async def call(self, method, params): + assert params["corner1"] == {"x": 0.0, "y": 0.0} + assert params["corner2"] == {"x": 10.0, "y": 10.0} + import json + + json.dumps(params) + return {"status": "success"} + + server = create_server(Bridge(), Workspace(tmp_path), enable_experimental=True) + result = await server.call_tool( + "nx_sketch_rectangle", + {"sketch_id": "sketch-test", "corner1": {"x": 0, "y": 0}, "corner2": {"x": 10, "y": 10}}, + ) + assert not result.isError, result + + +@pytest.mark.asyncio +async def test_capture_returns_inline_image_and_rejects_changed_artifact(tmp_path): + import hashlib + + from nx_mcp.server import create_server + from nx_mcp.workspace import Workspace + + data = b"\x89PNG\r\n\x1a\n" + b"fixture" + file = tmp_path / "view.png" + file.write_bytes(data) + + class Bridge: + async def call(self, method, params): + return { + "path": str(file), + "sha256": hashlib.sha256(data).hexdigest(), + "model_preview": True, + } + + server = create_server(Bridge(), Workspace(tmp_path), enable_experimental=True) + result = await server.call_tool("nx_screenshot", {}) + assert not result.isError and result.structuredContent["model_preview"] + assert len([item for item in result.content if item.type == "image"]) == 1 + file.write_bytes(b"changed") + result = await server.call_tool("nx_screenshot", {}) + assert result.isError and result.structuredContent["code"] == "NX_ARTIFACT_CHANGED" + + +def test_agent_ui_lock_is_not_nested_and_manual_handoff_releases_input(): + from types import SimpleNamespace + + from nx_mcp.interactive import InteractiveHost + + class UI: + count = 1 + + def AskLockStatus(self): + return int(self.count > 0) + + def LockAccess(self): + self.count += 1 + + def UnlockAccess(self): + self.count = max(0, self.count - 1) + + def CanOpenPart(self): + return self.count == 0 + + class Parts: + Display = None + + def __iter__(self): + return iter(()) + + host = InteractiveHost.__new__(InteractiveHost) + host.thread = threading.get_ident() + host.mode = "agent" + host.owns_lock = True + host.window_disabled = False + host.ui = UI() + host.nx = SimpleNamespace(UI=SimpleNamespace(Status=SimpleNamespace(Lock=1))) + host.main_hwnd = 1 + enabled = [] + host.user = SimpleNamespace(EnableWindow=lambda h, v: enabled.append(v)) + host.session = SimpleNamespace(Parts=Parts()) + host.completed = 0 + host.status = lambda: {} + host.publish = lambda: None + + def execute(method, params): + if method == "nx_create_part": + assert host.ui.count == 0 # FileNew must run without an NX UI lock. + return {"status": "success"} + + host.executor = SimpleNamespace(execute=execute, _history=[1], _checkpoints={"cp": 1}) + host.execute("nx_list_bodies", {}) + assert host.ui.count == 1 # Do not increment an already-held lock. + host.execute("nx_create_part", {}) + assert host.ui.count == 1 # Reacquire the lock released by FileNew exactly once. + host.control("manual") + assert host.ui.count == 0 and not enabled + assert not host.executor._history and not host.executor._checkpoints + + +@pytest.mark.asyncio +async def test_interactive_transport_services_health_during_blocked_request(): + import asyncio + + from nx_mcp.bridge import BridgeClient, BridgeServer + + entered, release = threading.Event(), threading.Event() + + def dispatch(method, params): + if method == "work": + entered.set() + release.wait(5) + return {"done": True} + return {"activity": "running", "snapshot_only": True} + + server = BridgeServer(dispatch, token="test", concurrent_requests=True) + server.start() + try: + client = BridgeClient("127.0.0.1", server.port, token="test", timeout=2) + running = asyncio.create_task(client.call("work", {})) + assert await asyncio.to_thread(entered.wait, 1) + status = await asyncio.wait_for(client.call("nx_ui_control", {}), 1) + assert status["snapshot_only"] and not running.done() + release.set() + assert (await running)["done"] + finally: + release.set() + server.stop() diff --git a/tests/test_inventory_reference_geometry.py b/tests/test_inventory_reference_geometry.py new file mode 100644 index 0000000..8194906 --- /dev/null +++ b/tests/test_inventory_reference_geometry.py @@ -0,0 +1,61 @@ +"""Selection and mutation preflight regressions; native geometry is tested separately.""" + +from unittest.mock import Mock + +import pytest + +from nx_mcp.inventory import compact_reference, page +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Object + + +@pytest.mark.parametrize("offset,limit", [(-1, None), (True, 2), (0, 0), (0, 1001), (0, True)]) +def test_invalid_paging(offset, limit): + with pytest.raises(NXToolError): + page([], offset, limit) + + +def test_compact_identity_keeps_occurrence_context_and_page_totals(): + reference = { + "id": "id", + "kind": "component", + "name": "bolt", + "part_id": "owner", + "occurrence_path": ["A", "bolt"], + "session_id": "session", + } + assert compact_reference(reference) == {k: v for k, v in reference.items() if k != "session_id"} + assert page([1, 2, 3], 1, 1) == ( + [2], + {"total_count": 3, "count": 1, "offset": 1, "next_offset": 2}, + ) + + +def test_topology_face_filter_rejects_foreign_faces_and_exposes_both_directions(rig): + body = Body() + rig.part.Bodies.append(body) + face, edge = body.faces[0], body.edges[0] + face.GetEdges = lambda: [edge] + edge.GetFaces = lambda: [face] + bid, fid = rig.ref(body), rig.ref(face, "face") + result = rig.e._list_topology(bid, face=fid, include_adjacency=True, compact=True) + assert result["face_edges"][0]["face"] == fid + assert result["edge_faces"][0]["faces"] == [fid] + assert result["edge_count"] == 1 and "session_id" not in result["faces"][0] + with pytest.raises(NXToolError, match="belong"): + rig.e._list_topology(bid, face=rig.ref(Body().faces[0], "face")) + + +def test_duplicate_reference_set_is_rejected_before_creation(rig): + existing = Object("SOLIDS") + rig.part.GetAllReferenceSets = lambda: [existing] + rig.part.CreateReferenceSet = Mock() + with pytest.raises(NXToolError, match="already exists"): + rig.e._create_reference_set("solids", ["unresolved"]) + rig.part.CreateReferenceSet.assert_not_called() + + +@pytest.mark.parametrize("name", ["", "Entire Part", "Empty", "x" * 133]) +def test_invalid_reference_set_name_never_resolves_members(rig, name): + with pytest.raises(NXToolError): + rig.e._create_reference_set(name, ["unresolved"]) diff --git a/tests/test_legacy_repairs.py b/tests/test_legacy_repairs.py new file mode 100644 index 0000000..f70081f --- /dev/null +++ b/tests/test_legacy_repairs.py @@ -0,0 +1,110 @@ +"""Legacy entry points resolve typed references and reject unsupported geometry.""" + +from types import SimpleNamespace as S + +import pytest + +from nx_mcp.legacy_repairs import LegacyRepairsMixin +from nx_mcp.runtime import NXToolError + + +class Line: + def __init__(self, tag, end): + self.Tag = tag + self.StartPoint = S(X=0, Y=0, Z=0) + self.EndPoint = S(X=end[0], Y=end[1], Z=end[2]) + + +class Harness(LegacyRepairsMixin): + def __init__(self): + self.nxopen = S(Line=Line, Edge=type("Edge", (), {}), Face=type("Face", (), {})) + self.a, self.b = Line(1, [1, 0, 0]), Line(2, [0, 1, 0]) + self.part = S(Sketches=[S(GetAllGeometry=lambda: [self.a, self.b])]) + self.calls = [] + + def _resolve(self, ref, kinds): + return {"a": self.a, "b": self.b}[ref] + + def _work_part(self): + return self.part + + def _reference(self, *args): + return {"id": "sketch"} + + def _edit_sketch(self, *args): + self.calls.append(args) + return {"edited": True} + + +@pytest.mark.parametrize(("end", "expected"), [([0, 1, 0], 90), ([1, 0, 0], 0), ([-1, 0, 0], 180)]) +def test_angle_uses_actual_directions(end, expected): + h = Harness() + h.b = Line(2, end) + result = h._measure_angle("a", "b") + assert result["angle_deg"] == pytest.approx(expected) + assert result["units"] == "deg" + assert result["coordinate_frame"] == "work_part" + + +def test_zero_length_rejected(): + h = Harness() + h.a = Line(1, [0, 0, 0]) + with pytest.raises(NXToolError, match="degenerate"): + h._measure_angle("a", "b") + + +@pytest.mark.parametrize( + ("kind", "targets", "value"), + [ + ("midpoint", ["a"], None), + ("horizontal", ["a", "b"], None), + ("horizontal", ["a"], 5), + ("radius", ["a"], None), + ], +) +def test_constraint_preflight_has_no_mutation(kind, targets, value): + h = Harness() + with pytest.raises(NXToolError): + h._sketch_constraint(kind, targets, value) + assert not h.calls + + +def test_fixed_constraint_routes_to_owned_sketch_editor(): + h = Harness() + h._sketch_constraint("fix", ["a"]) + assert h.calls == [("sketch", [{"action": "constraint", "curve": "a", "type": "fixed"}])] + + +def test_constraint_rejects_cross_sketch_targets(): + h = Harness() + h.part.Sketches = [S(GetAllGeometry=lambda: [h.a]), S(GetAllGeometry=lambda: [h.b])] + with pytest.raises(NXToolError, match="one sketch"): + h._sketch_constraint("parallel", ["a", "b"]) + + +def test_delete_feature_updates_native_delete_list_and_reports_reference(): + h = Harness() + h.session = S( + UpdateManager=S( + AddToDeleteList=lambda feature: h.calls.append(feature), DoUpdate=lambda: None + ) + ) + h._require_api = lambda *args: None + h._update_model = lambda: h.calls.append("update") + assert h._delete_feature("a") == {"deleted": [{"id": "sketch"}]} + assert h.calls == [h.a, "update"] + + +@pytest.mark.parametrize("batch", [True, False]) +def test_canned_view_reports_actual_host_mode(batch): + from nx_mcp.nx_bridge import NXOpenExecutor + + calls = [] + executor = S( + session=S(IsBatch=batch), + nxopen=S(View=S(Canned=S(Top=1), ScaleAdjustment=S(Fit=2))), + _work_part=lambda: S(ModelingViews=S(WorkView=S(Orient=lambda *a: calls.append(a)))), + ) + result = NXOpenExecutor._set_view(executor, "Top") + assert calls == [(1, 2)] + assert result["orientation"] == "top" and result["viewport_available"] is not batch diff --git a/tests/test_lookup_contract.py b/tests/test_lookup_contract.py new file mode 100644 index 0000000..acf795d --- /dev/null +++ b/tests/test_lookup_contract.py @@ -0,0 +1,25 @@ +"""Lookup tests use iterable NX collection seams, never unsupported ToArray().""" + +from types import SimpleNamespace + +import pytest + +from nx_mcp.runtime import NXToolError +from nx_mcp.utils.geometry import resolve_object_by_name + + +def test_lookup_accepts_journal_identifier_and_casefolded_name(): + body = SimpleNamespace(Name="", JournalIdentifier="BODY(1)") + named = SimpleNamespace(Name="Profile", JournalIdentifier="SKETCH(2)") + assert resolve_object_by_name(None, "body(1)", iter([body])) is body + assert resolve_object_by_name(None, "PROFILE", iter([named])) is named + assert resolve_object_by_name(None, "missing", [body, named]) is None + + +def test_lookup_rejects_ambiguous_names_but_deduplicates_same_object(): + first = SimpleNamespace(Name="Part", JournalIdentifier="BODY(1)") + second = SimpleNamespace(Name="PART", JournalIdentifier="BODY(2)") + assert resolve_object_by_name(None, "part", [first], [first]) is first + with pytest.raises(NXToolError) as error: + resolve_object_by_name(None, "part", [first, second]) + assert error.value.code == "NX_AMBIGUOUS_REFERENCE" diff --git a/tests/test_manufacturing_report.py b/tests/test_manufacturing_report.py new file mode 100644 index 0000000..b4c8641 --- /dev/null +++ b/tests/test_manufacturing_report.py @@ -0,0 +1,347 @@ +"""Manufacturing report regressions: contracts, transport recovery and analytic DXF.""" + +import pytest +from pydantic import TypeAdapter, ValidationError + +from nx_mcp.integration_server import BatchOperation +from nx_mcp.planar_dxf import dxf_text +from nx_mcp.result_transport import bound_result, encoded, read_result +from nx_mcp.runtime import NXToolError + + +def test_batch_discriminator_matches_native_sketch_contract(): + adapter = TypeAdapter(BatchOperation) + rectangle = { + "method": "nx_sketch_rectangle", + "params": { + "sketch_id": "sketch", + "corner1": {"x": 0, "y": 0}, + "corner2": {"x": 80, "y": 30}, + }, + } + assert adapter.validate_python(rectangle).params.corner2.x == 80 + with pytest.raises(ValidationError): + adapter.validate_python( + {"method": "nx_sketch_rectangle", "params": {"x1": 0, "y1": 0, "x2": 1, "y2": 1}} + ) + with pytest.raises(ValidationError): + adapter.validate_python({"method": "nx_save_part", "params": {}}) + assert ( + adapter.validate_python( + { + "method": "nx_sketch_arc", + "params": {"cx": 0, "cy": 0, "radius": 2, "start_angle": 0, "end_angle": 360}, + } + ).params.radius + == 2 + ) + + +def test_result_storage_failure_preserves_commit_and_restore(tmp_path, monkeypatch): + from pathlib import Path + + def fail(*_): + raise OSError("disk full") + + monkeypatch.setattr(Path, "write_bytes", fail) + with pytest.raises(NXToolError) as error: + bound_result( + { + "operation_id": "op", + "mutation_outcome": "committed", + "restore_id": "restore", + "objects": ["x" * 4096] * 300, + }, + tmp_path, + ) + assert error.value.code == "NX_RESULT_STORAGE_FAILED" + assert error.value.details["mutation_outcome"] == "committed" + assert error.value.details["restore_id"] == "restore" + + +def test_nested_oversize_pages_remain_bounded_and_addressable(tmp_path): + value = {str(i): ["x" * 2048] * 150 for i in range(120)} + full = { + "status": "success", + "operation_id": "op", + "mutation_outcome": "committed", + "objects": value, + } + receipt = bound_result(full, tmp_path) + key = receipt["full_result"]["id"] + page = read_result(tmp_path, key, "/objects", offset=100, limit=20) + assert list(page["value"]) == [str(i) for i in range(100, 120)] + assert page["total_count"] == 120 and page["next_offset"] is None + assert len(encoded(page)) < 512 * 1024 + child = read_result(tmp_path, key, "/objects/101", offset=140, limit=10) + assert len(child["value"]) == 10 and child["value"][0] == "x" * 2048 + + +def test_dxf_retains_analytic_entities_and_units(): + text = dxf_text( + [ + {"type": "LINE", "layer": "OUTLINE", "start": [0.0, 0.0], "end": [80.0, 0.0]}, + { + "type": "ARC", + "layer": "OUTLINE", + "center": [80.0, 2.0], + "radius": 2.0, + "start_angle": 270.0, + "end_angle": 0.0, + }, + {"type": "CIRCLE", "layer": "HOLES", "center": [10.0, 10.0], "radius": 2.0}, + ] + ) + tokens = text.splitlines() + pairs = list(zip(tokens[::2], tokens[1::2], strict=True)) + assert ("9", "$INSUNITS") in pairs + assert pairs[pairs.index(("9", "$INSUNITS")) + 1] == ("70", "4") + assert [v for k, v in pairs if k == "0" and v in {"LINE", "ARC", "CIRCLE"}] == [ + "LINE", + "ARC", + "CIRCLE", + ] + assert ("50", "270") in pairs and ("51", "0") in pairs and ("2", "HOLES") in pairs + + +def test_new_part_failure_closes_created_parts_and_restores_selection(): + from types import SimpleNamespace as NS + + from nx_mcp.hardened import HardenedExecutor + + class Parts(list): + Work = None + Display = None + + parts = Parts() + e = HardenedExecutor.__new__(HardenedExecutor) + e.session = NS(Parts=parts) + e._reference = lambda p, *_: {"id": str(p.Tag)} + e._close_part = lambda part, save: parts.remove(next(p for p in parts if str(p.Tag) == part)) + + def fail(*_): + parts.append(NS(Tag=10)) + raise NXToolError("NX_IMPORT_NO_OUTPUT", "no geometry") + + e._import_geometry_inner = fail + with pytest.raises(NXToolError) as error: + e._import_geometry("vendor.step", target="new_part", output_path="vendor.prt") + assert error.value.details["mutation_outcome"] == "rolled_back" + assert not parts + + def precondition(*_): + raise NXToolError("NX_FILE_NOT_FOUND", "missing") + + e._import_geometry_inner = precondition + with pytest.raises(NXToolError) as error: + e._import_geometry("missing.step", target="new_part", output_path="vendor.prt") + assert error.value.details["mutation_outcome"] == "not_started" + + +@pytest.fixture +def dimension_fixture(monkeypatch): + import sys + from types import SimpleNamespace as NS + from unittest.mock import Mock + + from nx_mcp.drawing_preferences import DrawingPreferencesMixin + + annotation = NS( + ToleranceType=NS( + NotSet=0, BilateralTwoLines=1, BilateralOneLine=2, LimitTwoLines=3, Basic=4, Reference=5 + ), + DimensionUnit=NS(Millimeters=0, Inches=1, Meters=2, Micrometers=3), + DecimalPointCharacter=NS(Period=0, Comma=1), + ) + monkeypatch.setitem(sys.modules, "NXOpen", NS(Annotations=annotation)) + monkeypatch.setitem(sys.modules, "NXOpen.Annotations", annotation) + formatting = NS( + DisplayTrailingZeros=False, PrimaryDimensionUnit=0, DecimalPointCharacter=0, Dispose=Mock() + ) + prefs = NS( + GetUnitsFormatPreferences=lambda: formatting, + SetUnitsFormatPreferences=Mock(), + Dispose=Mock(), + ) + dim = NS( + GetDimensionPreferences=lambda: prefs, + SetDimensionPreferences=Mock(), + ComputedSize=318.61, + IsRetained=False, + NumberOfAssociativities=2, + NominalDecimalPlaces=1, + ToleranceDecimalPlaces=1, + UpperToleranceValue=0, + LowerToleranceValue=0, + UpperMetricToleranceValue=0, + LowerMetricToleranceValue=0, + MetricNominalDecimalPlaces=1, + MetricToleranceDecimalPlaces=1, + ToleranceType=0, + RedisplayObject=Mock(), + ) + e = DrawingPreferencesMixin() + e._engineering_owned = lambda *args: dim + e._reference = lambda *args: {"id": "dimension"} + e._work_part = lambda: None + e._units = lambda: "mm" + return e, dim, formatting + + +def test_dimension_preferences_preserve_value_and_associations(dimension_fixture): + e, dim, formatting = dimension_fixture + result = e._edit_dimension_format( + "dimension", + decimal_places=2, + trailing_zeros=True, + units="mm", + decimal_separator="comma", + tolerance_type="bilateral", + upper_tolerance=0.05, + lower_tolerance=-0.02, + tolerance_decimal_places=2, + ) + assert result["computed_value"] == 318.61 and result["association_count"] == 2 + assert result["decimal_places"] == 2 and result["decimal_separator"] == "Comma" + assert result["upper_tolerance"] == 0.05 and result["lower_tolerance"] == -0.02 + assert result["tolerance_type"] == "BilateralTwoLines" + assert dim.NumberOfAssociativities == 2 and formatting.DisplayTrailingZeros + + +@pytest.mark.parametrize( + "params", + [ + {}, + {"decimal_places": 9}, + {"units": "feet"}, + {"decimal_separator": "colon"}, + {"tolerance_type": "invented"}, + ], +) +def test_dimension_format_preflight_rejects_unsupported_changes(dimension_fixture, params): + e, dim, _ = dimension_fixture + with pytest.raises(NXToolError): + e._edit_dimension_format("dimension", **params) + dim.SetDimensionPreferences.assert_not_called() + + +def test_dimension_format_rejects_changed_measurement(dimension_fixture): + e, dim, _ = dimension_fixture + dim.SetDimensionPreferences.side_effect = lambda *_: setattr(dim, "ComputedSize", 0) + with pytest.raises(NXToolError, match="measured value"): + e._edit_dimension_format("dimension", decimal_places=2) + + +@pytest.fixture +def planar_fixture(tmp_path, monkeypatch): + import math + import sys + from types import SimpleNamespace as NS + + from nx_mcp.planar_dxf import PlanarDxfMixin + from nx_mcp.workspace import Workspace + + class Sketch: + pass + + curves = [ + NS(Tag=1, kind="line", limits=[0, 1]), + NS(Tag=2, kind="circle", limits=[0, 2 * math.pi]), + NS(Tag=3, kind="arc", limits=[0, math.pi / 2]), + ] + arc = NS(Center=[1, 1, 0], Radius=0.25, XAxis=[1, 0, 0], YAxis=[0, 1, 0]) + + def evaluate(curve, t): + return ( + [ + [2 * t, 0, 0] + if curve.kind == "line" + else [1 + 0.25 * math.cos(t), 1 + 0.25 * math.sin(t), 0] + ], + ) + + # UF returns a coordinate vector as the first tuple element. + def point(curve, t): + return evaluate(curve, t)[0] + + evaluator = NS( + Initialize2=lambda tag: curves[tag - 1], + AskLimits=lambda c: c.limits, + EvaluateUnitVectors=lambda c, t: point(c, t), + IsLine=lambda c: c.kind == "line", + IsArc=lambda c: c.kind in {"arc", "circle"}, + AskArc=lambda _: arc, + ) + uf = NS(Eval=evaluator) + nx = NS(Sketch=Sketch) + monkeypatch.setitem(sys.modules, "NXOpen", nx) + monkeypatch.setitem(sys.modules, "NXOpen.UF", NS(UFSession=NS(GetUFSession=lambda: uf))) + nx.UF = sys.modules["NXOpen.UF"] + part = NS() + sketch = Sketch() + sketch.IsOccurrence = False + sketch.OwningPart = part + sketch.GetAllGeometry = lambda: curves + e = PlanarDxfMixin() + e.nxopen = nx + e.workspace = Workspace(tmp_path) + e._resolve = lambda *_: sketch + e._work_part = lambda: part + e._units = lambda: "inch" + e._sketch_frame = lambda _: { + "origin": [0, 0, 0], + "x_axis": [1, 0, 0], + "y_axis": [0, 1, 0], + "normal": [0, 0, 1], + } + e._reference = lambda c, *_: {"id": str(c.Tag)} + return e, sketch, curves + + +def test_planar_export_converts_inches_and_preserves_arc_layers(planar_fixture, tmp_path): + e, _, _ = planar_fixture + r = e._export_planar_dxf("sketch", str(tmp_path / "outline.dxf"), layers={"2": "HOLES"}) + assert r["units"] == "mm" and r["entity_counts"] == {"LINE": 1, "CIRCLE": 1, "ARC": 1} + line, circle, arc = r["entities"] + assert line["end"] == [50.8, 0] and circle["radius"] == 6.35 + assert circle["layer"] == "HOLES" and arc["start_angle"] == 0 and arc["end_angle"] == 90 + assert (tmp_path / "outline.dxf").stat().st_size == r["size"] + with pytest.raises(NXToolError, match="existing"): + e._export_planar_dxf("sketch", str(tmp_path / "outline.dxf")) + + +@pytest.mark.parametrize( + "params", + [ + {"origin": [0, 0, 1]}, + {"x_axis": [1, 0, 0]}, + {"x_axis": [1, 0, 0], "y_axis": [1, 0, 0]}, + {"layers": {"missing": "HOLES"}}, + {"layer": "invalid/name"}, + ], +) +def test_planar_export_preflights_before_writing(planar_fixture, tmp_path, params): + e, _, _ = planar_fixture + with pytest.raises(NXToolError): + e._export_planar_dxf("sketch", str(tmp_path / "rejected.dxf"), **params) + assert not (tmp_path / "rejected.dxf").exists() + + +def test_save_work_part_does_not_save_modified_components(rig): + from unittest.mock import Mock + + original = rig.part.Save + child = type("Child", (), {"IsModified": True})() + + def save(components, close): + if components: + child.IsModified = False + return original(components, close) + + rig.part.Save = save + rig.e._save_component_drawing_previews = Mock( + side_effect=AssertionError("Implicit component save") + ) + rig.e._save_part() + assert child.IsModified + rig.e._save_component_drawing_previews.assert_not_called() diff --git a/tests/test_native_inspection.py b/tests/test_native_inspection.py new file mode 100644 index 0000000..f52bd2e --- /dev/null +++ b/tests/test_native_inspection.py @@ -0,0 +1,252 @@ +"""Native inspection seam tests: reports, temporary solids and cleanup failures.""" + +import struct +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Component, point + +pytestmark = pytest.mark.fake_nx + + +def test_occurrence_geometry_bounds_and_volume_are_explicit(rig): + base = Body("base", [0, 0, 0, 2, 3, 4]) + base.volume = 24 + rig.part.Bodies.append(base) + root = Component("root") + a = Component("a", [Body()], root) + nested = Component("nested", [Body()], a) + rig.part.ComponentAssembly.RootComponent = root + assert len(rig.e._geometry(scope="assembly")) == 3 + assert len(rig.e._geometry(rig.ref(a, "component"))) == 2 + a.IsSuppressed = True + assert rig.e._occurrence_bodies(nested) == [] + assert rig.e._geometry(scope="assembly") == [base] + rig.ref(base) + for precision in ["exact", "conservative"]: + result = rig.e._get_bounding_box(precision=precision) + assert result["dimensions"] == [2, 3, 4] and result["units"] == "mm" + assert rig.e._measure_volume()["volume_mm3"] == 24 + base.IsSolidBody = False + with pytest.raises(NXToolError): + rig.e._measure_volume() + with pytest.raises(NXToolError): + rig.e._get_bounding_box(precision="bad") + rig.part.WCS.CoordinateSystem.Orientation.Element.Xx = 0 + with pytest.raises(NXToolError): + rig.e._get_bounding_box(precision="exact") + with pytest.raises(NXToolError): + rig.e._geometry(scope="bad") + rig.part.Bodies.clear() + with pytest.raises(NXToolError): + rig.e._geometry(scope="part") + + +def native_pair(rig, result=1, temporary=True): + b = NS(FirstBody=NS(Value=None), SecondBody=NS(Value=None), Reset=Mock(), Destroy=Mock()) + solid = Body() + solid.volume = 125 + sheet = Body() + sheet.IsSolidBody = False + + def perform(): + if temporary: + rig.part.Bodies.append(solid) + return result + + b.PerformCheck = Mock(side_effect=perform) + b.GetInterferenceResults = lambda: [solid, sheet] + rig.part.AnalysisManager = NS(CreateSimpleInterferenceObject=lambda: b) + return b + + +@pytest.mark.parametrize( + "enum,label,volume", [(1, "penetration", 125), (2, "contact", 0), (3, "clear", 0)] +) +def test_native_interference_removes_temporary_solids(rig, enum, label, volume): + a = Body() + b = Body() + rig.part.Bodies.extend([a, b]) + builder = native_pair(rig, enum) + result = rig.e._interference_pair(a, b) + assert result == {"classification": label, "interference_volume_mm3": volume} + assert rig.part.Bodies == [a, b] and not rig.session.marks + builder.Reset.assert_called_once() + builder.Destroy.assert_called_once() + + +@pytest.mark.parametrize("failure", ["unresolved", "perform", "reset", "undo", "leaked_body"]) +def test_interference_failure_cleanup_and_partial_outcome(rig, failure): + a = Body() + b = Body() + builder = native_pair(rig, 99 if failure == "unresolved" else 1) + if failure == "perform": + builder.PerformCheck = Mock(side_effect=RuntimeError("native failure")) + if failure == "reset": + builder.Reset = Mock(side_effect=RuntimeError("reset failure")) + if failure == "undo": + rig.session.UndoToMark = Mock(side_effect=RuntimeError("undo failed")) + if failure == "leaked_body": + rig.session.UndoToMark = Mock() + with pytest.raises((NXToolError, RuntimeError)) as error: + rig.e._interference_pair(a, b) + assert builder.Destroy.call_count == 1 + if failure in ["undo", "leaked_body"]: + assert error.value.code == "NX_ROLLBACK_FAILED" + assert error.value.details["mutation_outcome"] == "partial" + else: + assert not rig.part.Bodies + + +def test_clearance_prunes_only_separated_boxes_and_returns_native_points(rig): + a = Body("a") + b = Body("b", [12, 0, 0, 22, 10, 10]) + rig.part.Bodies.extend([a, b]) + refs = [rig.ref(v) for v in (a, b)] + measured = Mock(return_value=(2.0, point(10, 0, 0), point(12, 0, 0), 123)) + rig.session.Measurement = NS(GetMinimumDistance=measured) + skipped = rig.e._check_clearance(refs) + assert skipped["broad_phase_clear_pairs"] == 1 and measured.call_count == 0 + result = rig.e._check_clearance(refs, minimum_clearance=3) + assert result["counts"]["below_clearance"] == 1 + assert result["pairs"][0]["closest_points"] == [[10, 0, 0], [12, 0, 0]] + assert result["pairs"][0]["accuracy"] is None + assert rig.e._measure_distance(*refs)["distance"] == 2 + native_pair(rig, 2, False) + measured.return_value = (0, point(), point(), None) + result = rig.e._check_interference(*refs) + assert result["counts"]["contact"] == 1 + with pytest.raises(NXToolError): + rig.e._check_interference(refs[0], refs[0]) + b.IsSolidBody = False + with pytest.raises(NXToolError): + rig.e._check_interference(*refs) + with pytest.raises(NXToolError): + rig.e._check_clearance() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"minimum_clearance": -1}, + {"minimum_clearance": float("inf")}, + {"max_pairs": True}, + {"max_pairs": 0}, + {"max_pairs": 10001}, + {"objects": []}, + {"objects": ["one"]}, + {"objects": "bad"}, + ], +) +def test_invalid_clearance_rejected_before_geometry(rig, kwargs): + with pytest.raises(NXToolError): + rig.e._check_clearance(**kwargs) + + +def test_pair_limit_prevents_partial_clearance_report(rig): + rig.part.Bodies.extend([Body(), Body(), Body()]) + with pytest.raises(NXToolError) as error: + rig.e._check_clearance(max_pairs=1) + assert error.value.code == "NX_PAIR_LIMIT" + + +def image_builder(rig, payload=None, fail=False): + b = NS(Destroy=Mock(), SetCustomBackgroundColor=Mock()) + + def commit(): + if fail: + raise RuntimeError("graphics failed") + Path(b.FileName).write_bytes( + payload + if payload is not None + else b"\x89PNG\r\n\x1a\n" + b"\0" * 8 + struct.pack(">II", 320, 240) + ) + + b.Commit = commit + rig.part.Views = NS(CreateImageExportBuilder=lambda: b) + return b + + +@pytest.mark.parametrize( + "background,style", [("white", "shaded"), ("original", "current"), ("transparent", "wireframe")] +) +def test_viewport_metadata_and_style_restoration(rig, background, style): + b = image_builder(rig) + result = rig.e._screenshot(width=320, height=240, background=background, style=style, fit=True) + assert result["resolution"] == [320, 240] and not result["warnings"] + assert result["capture_kind"] == "nx_model_viewport" + assert rig.part.ModelingViews.WorkView.RenderingStyle == "shaded" + assert b.Destroy.call_count == 1 + rig.part.ModelingViews.WorkView.RenderingStyle = 99 + assert rig.e._view_info()["rendering_style"] == "nx_style_99" + + +@pytest.mark.parametrize( + "failure", + [ + "native", + "invalid_png", + "wrong_size", + "missing_display", + "batch", + "exists", + "extension", + "resolution", + "style", + ], +) +def test_capture_failure_does_not_leave_style_changed(rig, tmp_path, failure): + b = image_builder( + rig, payload=b"broken" if failure == "invalid_png" else None, fail=failure == "native" + ) + p = tmp_path / "test.png" + kwargs = {"path": str(p)} + if failure == "missing_display": + rig.session.Parts.Display = None + if failure == "batch": + rig.session.IsBatch = True + if failure == "exists": + p.write_bytes(b"preserved") + if failure == "extension": + kwargs["path"] = str(tmp_path / "test.jpg") + if failure == "resolution": + kwargs["width"] = True + if failure == "style": + kwargs["style"] = "bad" + if failure == "wrong_size": + result = rig.e._capture_view(**kwargs) + assert result["warnings"] + return + with pytest.raises((NXToolError, RuntimeError)): + rig.e._capture_view(**kwargs) + assert rig.part.ModelingViews.WorkView.RenderingStyle == "shaded" + if failure in ["native", "invalid_png"]: + assert b.Destroy.call_count == 1 + if failure == "exists": + assert p.read_bytes() == b"preserved" + + +def test_failed_builder_destroy_still_restores_view_style(rig): + builder = image_builder(rig) + builder.Destroy = Mock(side_effect=RuntimeError("destroy failed")) + with pytest.raises(RuntimeError): + rig.e._capture_view(style="wireframe") + assert rig.part.ModelingViews.WorkView.RenderingStyle == "shaded" + + +def test_read_only_cleanup_failure_keeps_partial_outcome_in_receipt(rig): + a = Body() + b = Body() + rig.part.Bodies.extend([a, b]) + refs = [rig.ref(v) for v in (a, b)] + native_pair(rig) + rig.session.Measurement = NS(GetMinimumDistance=lambda *_: (0, point(), point(), None)) + rig.session.UndoToMark = Mock(side_effect=RuntimeError("undo failed")) + with pytest.raises(NXToolError) as error: + rig.e.execute("nx_check_interference", dict(zip(["obj1", "obj2"], refs, strict=True))) + assert error.value.code == "NX_ROLLBACK_FAILED" + assert error.value.details["mutation_outcome"] == "partial" diff --git a/tests/test_native_release_runner.py b/tests/test_native_release_runner.py new file mode 100644 index 0000000..6e10c60 --- /dev/null +++ b/tests/test_native_release_runner.py @@ -0,0 +1,72 @@ +"""Native release receipts must distinguish failures and changed user sessions.""" + +import hashlib +import runpy +from pathlib import Path + +import pytest + +RUNNER = runpy.run_path( + str(Path(__file__).resolve().parents[1] / "scripts/validate_native_release.py") +) + + +def test_session_verification_checks_placements_and_unsaved_state(): + before = { + "parts": [{"path": "a.prt", "work": True, "display": True, "modified": False}], + "components": [{"path": "p.prt", "translation": [1, 2, 3]}], + } + RUNNER["verify_session"](before, before) + with pytest.raises(RuntimeError, match="component source"): + RUNNER["verify_session"](before, {**before, "components": []}) + with pytest.raises(RuntimeError, match="saved state"): + RUNNER["verify_session"]( + before, {**before, "parts": [{**before["parts"][0], "modified": True}]} + ) + + +def test_receipt_manifest_hashes_nested_artifacts(tmp_path): + (tmp_path / "sheets").mkdir() + (tmp_path / "sheets" / "view.png").write_bytes(b"fixture") + (tmp_path / "release-validation.json").write_text("not self-hashed") + assert RUNNER["manifest_files"](tmp_path) == [ + {"path": "sheets/view.png", "size": 7, "sha256": hashlib.sha256(b"fixture").hexdigest()} + ] + + +@pytest.mark.asyncio +async def test_agent_ux_client_forwards_tool_name_arguments(tmp_path): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + runner = runpy.run_path( + str(Path(__file__).resolve().parents[1] / "scripts/validate_agent_ux.py") + ) + session = SimpleNamespace( + call_tool=AsyncMock( + return_value=SimpleNamespace(isError=False, structuredContent={"status": "success"}) + ) + ) + client = runner["Client"](session, tmp_path / "ux") + client.schemas = {"nx_create_reference_set": {"properties": {}}} + await client.call("nx_create_reference_set", name="SOLIDS", objects=["body"]) + session.call_tool.assert_awaited_once_with( + "nx_create_reference_set", {"name": "SOLIDS", "objects": ["body"]} + ) + + +def test_native_examples_accept_runner_profile_count(): + """An added tool must not strand unrelated native suites on an old literal.""" + examples = Path(__file__).resolve().parents[1] / "examples" + for name in [ + "advanced_tools", + "authoring_tools", + "engineering_tools", + "freeform_manufacturing", + "project_folders", + "sheet_metal", + "visual_tools", + ]: + source = (examples / f"validate_{name}.py").read_text() + assert 'os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")' in source + assert "== 179" not in source diff --git a/tests/test_nx_executor.py b/tests/test_nx_executor.py index a397075..b9d5940 100644 --- a/tests/test_nx_executor.py +++ b/tests/test_nx_executor.py @@ -204,8 +204,15 @@ def Dispose(self): class FakeStepCreator: + def __init__(self): + self.ObjectTypes = SimpleNamespace() + def Commit(self): - Path(self.OutputFile).write_text("STEP", encoding="utf-8") + assert self.ExportFrom == "existing-part" + assert self.ObjectTypes.Solids is True + Path(self.OutputFile).write_text( + "ISO-10303-21;\nMANIFOLD_SOLID_BREP();\nEND-ISO-10303-21;", encoding="utf-8" + ) def Destroy(self): pass @@ -275,8 +282,9 @@ def UndoToMark(self, mark, name): FAKE_NXOPEN = SimpleNamespace( + StepCreator=SimpleNamespace(ExportFromOption=SimpleNamespace(ExistingPart="existing-part")), BasePart=SimpleNamespace( - SaveComponents=SimpleNamespace(TrueValue=True), + SaveComponents=SimpleNamespace(TrueValue=True, FalseValue=False), CloseAfterSave=SimpleNamespace(FalseValue=False), CloseWholeTree=SimpleNamespace(TrueValue="whole-tree"), CloseModified=SimpleNamespace(CloseModified="close"), @@ -367,7 +375,7 @@ def test_open_save_export_and_close_part_lifecycle(tmp_path: Path): assert opened["part"]["name"] == "existing" assert saved["message"] == "Saved part: existing" assert exported["path"].endswith("part.stp") - assert Path(exported["path"]).read_text(encoding="utf-8") == "STEP" + assert "MANIFOLD_SOLID_BREP" in Path(exported["path"]).read_text(encoding="utf-8") assert closed["message"] == "Closed part: existing" assert session.Parts.last_closed.close_args[:2] == ("whole-tree", "close") assert executor.execute("nx_status", {})["active_part"] is None diff --git a/tests/test_output_schemas.py b/tests/test_output_schemas.py new file mode 100644 index 0000000..8f3c683 --- /dev/null +++ b/tests/test_output_schemas.py @@ -0,0 +1,123 @@ +"""Contract acceptance and rejection, including MCP image/error transport.""" + +import copy +from unittest.mock import AsyncMock + +import pytest +from jsonschema import Draft202012Validator, ValidationError +from mcp.shared.memory import create_connected_server_and_client_session + +from nx_mcp.integration_server import IntegrationEnvelope, envelope +from nx_mcp.output_schemas import PAYLOADS, output_schema +from nx_mcp.runtime import NXToolError +from nx_mcp.server import create_server +from nx_mcp.workspace import Workspace + + +def validate(name, payload): + schema = output_schema(name, IntegrationEnvelope.model_json_schema()) + Draft202012Validator.check_schema(schema) + Draft202012Validator(schema).validate(envelope(payload).structuredContent) + + +@pytest.mark.parametrize("name", list(PAYLOADS)) +def test_success_requires_payload_but_error_has_independent_contract(name): + with pytest.raises(ValidationError): + validate(name, {}) + validate( + name, + NXToolError( + "NX_TEST", "Actionable failure", details={"mutation_outcome": "rolled_back"} + ).as_dict(), + ) + with pytest.raises(ValidationError): + validate(name, {"status": "error", "message": "missing code and retry guidance"}) + + +def test_recovery_states_and_unknown_are_typed_without_inventing_failure(): + unknown = {"operation_id": "unknown_123", "state": "unknown", "mutation_outcome": "unknown"} + validate("nx_operation_status", unknown) + for key, value in [("state", "complete"), ("mutation_outcome", "probably_ok")]: + with pytest.raises(ValidationError): + validate("nx_operation_status", {**unknown, key: value}) + validate( + "nx_operation_status", + { + **unknown, + "state": "committed", + "mutation_outcome": "committed", + "result": {"object": {}}, + }, + ) + + +def test_geometry_cardinality_units_and_closest_points(): + ref = {"id": "body123", "kind": "body", "name": "", "part_id": "part123"} + volume = { + "bodies": [{"body": ref, "volume_mm3": 12}], + "body_count": 1, + "volume_mm3": 12, + "volume_units": "mm^3", + "semantics": "sum_of_included_bodies", + "scope": "part", + } + validate("nx_measure_volume", volume) + with pytest.raises(ValidationError): + validate("nx_measure_volume", {**volume, "volume_units": "in^3"}) + distance = { + "distance": 5, + "closest_points": [[0, 0, 0], [5, 0, 0]], + "references": ["a", "b"], + "resolved_tags": [1, 2], + "coordinate_frame": "work_part", + "pair_count": 1, + "method": "NX", + "accuracy": None, + } + validate("nx_measure_distance", distance) + bad = copy.deepcopy(distance) + bad["closest_points"][0].pop() + with pytest.raises(ValidationError): + validate("nx_measure_distance", bad) + + +@pytest.mark.asyncio +async def test_fresh_mcp_client_accepts_artifacts_and_structured_errors(tmp_path): + (tmp_path / "test.txt").write_text("hello") + server = create_server(AsyncMock(), Workspace(tmp_path), enable_experimental=True) + async with create_connected_server_and_client_session(server) as client: + for args in [{"delivery": "metadata"}, {"length": 2}, {"offset": 5}]: + result = await client.call_tool("nx_download_file", {"path": "test.txt", **args}) + assert not result.isError + validate("nx_download_file", result.structuredContent) + error = await client.call_tool("nx_download_file", {"path": "absent.txt"}) + assert error.isError and error.structuredContent["code"] == "NX_FILE_NOT_FOUND" + validate("nx_download_file", error.structuredContent) + tools = {t.name: t for t in (await client.list_tools()).tools} + assert "sketch_name" in tools["nx_revolve"].inputSchema["required"] + assert "Saves the part" in tools["nx_export_step"].description + assert "checkpoints can expire" in tools["nx_export_step"].description + bad = await client.call_tool("nx_revolve", {}) + assert bad.isError and bad.structuredContent["code"] == "NX_INVALID_ARGUMENT" + + +@pytest.mark.asyncio +async def test_new_inspection_tools_publish_actionable_docstrings(tmp_path): + server = create_server(AsyncMock(), Workspace(tmp_path), enable_experimental=True) + tools = {t.name: t for t in await server.list_tools()} + expected = { + "nx_flat_pattern_orientation_edges": "x_axis_edge", + "nx_create_reference_set": "solid body IDs", + "nx_set_component_reference_set": "direct children", + "nx_list_reference_sets": "direct members", + "nx_list_datums": "coordinate systems", + "nx_set_datum_visibility": "restore_id", + "nx_list_open_parts": "total_count", + "nx_list_components": "include_transforms=False", + } + for name, guidance in expected.items(): + assert guidance in tools[name].description + assert ( + "semantics and installed API support have not been validated" + not in tools[name].description + ) diff --git a/tests/test_recovery_state.py b/tests/test_recovery_state.py new file mode 100644 index 0000000..88857d6 --- /dev/null +++ b/tests/test_recovery_state.py @@ -0,0 +1,248 @@ +"""Recovery invariants across native failures, save boundaries and part lifetimes.""" + +from unittest.mock import Mock + +import pytest + +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Part + +pytestmark = pytest.mark.fake_nx + + +def mutation(rig): + def add(fail=False): + rig.part.Bodies.append(Body()) + rig.part.IsModified = True + if fail: + raise RuntimeError("after mutation") + return {} + + rig.e._handlers["nx_test_add"] = add + return lambda **p: rig.e.execute("nx_test_add", p) + + +def test_checkpoint_rollback_invalidates_objects_and_preserves_receipt(rig): + add = mutation(rig) + cp = rig.e.execute("nx_checkpoint", {}) + committed = add(operation_id="test-operation-001") + ref = committed["changes"]["created"][0]["id"] + assert rig.e._checkpoint_state()["undo_depth"] == 1 + rig.e.execute("nx_status", {}) + assert rig.e._checkpoint_state()["undo_depth"] == 1 + rig.e.execute("nx_rollback", {"checkpoint_id": cp["checkpoint_id"]}) + assert not rig.part.Bodies and not rig.part.IsModified + with pytest.raises(NXToolError): + rig.e.objects.resolve(ref) + replay = add(operation_id="test-operation-001") + assert replay["replayed"] and "reverted" in replay["warnings"][0] + assert not rig.part.Bodies + with pytest.raises(NXToolError, match="not in this session"): + rig.e._rollback_checkpoint(cp["checkpoint_id"]) + + +def test_undo_reverts_only_latest_operation_and_save_expires_marks(rig): + add = mutation(rig) + add(operation_id="first-operation") + add(operation_id="second-operation") + assert len(rig.part.Bodies) == 2 + assert rig.e.execute("nx_undo", {})["undone_operation_id"] == "second-operation" + assert len(rig.part.Bodies) == 1 + cp = rig.e._checkpoint() + saved = rig.e._save_part() + assert saved["recovery"]["undo_depth"] == 0 + with pytest.raises(NXToolError, match="expired"): + rig.e._rollback_checkpoint(cp["checkpoint_id"]) + with pytest.raises(NXToolError, match="No native undo"): + rig.e._undo() + + +def test_cross_part_undo_and_checkpoint_refuse_unrelated_changes(rig, tmp_path): + add = mutation(rig) + cp = rig.e._checkpoint() + add() + other = Part(rig.session, tmp_path / "other.prt") + with pytest.raises(NXToolError) as error: + rig.e._undo() + assert error.value.code == "NX_CROSS_PART_ROLLBACK" + with pytest.raises(NXToolError) as error: + rig.e._rollback_checkpoint(cp["checkpoint_id"]) + assert error.value.code == "NX_CROSS_PART_ROLLBACK" + assert len(rig.part.Bodies) == 1 and not other.Bodies + + +def test_failed_update_rolls_back_and_rejects_retry(rig): + add = mutation(rig) + with pytest.raises(NXToolError) as error: + add(fail=True, operation_id="failed-operation") + assert error.value.details["mutation_outcome"] == "rolled_back" + assert not rig.part.Bodies and not rig.part.IsModified + with pytest.raises(NXToolError) as replay: + add(fail=True, operation_id="failed-operation") + assert replay.value.code == "NX_OPERATION_FAILED" + assert rig.e._current_operation is None + + +def test_rollback_failure_is_partial_and_never_replayed_as_success(rig): + add = mutation(rig) + rig.session.UndoToMark = Mock(side_effect=RuntimeError("lost mark")) + with pytest.raises(NXToolError) as error: + add(fail=True, operation_id="partial-operation") + assert error.value.code == "NX_ROLLBACK_FAILED" + assert error.value.details["mutation_outcome"] == "partial" + assert rig.e.store.get("partial-operation")["mutation_outcome"] == "partial" + assert len(rig.part.Bodies) == 1 + + +def test_receipt_write_failure_does_not_undo_committed_geometry(rig, monkeypatch): + add = mutation(rig) + put = rig.e.store.put + + def persist(record): + if record["state"] == "committed": + raise OSError("disk full") + put(record) + + monkeypatch.setattr(rig.e.store, "put", persist) + with pytest.raises(OSError): + add(operation_id="durable-operation") + assert len(rig.part.Bodies) == 1 + assert rig.e.store.get("durable-operation")["state"] == "running" + rig.e.store.recover("next-session") + assert rig.e.store.get("durable-operation")["state"] == "unknown" + with pytest.raises(NXToolError) as error: + add(operation_id="durable-operation") + assert error.value.code == "NX_OPERATION_UNKNOWN" + + +def test_prior_session_receipt_warns_and_cannot_duplicate(rig): + add = mutation(rig) + add(operation_id="previous-session") + r = rig.e.store.get("previous-session") + r["session_id"] = "old" + rig.e.store.put(r) + result = add(operation_id="previous-session") + assert any("earlier NX session" in w for w in result["warnings"]) + assert len(rig.part.Bodies) == 1 + + +def test_session_lifecycle_and_generation_reject_closed_references(rig, tmp_path): + part = rig.part + body = Body() + part.Bodies.append(body) + old = rig.ref(body) + p = rig.e._reference(part, "part", part, "Part")["id"] + opened = rig.e._open_part(part.FullPath) + assert opened["already_loaded"] + assert opened["message"] == "Reused loaded part" + assert rig.e._activate_part(part.Name, False, False)["part"]["id"] == p + cp = rig.e._checkpoint() + rig.e._close_part(save=True, part=p) + assert not rig.session.Parts and not rig.e._checkpoints + with pytest.raises(NXToolError): + rig.e.objects.resolve(old) + rig.session.Parts.append(part) + rig.session.Parts.Work = rig.session.Parts.Display = part + assert rig.ref(body) != old + path = tmp_path / "imported.prt" + path.write_text("fixture") + new_part = rig.e._open_part(str(path), work=False, display=False) + assert not new_part["already_loaded"] + assert new_part["message"] == "Opened part" + assert len(rig.e._list_open_parts()["parts"]) == 2 + with pytest.raises(NXToolError): + rig.e._open_part(str(tmp_path / "missing.prt")) + with pytest.raises(NXToolError): + rig.e._activate_part("missing") + assert cp["checkpoint_id"] not in rig.e._checkpoints + + +def test_snapshot_invalidation_preserves_topology_for_display_only(rig): + b = Body() + rig.part.Bodies.append(b) + before = rig.e._snapshot(rig.part) + face = rig.ref(b.faces[0], "face") + rig.e._invalidate_deleted(before, before, False) + assert rig.e.objects.resolve(face) is b.faces[0] + rig.e._invalidate_deleted(before, {}) + for ref in [face, next(iter(before.values()))["id"]]: + with pytest.raises(NXToolError): + rig.e.objects.resolve(ref) + + +def test_lookup_ambiguity_ownership_and_kind_guards(rig, tmp_path): + first = Body("A") + second = Body("a") + rig.part.Bodies.extend([first, second]) + ref = rig.ref(first) + assert rig.e._resolve(first.JournalIdentifier, {"body"}) is first + with pytest.raises(NXToolError) as error: + rig.e._resolve("A") + assert error.value.code == "NX_AMBIGUOUS_REFERENCE" + with pytest.raises(NXToolError): + rig.e._resolve(ref, {"feature"}) + with pytest.raises(NXToolError): + rig.e._resolve("obj_missing") + with pytest.raises(NXToolError): + rig.e._resolve("missing") + Part(rig.session, tmp_path / "other.prt") + with pytest.raises(NXToolError): + rig.e._resolve(ref) + + +def test_guard_failures_never_create_undo_marks(rig): + with pytest.raises(NXToolError): + rig.e.execute("nx_checkpoint", {"unexpected": 1}) + with pytest.raises(NXToolError): + rig.e.execute("nx_no_such_tool", {}) + rig.e.enable_experimental = False + with pytest.raises(NXToolError): + rig.e.execute("nx_no_such_tool", {}) + assert not rig.session.marks + + +def test_open_reuses_loaded_part_with_equivalent_path_spelling(rig, tmp_path): + path = tmp_path / "project" / "base.prt" + path.parent.mkdir() + rig.part.FullPath = str(path.parent / "sub" / ".." / path.name) + opened = rig.e._open_part(str(path)) + assert opened["already_loaded"] + assert len(rig.session.Parts) == 1 + + +def test_close_invalidates_automatically_unloaded_prototypes(rig, tmp_path): + child = Part(rig.session, tmp_path / "child.prt") + child_ref = rig.e._reference(child, "part", child, "Child")["id"] + target = rig.e._reference(rig.part, "part", rig.part, "Parent")["id"] + rig.part.Close = Mock(side_effect=lambda *args: rig.session.Parts.clear()) + result = rig.e._close_part(save=False, part=target) + assert result["closed_count"] == 2 and result["remaining_count"] == 0 + with pytest.raises(NXToolError) as error: + rig.e.objects.resolve(child_ref) + assert error.value.code == "NX_OBJECT_STALE" + + +def test_operation_status_preserves_target_receipt_metadata(rig): + committed = mutation(rig)(operation_id="receipt-query-target") + status = rig.e.execute("nx_operation_status", {"operation_id": "receipt-query-target"}) + for key in ["operation_id", "session_id", "mutation_outcome"]: + assert status[key] == committed[key] + assert status["query_operation_id"] != status["operation_id"] + missing = rig.e.execute("nx_operation_status", {"operation_id": "receipt-not-recorded"}) + assert missing["operation_id"] == "receipt-not-recorded" + assert missing["mutation_outcome"] == "unknown" and missing["session_id"] is None + + +def test_capability_filters_and_unit_conventions(rig): + from types import SimpleNamespace + + rig.session.DexManager = SimpleNamespace() + rig.session.Measurement = SimpleNamespace() + one = rig.e._capabilities(tool="nx_close_part") + assert list(one["tools"]) == ["nx_close_part"] and one["units"] is None + group = rig.e._capabilities(prefix="nx_sheet") + assert all(name.startswith("nx_sheet") for name in group["tools"]) + assert group["tool_count"] < group["total_tool_count"] + for params in [{"tool": "nx_missing"}, {"tool": "nx_close_part", "prefix": "nx_"}]: + with pytest.raises(NXToolError): + rig.e._capabilities(**params) diff --git a/tests/test_release_acceptance.py b/tests/test_release_acceptance.py new file mode 100644 index 0000000..80f2dec --- /dev/null +++ b/tests/test_release_acceptance.py @@ -0,0 +1,231 @@ +"""Acceptance verifies installed bytes and never replays uncertain native work.""" + +import io +import json +import runpy +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest + +RUNNER = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/accept_release.py")) + + +def package(tmp_path): + install = tmp_path / "install" + runtime = install / "venv/nx_mcp" + runtime.mkdir(parents=True) + (runtime / "__init__.py").write_bytes(b"version = 'test'\n") + release = {"version": "1.0", "commit": "a" * 40} + (install / "release.json").write_text(json.dumps(release)) + (install / "source").mkdir() + (install / "source/file.py").write_bytes(b"source\n") + wheel = io.BytesIO() + with zipfile.ZipFile(wheel, "w") as z: + z.writestr("nx_mcp/__init__.py", (runtime / "__init__.py").read_bytes()) + files = { + "release.json": json.dumps(release).encode(), + "source/file.py": b"source\n", + "wheels/nx_mcp-1.0-py3-none-any.whl": wheel.getvalue(), + } + files["manifest.json"] = json.dumps({k: RUNNER["digest"](v) for k, v in files.items()}).encode() + archive = tmp_path / "release.zip" + with zipfile.ZipFile(archive, "w") as z: + for name, data in files.items(): + z.writestr(name, data) + return archive, RUNNER["digest"](archive.read_bytes()), release["commit"], install, runtime + + +def test_release_checks_source_wheel_commit_and_archive(tmp_path): + args = package(tmp_path) + result = RUNNER["verify_package"](*args) + assert result["source_files_verified"] == result["runtime_files_verified"] == 1 + assert result["loaded_process_bytes_attested"] is False + with pytest.raises(RuntimeError, match="SHA-256"): + RUNNER["verify_package"](args[0], "0" * 64, *args[2:]) + with pytest.raises(RuntimeError, match="commit"): + RUNNER["verify_package"](*args[:2], "b" * 40, *args[3:]) + (args[3] / "source/file.py").write_text("changed") + with pytest.raises(RuntimeError, match="Installed source mismatch"): + RUNNER["verify_package"](*args) + + +def test_release_rejects_shadow_runtime_and_changed_wheel(tmp_path): + args = package(tmp_path) + (args[4] / "unexpected.py").write_text("shadow") + with pytest.raises(RuntimeError, match="Unpackaged"): + RUNNER["verify_package"](*args) + (args[4] / "unexpected.py").unlink() + (args[4] / "__init__.py").write_text("changed") + with pytest.raises(RuntimeError, match="differs from wheel"): + RUNNER["verify_package"](*args) + + +@pytest.mark.parametrize("state", ["running", "failed"]) +def test_resume_does_not_repeat_uncertain_native_phase(tmp_path, state): + path = tmp_path / "acceptance.json" + path.write_text( + json.dumps({"identity": {"commit": "a"}, "phases": {"native": {"state": state}}}) + ) + with pytest.raises(RuntimeError, match="no automatic replay"): + RUNNER["prepare_report"](path, {"commit": "a"}, True) + + +def test_resume_requires_same_inputs_and_complete_preservation(tmp_path): + path = tmp_path / "acceptance.json" + report = {"identity": {"commit": "a"}, "state": "verified", "phases": {}} + path.write_text(json.dumps(report)) + assert RUNNER["prepare_report"](path, report["identity"], True) == report + with pytest.raises(RuntimeError, match="inputs differ"): + RUNNER["prepare_report"](path, {"commit": "b"}, True) + report["phases"]["native"] = {"state": "passed"} + path.write_text(json.dumps(report)) + with pytest.raises(RuntimeError, match="reconcile session"): + RUNNER["prepare_report"](path, report["identity"], True) + + +def test_phase_persists_running_before_work_and_failure_after(tmp_path): + path = tmp_path / "acceptance.json" + report = {"phases": {}} + + def fail(): + assert json.loads(path.read_text())["phases"]["native"]["state"] == "running" + raise RuntimeError("uncertain outcome") + + with pytest.raises(RuntimeError, match="uncertain"): + RUNNER["phase"](report, path, "native", fail) + assert json.loads(path.read_text())["state"] == "failed" + + +def test_dependency_pins_checked_not_only_pip_compatibility(tmp_path): + lock = tmp_path / "requirements.lock" + lock.write_text("mcp==1.0 \\\n --hash=sha256:abc\n") + assert RUNNER["verify_dependencies"](lock, lambda name: "1.0") == {"mcp": "1.0"} + with pytest.raises(RuntimeError, match="dependency mismatch"): + RUNNER["verify_dependencies"](lock, lambda name: "2.0") + + +@pytest.mark.parametrize("path", ["../file", "/file", "C:/file", "folder\\file"]) +def test_package_paths_cannot_escape(path): + with pytest.raises(RuntimeError, match="Unsafe"): + RUNNER["safe_name"](path) + + +def test_native_exit_zero_still_requires_preservation_evidence(tmp_path, monkeypatch): + (tmp_path / "native").mkdir() + receipt = tmp_path / "native/release-validation.json" + receipt.write_text(json.dumps({"passed": True, "session_restored": False})) + monkeypatch.setattr( + RUNNER["run_native"].__globals__["subprocess"], + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0), + ) + with pytest.raises(RuntimeError, match="session_restored evidence"): + RUNNER["run_native"](tmp_path, tmp_path, 179) + receipt.write_text(json.dumps({"passed": True, "session_restored": True, "suites": []})) + result = RUNNER["run_native"](tmp_path, tmp_path, 179) + assert result["sha256"] == RUNNER["digest"](receipt.read_bytes()) + + +def test_native_failure_still_checks_original_session(tmp_path, monkeypatch): + execute = RUNNER["execute"] + namespace = execute.__globals__ + snapshot = {"nx_version": "v2606", "tool_count": 179} + verified = [] + + async def read_snapshot(): + return snapshot + + monkeypatch.setattr( + namespace["importlib"].util, + "find_spec", + lambda name: SimpleNamespace(origin=str(tmp_path / "venv/nx_mcp/__init__.py")), + ) + monkeypatch.setitem(namespace, "verify_package", lambda *args: {}) + monkeypatch.setitem(namespace, "verify_dependencies", lambda *args: {}) + monkeypatch.setattr( + namespace["subprocess"], + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="ok"), + ) + monkeypatch.setattr( + namespace["runpy"], + "run_path", + lambda *args: { + "snapshot": read_snapshot, + "verify_session": lambda a, b: verified.append((a, b)), + }, + ) + + def fail_native(*args): + raise RuntimeError("native test failed") + + monkeypatch.setitem(namespace, "run_native", fail_native) + args = SimpleNamespace( + install_root=tmp_path, + release_zip=tmp_path / "r.zip", + sha256="a", + expected_commit="b", + expected_tool_count=179, + expected_nx_version="v2606", + verify_only=False, + output=tmp_path, + ) + report = {"phases": {}} + with pytest.raises(RuntimeError, match="native test failed"): + execute(args, tmp_path / "acceptance.json", report) + assert verified == [(snapshot, snapshot)] + assert report["state"] == "failed" + assert report["phases"]["session_preservation"]["state"] == "passed" + + +def test_cli_accepts_new_shared_drive_output_when_canonical_resolution_fails(tmp_path, monkeypatch): + """Reproduce WinError 1005 without requiring a Windows VirtIO mount.""" + main = RUNNER["main"] + namespace = main.__globals__ + install = tmp_path / "install" + install.mkdir() + fixture = tmp_path / "authorized.step" + fixture.write_text("fixture") + archive = tmp_path / "release.zip" + archive.write_text("fixture") + output = tmp_path / "new-parent/acceptance" + error = OSError("The volume does not contain a recognized file system") + error.winerror = 1005 + + def unsupported_resolve(*args, **kwargs): + raise error + + monkeypatch.setattr(Path, "resolve", unsupported_resolve) + monkeypatch.setattr(namespace["sys"], "platform", "win32") + monkeypatch.setattr(namespace["sys"], "version_info", (3, 12, 0)) + monkeypatch.setattr( + namespace["sys"], + "argv", + [ + "accept_release.py", + "--release-zip", + str(archive), + "--sha256", + "a" * 64, + "--expected-commit", + "b" * 40, + "--install-root", + str(install), + "--output", + str(output), + "--verify-only", + ], + ) + monkeypatch.setenv("NX_MCP_URL", "http://127.0.0.1:8765/mcp") + monkeypatch.setenv("NX_VENDOR_STEP", str(fixture)) + calls = [] + monkeypatch.setitem(namespace, "execute", lambda args, receipt, report: calls.append(report)) + main() + assert len(calls) == 1 + assert (output / "acceptance.json").is_file() + assert calls[0]["identity"]["vendor_step"] == str(fixture) + assert calls[0]["identity"]["install_root"] == str(install) + assert calls[0]["identity"]["release_zip"] == str(archive) + assert not (install / "acceptance.lock").exists() diff --git a/tests/test_release_engineering.py b/tests/test_release_engineering.py new file mode 100644 index 0000000..c9ea3cb --- /dev/null +++ b/tests/test_release_engineering.py @@ -0,0 +1,310 @@ +"""Preflight, ownership and verification failures for native release engineering.""" + +from types import SimpleNamespace as NS +from unittest.mock import MagicMock + +import pytest + +from nx_mcp.release_engineering import sheet_point +from nx_mcp.runtime import NXToolError +from tests.test_freeform_manufacturing import ff as _fixture + + +@pytest.fixture +def ff(rig, monkeypatch): + value = _fixture.__wrapped__(rig, monkeypatch) + value.part.Annotations = MagicMock() + value.part.DraftingViews = MagicMock() + value.part.Dimensions = [] + return value + + +@pytest.mark.parametrize("value", [None, [], [1], [1, 2, 3], [float("nan"), 2], ["x", 2]]) +def test_invalid_sheet_point(value): + with pytest.raises((NXToolError, ValueError)): + sheet_point(value) + + +def test_view_edit_preflight_and_native_readback(ff): + obj = NS(Tag=2) + ff.e._drawing_object = lambda *_: obj + sheet = MagicMock() + ff.e._view_sheet = lambda _: sheet + ff.uf.Draw = MagicMock() + with pytest.raises(NXToolError): + ff.e._edit_drawing_view("view") + with pytest.raises(NXToolError): + ff.e._edit_drawing_view("view", scale=-1) + ff.uf.Draw.SetViewScale.assert_not_called() + ff.e._drawing_view_info = lambda _: {"scale": 2, "position": [5, 6], "object": {"id": "view"}} + assert ff.e._edit_drawing_view("view", position=[5, 6], scale=2)["modified"] == [{"id": "view"}] + with pytest.raises(NXToolError, match="alignment or scale"): + ff.e._edit_drawing_view("view", position=[7, 8]) + + +def test_section_rejects_nonplanar_or_parallel_directions(ff): + for step, arrow in [([1, 0, 0], [1, 0, 0]), ([0, 0, 1], [1, 0, 0])]: + with pytest.raises(NXToolError, match="perpendicular"): + ff.e._add_section_drawing_view("view", "edge", [10, 20], step, arrow) + ff.uf.Draw.CreateSimpleSxview.assert_not_called() + + +@pytest.mark.parametrize( + "anchor", + [{}, {"version": 2}, {"version": 1, "owner_part": "part", "kind": "component", "handle": "h"}], +) +def test_anchor_schema_rejects_foreign_kinds(ff, anchor): + with pytest.raises(NXToolError): + ff.e._resolve_geometry_anchor(anchor) + ff.uf.Tag.AskTagOfHandle.assert_not_called() + + +def test_anchor_owner_and_missing_entity_fail_closed(ff): + part = ff.e._work_part() + anchor = {"version": 1, "owner_part": part.FullPath, "kind": "body", "handle": "native"} + with pytest.raises(NXToolError, match="another part"): + ff.e._resolve_geometry_anchor({**anchor, "owner_part": "wrong"}) + ff.uf.Tag.AskTagOfHandle.return_value = 912345 + with pytest.raises(NXToolError, match="no longer exists"): + ff.e._resolve_geometry_anchor(anchor) + body = list(part.Bodies)[0] + ff.uf.Tag.AskTagOfHandle.return_value = body.Tag + assert ff.e._resolve_geometry_anchor(anchor)["object"]["kind"] == "body" + + +@pytest.mark.parametrize( + "overrides", + [ + {"kind": "approval"}, + {"rows": []}, + {"rows": [["one"]]}, + {"widths": [-1, 20]}, + {"rows": [[1, "two"]]}, + {"rows": [["x" * 1025, "two"]]}, + {"row_height": 0}, + ], +) +def test_table_preflight_prevents_partial_authoring(ff, overrides): + args = { + "drawing": "sheet", + "kind": "revision", + "rows": [["A", "Initial"]], + "widths": [10, 20], + "position": [0, 0], + } + with pytest.raises(NXToolError): + ff.e._drawing_table(**{**args, **overrides}) + ff.part.Annotations.TableSections.CreateTableSectionBuilder.assert_not_called() + + +@pytest.fixture +def drawing(ff, monkeypatch): + import sys + + from tests.fakes import Object, point + + d = NS( + DrawingSheet=NS(Unit=NS(Millimeters=2, Inches=1)), + DetailViewBuilder=NS(Types=NS(Circular=1)), + ViewScaleBuilder=NS(Type=NS(Ratio=1)), + ) + monkeypatch.setitem(sys.modules, "NXOpen.Drawings", d) + ff.nx.Drawings = d + ff.nx.NXObject = NS(AttributeType=NS(String=1)) + ff.uf.Tag.AskHandleFromTag.side_effect = lambda tag: str(tag) + sheet = Object("Sheet") + sheet.Length, sheet.Height, sheet.Units = 297, 210, 2 + sheet.Open = MagicMock() + view = Object("Top") + view.OwningPart = ff.part + view.GetDrawingReferencePoint = lambda: point(100, 90, 0) + view.Matrix = NS(Xx=1, Xy=0, Xz=0) + view.HasUserAttribute = MagicMock(return_value=False) + view.SetAttribute = MagicMock() + view.UpdateAutomaticViewBound = MagicMock() + import contextlib + + ff.e._drawing_save_context = lambda *a, **k: contextlib.nullcontext() + sheet.GetDraftingViews = lambda: [view] + ff.part.DrawingSheets = [sheet] + ff.e._drawing_object = lambda ref, kind: sheet if kind == "drawing_sheet" else view + ff.uf.Draw.AskViewBorders.return_value = [60, 70, 140, 110] + ff.uf.Draw.AskViewScale.return_value = (0, 1.0) + ff.uf.View.MapModelToDrawing.side_effect = lambda _, p: [p[0] + 100, p[1] + 90] + ff.part.SettingsManager = MagicMock() + style = ff.part.SettingsManager.CreateDrawingEditViewSettingsBuilder.return_value.ViewStyle + from nx_mcp.drawing_preferences import STYLE_PROPERTIES + + for key, (group, prop) in STYLE_PROPERTIES.items(): + setattr(getattr(style, group), prop, 1 if key.endswith(("font", "width")) else True) + ff.sheet, ff.view = sheet, view + return ff + + +def test_sheet_units_and_physical_point_conversion(drawing): + f = drawing + assert f.e._drawing_view_info("v")["inside_sheet"] + f.sheet.Units = 1 + assert f.e._sheet_units(f.sheet) == "in" + result = f.e._sheet_point3d(f.sheet, [1, 2]) + assert (result.X, result.Y) == (25.4, 50.8) + assert f.e._drawing_coordinates(f.sheet, [1, 2])["position_mm"] == [25.4, 50.8] + f.part.PartUnits = "inch" + f.sheet.Units = 2 + assert pytest.approx(1) == f.e._sheet_point3d(f.sheet, [25.4, 50.8]).X + f.sheet.Units = 912 + with pytest.raises(NXToolError, match="Unknown native"): + f.e._sheet_units(f.sheet) + + +def test_view_ownership_and_native_placement_verification(drawing): + f = drawing + f.e._place_drawing_view(f.view, f.sheet, [100, 90]) + with pytest.raises(NXToolError, match="placement differs"): + f.e._place_drawing_view(f.view, f.sheet, [100, 91]) + f.sheet.GetDraftingViews = lambda: [] + with pytest.raises(NXToolError, match="one work-part sheet"): + f.e._view_sheet(f.view) + + +def test_detail_maps_model_boundary_to_sheet_and_releases_builder(drawing): + f = drawing + b = MagicMock() + b.Commit.return_value = f.view + f.part.DraftingViews.CreateDetailViewBuilder.return_value = b + f.e._edit_drawing_view = lambda *a, **k: {"scale": k["scale"]} + assert f.e._add_detail_drawing_view("v", [0, 0, 0], 5, [100, 100], 3)["scale"] == 3 + calls = f.part.Points.CreatePoint.call_args_list + assert calls[0].args[0].X == 100 and calls[1].args[0].X == 105 + b.Destroy.assert_called_once() + b.Validate.return_value = False + with pytest.raises(NXToolError, match="did not validate"): + f.e._add_detail_drawing_view("v", [0, 0, 0], 5, [100, 100]) + assert b.Destroy.call_count == 2 + + +def test_section_native_associativity_and_explicit_scale(drawing): + f = drawing + f.nx.UF.Drf = NS(Object=lambda: NS(), AssocType=NS(ARC_CENTER=2, END_POINT=1)) + f.nx.TaggedObjectManager = NS(GetTaggedObject=lambda _: f.view) + f.e._edit_drawing_view = lambda *a, **k: {"scale": k["scale"]} + face_ref = f.ref(f.body.GetEdges()[0], "edge") + for assoc, expected, modifier in [("start", 1, 1), ("end", 1, 2), ("arc_center", 2, 0)]: + result = f.e._add_section_drawing_view( + "v", face_ref, [100, 100], [1, 0, 0], [0, 1, 0], 2, assoc + ) + assert result["scale"] == 2 + arg = f.uf.Draw.CreateSimpleSxview.call_args.args[5] + assert (arg.ObjectAssocType, arg.ObjectAssocModifier) == (expected, modifier) + with pytest.raises(NXToolError, match="cut_association"): + f.e._add_section_drawing_view("v", face_ref, [100, 100], [1, 0, 0], [0, 1, 0], 2, "bad") + + +def test_detail_refresh_requires_surviving_parent(drawing): + import json + + f = drawing + f.view.HasUserAttribute.return_value = True + f.view.GetStringAttribute = lambda _: json.dumps( + {"parent": "handle", "center": [0, 0, 0], "radius": 5} + ) + f.uf.Tag.AskTagOfHandle.return_value = f.view.Tag + f.e._refresh_detail_boundaries() + f.part.DraftingViews.CreateDetailViewBuilder.return_value.Commit.assert_called_once() + f.uf.Tag.AskTagOfHandle.return_value = -100 + with pytest.raises(NXToolError, match="parent no longer"): + f.e._refresh_detail_boundaries() + + +def test_table_edits_preserve_native_identity_and_evaluated_cells(drawing): + from tests.fakes import Object + + f = drawing + section = Object("Revision") + section.OwningPart, section.IsOccurrence = f.part, False + attrs = {} + section.SetAttribute = lambda k, v: attrs.__setitem__(k, v) + section.HasUserAttribute = lambda k, *_: k in attrs + section.GetStringAttribute = lambda k: attrs[k] + sheet_attrs = {} + sheet = f.e._drawing_object("s", "drawing_sheet") + sheet.SetAttribute = lambda k, v: sheet_attrs.__setitem__(k, v) + sheet.HasUserAttribute = lambda k, *_: k in sheet_attrs + sheet.GetStringAttribute = lambda k: sheet_attrs[k] + section.AnnotationOrigin = None + b = f.part.Annotations.TableSections.CreateTableSectionBuilder.return_value + b.Commit.return_value = section + f.uf.Tag.AskHandleFromTag.side_effect = lambda t: str(t) + tab = f.uf.Tabnot + data = [["", ""]] + tab.AskNmColumns.return_value = 2 + tab.AskNmRows.side_effect = lambda _: len(data) + tab.AskNthRow.side_effect = lambda _, i: i + tab.AskNthColumn.side_effect = lambda _, i: i + tab.AskCellAtRowCol.side_effect = lambda r, c: (r, c) + tab.SetCellText.side_effect = lambda cell, v: data[cell[0]].__setitem__(cell[1], v) + tab.AskEvaluatedCellText.side_effect = lambda cell: data[cell[0]][cell[1]] + tab.AddRow.side_effect = lambda *_: data.append(["", ""]) + tab.RemoveRow.side_effect = lambda i: data.pop(i) + args = { + "drawing": "s", + "kind": "revision", + "rows": [["REV", "TEXT"], ["A", "Initial"]], + "widths": [20, 70], + "position": [20, 250], + } + result = f.e._drawing_table(**args) + assert result["rows"] == args["rows"] + ref = result["table"]["id"] + args["rows"] = [["B", "Changed"]] + assert f.e._drawing_table(**args, table=ref)["table"]["id"] == ref + assert data == [["B", "Changed"]] + args["kind"] = "title_block" + with pytest.raises(NXToolError, match="same kind"): + f.e._drawing_table(**args, table=ref) + assert b.Commit.call_count == 1 + + +def test_assembly_refresh_unsatisfied_constraints_releases_network(drawing, monkeypatch): + import sys + + f = drawing + monkeypatch.setitem( + sys.modules, "NXOpen.Positioning", NS(Constraint=NS(SolverStatus=NS(Solved=1))) + ) + f.e._explosion_context = lambda: f.part + constraint = NS(GetConstraintStatus=lambda: 2) + f.e._assembly_constraints = lambda _: [constraint] + positioner = MagicMock() + f.part.ComponentAssembly.Positioner = positioner + with pytest.raises(NXToolError, match="did not solve"): + f.e._update_assembly_documentation() + positioner.ClearNetwork.assert_called_once() + positioner.EndAssemblyConstraints.assert_called_once() + f.part.Annotations.PartsLists = [] + f.e._assembly_constraints = lambda _: [] + f.e._explosions = lambda _: [] + f.e._refresh_annotations = lambda: {"updated_count": 0} + result = f.e._update_assembly_documentation() + assert result["health"]["healthy"] and len(result["views"]) == 1 + + +def test_native_mass_unit_is_explicit(ff): + props = NS(Volume=8193.532, Dispose=MagicMock()) + ff.part.MeasureManager.NewMassProperties = lambda *a: props + result = ff.e._measure_volume() + assert props.InformationUnit == ff.nx.MeasureBodies.AnalysisUnit.KilogramMillimeter + assert result["volume_mm3"] == 8193.532 + props.Dispose.assert_called_once() + + +def test_retained_dimensions_are_not_valid_measurements(drawing): + from tests.fakes import Object, point + + d = Object("Retained dimension") + d.ComputedSize, d.IsRetained, d.AnnotationOrigin = 8, True, point(0, 0, 0) + drawing.part.Dimensions = [d] + result = drawing.e._list_dimensions()["dimensions"][0] + assert result["retained"] and not result["measurement_valid"] + d.IsRetained = False + assert drawing.e._list_dimensions()["dimensions"][0]["measurement_valid"] diff --git a/tests/test_result_transport.py b/tests/test_result_transport.py new file mode 100644 index 0000000..e48e332 --- /dev/null +++ b/tests/test_result_transport.py @@ -0,0 +1,49 @@ +"""Committed results remain retrievable when their payload exceeds bridge framing.""" + +import pytest + +from nx_mcp.bridge import BridgeClient, BridgeServer +from nx_mcp.result_transport import bound_result, read_result + + +def test_large_result_pages_preserve_requested_cardinality(tmp_path): + full = { + "status": "success", + "operation_id": "large-operation", + "mutation_outcome": "committed", + "restore_id": "display_test", + "objects": [{"id": str(i), "data": "x" * 2000} for i in range(2032)], + } + bounded = bound_result(full, tmp_path) + assert bounded["mutation_outcome"] == "committed" and bounded["restore_id"] == "display_test" + key = bounded["full_result"]["id"] + page = read_result(tmp_path, key, "/objects", offset=100, limit=20) + assert len(page["value"]) == 20 and page["next_offset"] == 120 and page["total_count"] == 2032 + assert page["value"][0]["id"] == "100" + + +@pytest.mark.asyncio +async def test_bridge_bounds_large_native_commit(tmp_path): + calls = [] + + def execute(method, params): + calls.append(method) + return { + "status": "success", + "mutation_outcome": "committed", + "operation_id": "large-operation", + "objects": ["x" * 2048] * 2032, + } + + server = BridgeServer(execute, token="test", result_directory=tmp_path) + server.start() + try: + client = BridgeClient("127.0.0.1", server.port, token="test") + result = await client.call("mutate", {}) + assert ( + result["mutation_outcome"] == "committed" + and result["full_result"]["tool"] == "nx_read_result" + ) + assert calls == ["mutate"] + finally: + server.stop() diff --git a/tests/test_save_audit.py b/tests/test_save_audit.py new file mode 100644 index 0000000..b83acae --- /dev/null +++ b/tests/test_save_audit.py @@ -0,0 +1,80 @@ +"""Disk and native-state regressions for explicitly scoped saves.""" + +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp.runtime import NXToolError +from nx_mcp.save_audit import snapshot +from tests.fakes import Part + + +def child(rig, tmp_path): + original = rig.part + p = Part(rig.session, tmp_path / "child.prt") + p.Save() + p.IsModified = True + rig.session.Parts.Work = rig.session.Parts.Display = original + return p + + +def test_receipt_identifies_saved_file_and_preserved_child(rig, tmp_path): + p = child(rig, tmp_path) + before = snapshot(rig.session)[p.Tag] + result = rig.e._save_part() + assert result["saved_files"] == [rig.part.FullPath] + assert result["unrelated_modified_parts"] == [ + {"path": p.FullPath, "modified_before": True, "modified_after": True, "unchanged": True} + ] + assert snapshot(rig.session)[p.Tag] == before + assert result["target_file"]["after"]["sha256"] + + +@pytest.mark.parametrize("change", ["flag", "disk"]) +def test_unexpected_child_change_is_partial_with_evidence(rig, tmp_path, change): + from pathlib import Path + + p = child(rig, tmp_path) + save = rig.part.Save + + def faulty_save(*args): + if change == "flag": + p.IsModified = False + else: + Path(p.FullPath).write_bytes(b"unintended write") + return save(*args) + + rig.part.Save = faulty_save + with pytest.raises(NXToolError) as error: + rig.e._save_part() + assert error.value.details["mutation_outcome"] == "partial" + assert error.value.details["saved_files"] == [rig.part.FullPath] + assert error.value.details["unexpected_changes"][0]["path"] == p.FullPath + + +def test_native_unsaved_status_is_not_reported_as_success(rig): + status = NS( + NumberUnsavedParts=1, + NumberUnsavedObjects=0, + GetPart=lambda i: rig.part, + GetStatus=lambda i: 123, + Dispose=Mock(), + ) + rig.part.Save = Mock(return_value=status) + with pytest.raises(NXToolError) as error: + rig.e._save_part() + assert error.value.details["native_save_errors"] == [ + {"path": rig.part.FullPath, "nx_code": 123} + ] + assert not error.value.details["saved_files"] + status.Dispose.assert_called_once() + + +def test_preflight_read_failure_prevents_save(rig, monkeypatch): + monkeypatch.setattr("nx_mcp.save_audit.snapshot", Mock(side_effect=PermissionError("denied"))) + rig.part.Save = Mock() + with pytest.raises(NXToolError) as error: + rig.e._save_part() + assert error.value.details["mutation_outcome"] == "not_started" + rig.part.Save.assert_not_called() diff --git a/tests/test_sheet_metal.py b/tests/test_sheet_metal.py new file mode 100644 index 0000000..25ae641 --- /dev/null +++ b/tests/test_sheet_metal.py @@ -0,0 +1,770 @@ +"""Sheet-metal contracts and failure recovery; these are not NX kernel tests.""" + +import inspect +import json +import sys +from pathlib import Path +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp import sheet_metal_server +from nx_mcp.runtime import NXToolError +from nx_mcp.sheet_metal import APPLICATION, CATALOG, field_schema +from tests.fakes import Body, Edge, Face, Feature, Object, Sketch + +pytestmark = pytest.mark.fake_nx + + +@pytest.fixture +def sm(rig, monkeypatch): + r = rig + r.session.ApplicationName = APPLICATION + r.session.ApplicationSwitchImmediate = Mock( + side_effect=lambda name: setattr(r.session, "ApplicationName", name) + ) + module = NS( + ApplicationContext=NS(NxSheetMetal=1), + SheetmetalBendState=NS(Bent=1, Flat=2), + Tab=Feature, + MultiFlange=Feature, + FlatPattern=Feature, + ) + r.nx.Features.SheetMetal = module + monkeypatch.setitem(sys.modules, "NXOpen.Features", r.nx.Features) + monkeypatch.setitem(sys.modules, "NXOpen.Features.SheetMetal", module) + r.sm = module + r.nx.ObjectList = NS(DeleteOption=NS(Delete="delete")) + r.part.Features.SheetmetalManager = NS() + r.owned = [] + + def own(obj, kind): + obj.IsOccurrence = False + if isinstance(obj, Sketch): + obj.Feature = Feature() + obj.OwningPart = r.part + r.owned.append(obj) + return r.ref(obj, kind) + + r.own = own + return r + + +def test_public_signatures_match_executor_and_schema(sm): + for name, fn in vars(sheet_metal_server).items(): + if name.startswith("nx_") and inspect.isfunction(fn): + native = sm.e._handlers[name] + assert set(inspect.signature(fn).parameters) == set( + inspect.signature(native).parameters + ) + schemas = sm.e._sheet_metal_schema() + assert len(schemas["operations"]) == len(CATALOG) + for operation, spec in CATALOG.items(): + result = sm.e._sheet_metal_schema(operation) + schema = result["parameters_schema"] + assert set(schema["required"]) <= set(schema["properties"]) + assert not schema["additionalProperties"] + assert result["validation_status"] == spec["native_status"] + with pytest.raises(NXToolError, match="Unknown"): + sm.e._sheet_metal_schema("__dict__") + + +def test_context_is_explicit_and_rejects_active_sketch(sm): + sm.session.ApplicationName = "UG_APP_MODELING" + with pytest.raises(NXToolError, match="context"): + sm.e._sm_require_context() + sm.session.ApplicationSwitchImmediate.assert_not_called() + result = sm.e.execute("nx_sheet_metal_context", {}) + assert result["application"] == APPLICATION + assert not sm.session.marks + sm.session.ActiveSketch = object() + with pytest.raises(NXToolError, match="Finish"): + sm.e._sm_prepare() + with pytest.raises(NXToolError, match="Finish"): + sm.e._sm_require_context() + sm.session.ActiveSketch = None + sm.session.Parts.Display = None + with pytest.raises(NXToolError, match="work and display"): + sm.e._sm_prepare() + + +def test_context_failure_and_batch(sm): + sm.session.ApplicationName = "UG_APP_MODELING" + sm.session.ApplicationSwitchImmediate.side_effect = None + with pytest.raises(NXToolError, match="did not enter"): + sm.e._sm_prepare() + sm.session.IsBatch = True + assert sm.e._sheet_metal_context()["application"] == "batch" + sm.e._sm_require_context() + + +@pytest.mark.parametrize( + "params", + [ + {}, + {"section": "missing", "thickness": 2, "ignored": 1}, + {"section": "missing", "thickness": float("nan")}, + {"thickness": -1}, + {"thickness": 0}, + {"thickness": True}, + ], +) +def test_bad_inputs_do_not_construct_native_builder(sm, params): + factory = Mock() + sm.part.Features.SheetmetalManager.CreateTabFeatureBuilder = factory + with pytest.raises(NXToolError): + sm.e.execute("nx_sheet_metal_feature", {"operation": "tab", "parameters": params}) + factory.assert_not_called() + assert not list(sm.part.Bodies) + + +@pytest.mark.parametrize( + "kind,value", + [ + ("integer", True), + ("boolean", 1), + ("enum", "ValueOf"), + ("string", "bad\nname"), + ("point3d", [1, 2]), + ("direction", [0, 0, 0]), + ("flange_list", []), + ("face_pairs", [["same", "same"]]), + ("collector", []), + ("csys", {"origin": [0, 0, 0], "x_axis": [1, 0, 0], "y_axis": [1, 0, 0]}), + ("plane", {"origin": [0, 0, 0]}), + ], +) +def test_strict_nested_validation(sm, kind, value): + field = {"kind": kind, "values": ["Valid"], "fields": {}, "objects": "edge"} + with pytest.raises(NXToolError): + sm.e._sm_validate({"input": field}, {"input": value}) + + +def test_owned_typed_references_and_normalized_basis(sm): + face = Face() + ref = sm.own(face, "face") + assert sm.e._sm_reference(ref, "face_or_edge") is face + face.IsOccurrence = True + with pytest.raises(NXToolError, match="work part"): + sm.e._sm_reference(ref, "face") + with pytest.raises(NXToolError, match="typed"): + sm.e._sm_reference(12, "face") + edge = sm.own(Edge(), "edge") + with pytest.raises(NXToolError, match="unique"): + sm.e._sm_validate( + {"edges": {"kind": "collector", "objects": "edge"}}, {"edges": [edge, edge]} + ) + sk = Sketch(sm.session) + sid = sm.own(sk, "sketch") + sm.session.ActiveSketch = sk + with pytest.raises(NXToolError, match="Finish"): + sm.e._sm_reference(sid, "sketch") + values = sm.e._sm_validate( + {"plane": {"kind": "plane"}}, {"plane": {"origin": [1, 2, 3], "normal": [0, 0, 2]}} + ) + assert values["plane"]["normal"] == [0, 0, 1] + + +def test_face_pair_edit_replaces_old_pairs(sm): + old = [(Face(), Face()), (Face(), Face())] + pairs = list(old) + builder = NS( + GetNumberOfFacePairs=lambda: len(pairs), + GetFacePair=lambda i: pairs[i], + RemoveFacePair=lambda a, b: pairs.remove((a, b)), + AddFacePair=lambda a, b: pairs.append((a, b)), + ) + new = (Face(), Face()) + sm.e._sm_apply(builder, {"pairs": {"kind": "face_pairs"}}, {"pairs": [new]}) + assert pairs == [new] + + +def test_flange_list_uses_dedicated_factory_and_owns_failed_entry(sm): + entries = [] + sequence = NS(Clear=Mock(side_effect=lambda _: entries.clear()), Append=entries.append) + item = NS(Length=NS(RightHandSide="1")) + factory = Mock(return_value=item) + b = NS( + FlangePropertiesList=NS( + FeatureBendPropertiesList=sequence, CreateFlangeBendProperties=factory + ) + ) + fields = { + "flanges": { + "kind": "flange_list", + "fields": {"length": {"kind": "expression", "path": "Length"}}, + } + } + sm.e._sm_apply(b, fields, {"flanges": [{"length": 20}]}) + assert entries == [item] and item.Length.RightHandSide == "20" + sequence.Clear.assert_called_once_with("delete") + fields["flanges"]["fields"]["bad"] = {"kind": "expression", "path": "Missing"} + with pytest.raises(AttributeError): + sm.e._sm_apply(b, fields, {"flanges": [{"bad": 1}]}) + assert entries == [item] # Destroy of the parent builder can now clean it up. + + +def make_tab_builder(sm, fail=False): + b = NS( + Thickness=NS(RightHandSide="1"), + Section=None, + SetApplicationContext=Mock(), + Validate=Mock(return_value=True), + Destroy=Mock(), + ) + + def commit(): + body = Body() + sm.part.Bodies.append(body) + if fail: + raise RuntimeError("native partial failure") + f = Feature(bodies=[body]) + f.FeatureType = "Base Tab" + f.expressions = [] + sm.part.Features.append(f) + return f + + b.CommitFeature = Mock(side_effect=commit) + sm.part.Features.SheetmetalManager.CreateTabFeatureBuilder = Mock(return_value=b) + sm.e._engineering_section = Mock(return_value=object()) + sm.e._update_model = Mock() + return b + + +def test_native_failure_rolls_back_partial_body_and_disposes_builder(sm): + b = make_tab_builder(sm, fail=True) + sid = sm.own(Sketch(sm.session), "sketch") + with pytest.raises(NXToolError, match="native partial failure"): + sm.e.execute( + "nx_sheet_metal_feature", + { + "operation": "tab", + "parameters": {"section": sid, "thickness": 2}, + "operation_id": "sm-failure", + }, + ) + assert len(sm.part.Bodies) == 0 + b.Destroy.assert_called_once() + receipt = sm.e.store.get("sm-failure") + assert receipt["mutation_outcome"] == "rolled_back" + + +def test_native_creation_and_idempotent_retry(sm): + b = make_tab_builder(sm) + sid = sm.own(Sketch(sm.session), "sketch") + params = { + "operation": "tab", + "parameters": {"section": sid, "thickness": 2}, + "operation_id": "sm-once-01", + } + result = sm.e.execute("nx_sheet_metal_feature", params) + assert result["body_count"] == 1 and result["native_feature_type"] == "Base Tab" + assert result["requested_parameters"]["thickness"] == 2 + assert sm.e.execute("nx_sheet_metal_feature", params)["replayed"] + b.CommitFeature.assert_called_once() + + +@pytest.mark.parametrize("failure", ["commit", "destroy", "invalid", "empty"]) +def test_export_failure_removes_staging_and_preserves_existing(sm, tmp_path, failure): + obj = Feature() + obj.FeatureType = "FLAT_PATTERN" + feature = sm.own(obj, "feature") + b = NS(FlatPattern=NS(Value=None)) + + def commit(): + Path(b.OutputFile).write_bytes( + b"" if failure == "empty" else b"invalid" if failure == "invalid" else b"0\nSECTION\n" + ) + if failure == "commit": + raise RuntimeError("export failed") + + def destroy(): + if failure == "destroy": + raise RuntimeError("destroy failed") + + b.Commit, b.Destroy = commit, Mock(side_effect=destroy) + sm.part.Features.SheetmetalManager.CreateExportFlatPatternBuilder = lambda: b + sm.sm.ExportFlatPatternBuilder = NS( + DxfRevisionType=NS(R2018=2018), + FileType=NS(Dxf=1, TrumpfGeo=2), + ExportLocationOptions=NS(Native=1), + ) + with pytest.raises((NXToolError, RuntimeError)): + sm.e._export_flat_pattern(feature, "new.dxf") + assert not (tmp_path / "new.dxf").exists() + assert not list(tmp_path.glob(".nx-export-*")) + (tmp_path / "existing.dxf").write_text("preserve") + with pytest.raises(NXToolError): + sm.e._export_flat_pattern(feature, "existing.dxf") + assert (tmp_path / "existing.dxf").read_text() == "preserve" + + +def test_schema_primitives_and_required_lists(): + for kind in [ + "expression", + "number", + "boolean", + "integer", + "string", + "point", + "point3d", + "direction", + "plane", + "csys", + "face_pairs", + "collector", + "reference_list", + "select_faces", + "select_edges", + "select_bodies", + "face", + "section", + ]: + schema = field_schema({"kind": kind}) + assert "type" in schema or "oneOf" in schema + assert field_schema(CATALOG["flange"]["fields"]["flanges"])["items"]["required"] == [ + "edges", + "length", + "angle", + ] + assert field_schema(CATALOG["joggle"]["fields"]["inputs"])["items"]["required"] == [ + "faces", + "depth", + ] + + +def test_standard_validation_rejects_before_commit(sm): + b = make_tab_builder(sm) + b.Validate.return_value = False + sid = sm.own(Sketch(sm.session), "sketch") + with pytest.raises(NXToolError, match="validation failed"): + sm.e.execute( + "nx_sheet_metal_feature", + {"operation": "tab", "parameters": {"section": sid, "thickness": 2}}, + ) + b.CommitFeature.assert_not_called() + b.Destroy.assert_called_once() + + +def test_legacy_validator_is_not_used_for_valid_tab(sm): + b = make_tab_builder(sm) + b.ValidateBuilderData = Mock(side_effect=RuntimeError("Unreliable NX 2606 method")) + sid = sm.own(Sketch(sm.session), "sketch") + result = sm.e.execute( + "nx_sheet_metal_feature", + {"operation": "tab", "parameters": {"section": sid, "thickness": 2}}, + ) + assert result["body_count"] == 1 + b.ValidateBuilderData.assert_not_called() + assert not hasattr(b, "Sketch") # External sketch sections are not consumed internally. + + +def test_edit_preserves_application_context(sm): + b = make_tab_builder(sm) + b.GetApplicationContext = Mock(return_value=1) + f = Feature() + f.FeatureType = "Base Tab" + fid = sm.own(f, "feature") + sm.e.execute( + "nx_sheet_metal_feature", + {"operation": "tab", "feature": fid, "parameters": {"thickness": 3}}, + ) + b.SetApplicationContext.assert_not_called() + b.GetApplicationContext.assert_called_once() + + +@pytest.fixture +def preferences(sm, monkeypatch): + r = sm + modes = NS(Value=0, MaterialTable=1, ToolIdTable=2) + methods = NS( + NeutralFactorValue=0, + BendTable=1, + BendAllowanceFormula=2, + MaterialTable=3, + ToolTable=4, + BendAllowanceTable=5, + BendDeductionTable=6, + BendDeductionFormula=7, + Din6935Formula=8, + ) + pref = NS( + SheetMetalPreferencesBuilder=NS( + ParameterEntryTypes=modes, BendDefinitionMethodOptions=methods + ) + ) + r.nx.Preferences = pref + monkeypatch.setitem(sys.modules, "NXOpen.Preferences", pref) + values = { + key: NS(RightHandSide="2") + for key in [ + "MaterialThickness", + "BendRadius", + "NeutralFactor", + "BendReliefWidth", + "BendReliefDepth", + ] + } + values["NeutralFactor"].RightHandSide = ".33" + b = NS( + **values, + ParameterEntryType=0, + BendAllowanceFormula="", + BendDeductionFormula="", + Commit=Mock(), + Destroy=Mock(), + ) + state = {"material": "", "tool": "", "method": 0, "table": ""} + b.SetMaterial = lambda x: state.update(material=x) + b.SetToolName = lambda x: state.update(tool=x) + b.SetBendDefinitionMethod = lambda x: state.update(method=x) + b.SetBendTable = lambda x: state.update(table=x) + manager = NS( + **{"Get" + key: (lambda value=exp: value) for key, exp in values.items()}, + GetParameterEntryType=lambda: b.ParameterEntryType, + GetBendDefinitionMethod=lambda: state["method"], + GetMaterialName=lambda: state["material"], + GetToolName=lambda: state["tool"], + GetBendTable=lambda: state["table"], + GetBendAllowanceFormula=lambda: b.BendAllowanceFormula, + GetBendDeductionFormula=lambda: b.BendDeductionFormula, + GetMaterialNames=Mock(side_effect=RuntimeError("unsafe native catalog")), + CreateSheetMetalPreferencesBuilder=Mock(return_value=b), + ) + r.part.Preferences = NS(SheetMetalPreferences=manager) + r.e._expression_record = lambda exp: { + "value": float(exp.RightHandSide), + "formula": exp.RightHandSide, + } + r.e._update_model = Mock() + r.pref_builder, r.pref_manager = b, manager + return r + + +def test_defaults_are_read_back_without_unsafe_catalog_enumeration(preferences): + r = preferences + result = r.e._set_sheet_metal_defaults( + thickness=1.5, + bend_radius=3, + neutral_factor=0.4, + parameter_entry="Value", + bend_definition="NeutralFactorValue", + ) + assert result["parameters"]["thickness"]["value"] == 1.5 + assert result["parameters"]["bend_radius"]["value"] == 3 + assert result["parameters"]["neutral_factor"]["value"] == 0.4 + assert result["parameter_entry"] == "Value" + assert result["material_catalog_status"] == "unavailable" + r.pref_manager.GetMaterialNames.assert_not_called() + r.pref_builder.Destroy.assert_called_once() + + +@pytest.mark.parametrize( + "params", + [ + {}, + {"thickness": 0}, + {"neutral_factor": 2}, + {"bend_radius": -1}, + {"parameter_entry": "invalid"}, + {"bend_definition": "ValueOf"}, + {"material": ""}, + {"tool": "bad\nname"}, + {"bend_allowance_formula": ""}, + {"bend_deduction_formula": "x\ny"}, + {"bend_table": "missing.tbl"}, + ], +) +def test_default_preflight_constructs_no_builder(preferences, params): + r = preferences + with pytest.raises(NXToolError): + r.e._set_sheet_metal_defaults(**params) + r.pref_manager.CreateSheetMetalPreferencesBuilder.assert_not_called() + + +def test_silently_ignored_default_is_a_verification_failure(preferences): + r = preferences + r.pref_builder.Commit.side_effect = lambda: setattr( + r.pref_builder.MaterialThickness, "RightHandSide", "2" + ) + with pytest.raises(NXToolError, match="did not retain"): + r.e._set_sheet_metal_defaults(thickness=4) + r.pref_builder.Destroy.assert_called_once() + + +def test_material_and_bend_table_settings_are_verified(preferences, tmp_path): + r = preferences + table = tmp_path / "bend.tbl" + table.write_text("fixture") + result = r.e._set_sheet_metal_defaults( + parameter_entry="MaterialTable", + material="Aluminum", + tool="ToolA", + bend_definition="BendTable", + bend_table=str(table), + bend_allowance_formula="1", + bend_deduction_formula="2", + ) + assert result["material"] == "Aluminum" and result["tool"] == "ToolA" + assert result["bend_table"] == str(table) + r.pref_builder.SetMaterial = lambda _: None + with pytest.raises(NXToolError, match="did not retain requested material"): + r.e._set_sheet_metal_defaults(material="MissingMaterial") + + +def test_assignable_collector_is_created_before_binding_rules(sm): + collected = NS(ReplaceRules=Mock()) + sm.part.ScCollectors = NS(CreateCollector=Mock(return_value=collected)) + sm.part.ScRuleFactory = NS(CreateRuleFaceDumb=Mock(return_value="rule")) + builder = NS(FaceCollector=None) + faces = [Face()] + sm.e._sm_apply( + builder, + { + "faces": { + "kind": "collector", + "path": "FaceCollector", + "objects": "face", + "assign": True, + } + }, + {"faces": faces}, + ) + assert builder.FaceCollector is collected + collected.ReplaceRules.assert_called_once_with(["rule"], False) + sm.part.ScRuleFactory.CreateRuleFaceDumb.assert_called_once_with(faces) + + +def test_legacy_sheet_metal_builder_without_context_methods(sm): + b = make_tab_builder(sm) + del b.SetApplicationContext + sid = sm.own(Sketch(sm.session), "sketch") + result = sm.e.execute( + "nx_sheet_metal_feature", + {"operation": "tab", "parameters": {"section": sid, "thickness": 2}}, + ) + assert result["body_count"] == 1 + + +def test_assignable_section_is_not_read_before_initialization(sm, monkeypatch): + class NativeBuilder: + @property + def Section(self): + raise RuntimeError("Native getter fails before initialization") + + @Section.setter + def Section(self, section): + self.assigned = section + + builder = NativeBuilder() + sketch = Sketch(sm.session) + section = object() + monkeypatch.setattr(sm.e, "_engineering_section", lambda value: section) + sm.e._sm_apply( + builder, + {"section": {"kind": "section", "path": "Section", "assign": True}}, + {"section": sketch}, + ) + assert builder.assigned is section + + +def test_nested_catalog_required_fields_exist(): + def check(spec): + assert set(spec.get("required", [])) <= set(spec.get("fields", {})) + for field in spec.get("fields", {}).values(): + check(field) + + for spec in CATALOG.values(): + check(spec) + + +@pytest.fixture +def annotation_rig(sm, monkeypatch): + r = sm + module = NS(SheetMetalPMIBuilder=NS(Types=NS(Body="body", Bend="bend"))) + r.nx.Annotations = module + monkeypatch.setitem(sys.modules, "NXOpen.Annotations", module) + body = Body() + r.body_id = r.own(body, "body") + r.face = Face() + r.face.GetBody = lambda: body + r.face_id = r.own(r.face, "face") + r.note = Object("PMI") + r.note.OwningPart = r.part + r.note.GetText = lambda: list(r.lines) + r.lines = [] + r.builder = NS( + SelectedBody=NS(Value=None), + SelectedFace=NS(Clear=Mock(), Add=Mock()), + AssociatedObjects=NS(Nxobjects=NS(Clear=Mock(), Add=Mock())), + Text=NS(TextBlock=NS(SetText=lambda lines: setattr(r, "lines", list(lines)))), + Origin=NS(SetInferRelativeToGeometry=Mock(), Origin=NS(SetValue=Mock())), + Validate=Mock(return_value=True), + Commit=Mock(side_effect=lambda: r.part.Notes.append(r.note) or r.note), + GetCommittedObjects=Mock(return_value=[r.note]), + Destroy=Mock(), + ) + manager = r.part.Features.SheetmetalManager + manager.CreateSheetMetalPmiBuilder = Mock(return_value=r.builder) + manager.IsSheetmetalBody = lambda b: b is body + manager.GetBodyThickness = lambda b: 2.0 + manager.GetBendParameters = lambda f: NS(InnerRadius=3.0, BendAngle=90.0, NeutralFactor=0.33) + return r + + +def test_annotation_has_measured_text_and_geometry_association(annotation_rig): + r = annotation_rig + result = r.e.execute( + "nx_sheet_metal_annotation", + { + "kind": "bend", + "body": r.body_id, + "faces": [r.face_id], + "position": [10, 20, 30], + }, + ) + assert "90.000 deg" in result["text"][0][1] + assert result["measured_parameters"]["bends"][0]["inner_radius"] == 3 + assert "snapshot" in result["text_semantics"].lower() + r.builder.AssociatedObjects.Nxobjects.Add.assert_called_once_with([r.face]) + assert result["annotation_count"] == 1 + r.builder.Destroy.assert_called_once() + + +def test_partial_annotation_commit_rolls_back_and_destroys_builder(annotation_rig): + r = annotation_rig + + def fail(): + r.part.Notes.append(r.note) + raise RuntimeError("native annotation failure") + + r.builder.Commit.side_effect = fail + with pytest.raises(NXToolError) as error: + r.e.execute( + "nx_sheet_metal_annotation", + { + "kind": "body", + "body": r.body_id, + "position": [0, 0, 0], + }, + ) + assert error.value.details["mutation_outcome"] == "rolled_back" + assert not r.part.Notes + r.builder.Destroy.assert_called_once() + + +def test_explicit_edge_section_validates_ownership_before_builder(sm): + edge = Edge() + edge_id = sm.own(edge, "edge") + values = sm.e._sm_validate( + {"section": {"kind": "section"}}, + { + "section": {"edges": [edge_id], "help_point": [1, 2, 3]}, + }, + ) + assert values["section"]["edges"] == [edge] + for invalid in [ + {"edges": [edge_id]}, + {"edges": [edge_id], "curves": [edge_id], "help_point": [0, 0, 0]}, + {"curves": [edge_id], "help_point": [0, 0, 0]}, + ]: + with pytest.raises(NXToolError): + sm.e._sm_validate({"section": {"kind": "section"}}, {"section": invalid}) + + +def test_path_sketch_uses_arc_length_and_returns_actual_frame(sm, monkeypatch): + g = NS(OnPathDimensionBuilder=NS(UpdateReason=NS(Path="path"))) + sm.nx.GeometricUtilities = g + monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", g) + sm.nx.SketchAlongPathBuilder = NS( + PlaneOrientationType=NS(NormalToPath="normal"), + SketchOrientationType=NS(Automatic="auto", RelativeToFace="face"), + ) + edge = sm.own(Edge(), "edge") + sk = Sketch(sm.session) + sk.OwningPart = sm.part + sk.Activate = Mock() + builder = NS( + Section=object(), + PlaneLocation=NS(Expression=NS(RightHandSide="0"), Update=Mock()), + Validate=Mock(return_value=True), + Commit=Mock(return_value=sk), + Destroy=Mock(), + ) + sm.part.Sketches.CreateSketchAlongPathBuilder = Mock(return_value=builder) + monkeypatch.setattr(sm.e, "_sm_section", Mock()) + result = sm.e.execute( + "nx_create_path_sketch", {"edges": [edge], "help_point": [0, 0, 0], "percent": 25} + ) + assert builder.PlaneLocation.IsParameterUsed is False + assert builder.PlaneLocation.IsPercentUsed is True + assert float(builder.PlaneLocation.Expression.RightHandSide) == 25 + assert result["frame"]["origin"] == [0, 0, 0] + assert result["position_convention"] == "arc_length_percent" + builder.Destroy.assert_called_once() + for invalid in [-1, 101, True, float("nan")]: + with pytest.raises(NXToolError): + sm.e._create_path_sketch([edge], [0, 0, 0], percent=invalid) + assert sm.part.Sketches.CreateSketchAlongPathBuilder.call_count == 1 + + +def test_secondary_tab_rejects_inconsistent_thickness_before_builder(sm): + b = make_tab_builder(sm) + body = Body() + body_id = sm.own(body, "body") + sketch_id = sm.own(Sketch(sm.session), "sketch") + sm.part.Features.SheetmetalManager.GetBodyThickness = lambda target: 2 + with pytest.raises(NXToolError, match="must match"): + sm.e.execute( + "nx_sheet_metal_feature", + { + "operation": "tab", + "parameters": { + "section": sketch_id, + "target_body": body_id, + "is_secondary": True, + "thickness": 3, + }, + }, + ) + b.CommitFeature.assert_not_called() + sm.part.Features.SheetmetalManager.CreateTabFeatureBuilder.assert_not_called() + + +@pytest.mark.parametrize("operation", ["flange", "advanced_flange", "unbend", "rebend"]) +def test_guided_examples_match_creation_contract_and_native_evidence(sm, operation): + from jsonschema import Draft202012Validator + + result = sm.e._sheet_metal_schema(operation) + Draft202012Validator(result["parameters_schema"]).validate(result["example_parameters"]) + source = result["example_evidence"]["source"] + path, _, pointer = source.partition("#") + evidence_path = Path(__file__).parents[1] / path + assert evidence_path.is_file() + if pointer: + evidence = json.loads(evidence_path.read_text()) + for key in pointer.strip("/").split("/"): + evidence = evidence[key] + assert evidence["source_fixture"] + if "parameters" in evidence: + assert result["example_parameters"] == evidence["parameters"] + assert len(result["prerequisites"]) > 3 + + +def test_unbend_guidance_distinguishes_stationary_web_from_bend_strip(sm): + result = sm.e._sheet_metal_schema("unbend") + for key in ["parameters_schema", "edit_parameters_schema"]: + fields = result[key]["properties"] + assert "items[].bends[].face.id" in fields["face_collector"]["description"] + assert "not the flattened bend strip" in fields["reference_entity"]["description"] + assert ( + "Edge-based stationary references were not exercised" in result["example_evidence"]["scope"] + ) + + +def test_advanced_flange_does_not_claim_reference_modes_are_verified(sm): + result = sm.e._sheet_metal_schema("advanced_flange") + assert "unverified" in result["parameters_schema"]["properties"]["type"]["description"] + assert result["parameters_schema"]["required"] == ["edges"] + assert "type" not in result["example_parameters"] diff --git a/tests/test_tools/test_file_ops.py b/tests/test_tools/test_file_ops.py index a3a25ca..2f475b6 100644 --- a/tests/test_tools/test_file_ops.py +++ b/tests/test_tools/test_file_ops.py @@ -40,6 +40,7 @@ def _make_mock_nxopen(): base_part.Units.Inches = "Inches" base_part.SaveComponents = MagicMock() base_part.SaveComponents.TrueValue = "TrueValue" + base_part.SaveComponents.FalseValue = "FalseValue" base_part.CloseAfterSave = MagicMock() base_part.CloseAfterSave.FalseValue = "FalseValue" base_part.CloseModified = MagicMock() diff --git a/tests/test_tools/test_measure.py b/tests/test_tools/test_measure.py index 0a8484c..c584649 100644 --- a/tests/test_tools/test_measure.py +++ b/tests/test_tools/test_measure.py @@ -45,6 +45,7 @@ def _make_named(name): # --- Features --- mock_work_part.Features = MagicMock() mock_work_part.Features.ToArray = MagicMock(return_value=mock_features) + mock_work_part.Features.__iter__.side_effect = lambda: iter(mock_features) # --- Bodies --- mock_work_part.Bodies = MagicMock() diff --git a/tests/test_tools/test_modeling.py b/tests/test_tools/test_modeling.py index e5a7cd0..8dee706 100644 --- a/tests/test_tools/test_modeling.py +++ b/tests/test_tools/test_modeling.py @@ -87,6 +87,7 @@ def _make_named(name): mock_sketches = [_make_named("Circle1"), _make_named("Path1")] mock_work_part.Sketches = MagicMock() mock_work_part.Sketches.ToArray = MagicMock(return_value=mock_sketches) + mock_work_part.Sketches.__iter__.side_effect = lambda: iter(mock_sketches) # --- Curves --- mock_work_part.Curves = MagicMock() diff --git a/tests/test_ui_recovery.py b/tests/test_ui_recovery.py new file mode 100644 index 0000000..f19e389 --- /dev/null +++ b/tests/test_ui_recovery.py @@ -0,0 +1,283 @@ +"""UI handoff and scheduler failures; Win32 calls are explicit seams.""" + +import ctypes +import os +import threading +import time +from types import SimpleNamespace as NS +from unittest.mock import Mock + +import pytest + +from nx_mcp import interactive +from nx_mcp.bridge import BridgeDescriptor +from nx_mcp.interactive import ControlPanel, InteractiveHost +from nx_mcp.runtime import NXToolError +from tests.fakes import Body, Collection + +pytestmark = pytest.mark.fake_nx + + +@pytest.fixture +def host(rig, tmp_path, monkeypatch): + lock = {"value": 0} + enabled = {"value": True} + ui = NS( + AskLockStatus=lambda: lock["value"], + CanOpenPart=lambda: not lock["value"], + LockAccess=lambda: lock.update(value=1), + UnlockAccess=lambda: lock.update(value=0), + ) + rig.nx.UI = NS(Status=NS(Lock=1)) + h = InteractiveHost.__new__(InteractiveHost) + h.__dict__.update( + thread=threading.get_ident(), + mode="manual", + owns_lock=False, + window_disabled=False, + ui=ui, + nx=rig.nx, + session=rig.session, + executor=rig.e, + main_hwnd=1, + native_thread=123, + ticks=0, + completed=0, + running_method=None, + operation_started=None, + last_duration_seconds=None, + last_method=None, + last_error=None, + started=time.time(), + auto_start=False, + busy=False, + stopped=False, + stop_requested=False, + requested_mode=None, + stop_file=tmp_path / "stop", + state_dir=tmp_path, + panel=NS(update=Mock(), close=Mock()), + dispatcher=NS(drain=Mock(), stop=Mock()), + server=NS(stop=Mock()), + timer=7, + ) + h.user = NS( + EnableWindow=lambda _, v: enabled.update(value=v), + IsWindowEnabled=lambda _: enabled["value"], + KillTimer=Mock(), + ) + monkeypatch.setattr( + ctypes, "windll", NS(kernel32=NS(GetCurrentThreadId=lambda: 123)), raising=False + ) + h.descriptor = BridgeDescriptor.create(12345, "test") + h.descriptor_path = tmp_path / "bridge.json" + h.descriptor.write(h.descriptor_path) + return h + + +def test_manual_handoff_invalidates_references_and_checkpoints(host, rig): + body = Body() + rig.part.Bodies.append(body) + ref = rig.ref(body) + rig.e._checkpoint() + assert host.control("agent")["actual_ui_lock"] + host.control("manual") + assert not host.owns_lock and host.status()["nx_window_input_enabled"] + assert not rig.e._checkpoints + with pytest.raises(NXToolError): + rig.e.objects.resolve(ref) + with pytest.raises(NXToolError): + host.control("bad") + host.ui.CanOpenPart = lambda: False + with pytest.raises(NXToolError, match="dialog"): + host.control("agent") + host.thread = -1 + with pytest.raises(RuntimeError): + host.control("manual") + with pytest.raises(RuntimeError): + host.execute("nx_status", {}) + + +def test_operation_failure_relocks_ui_and_refresh_failure_is_warning(host, rig): + with pytest.raises(NXToolError, match="paused"): + host.execute("nx_list_bodies", {}) + host.execute("nx_ui_control", {"mode": "agent"}) + assert host.execute("nx_status", {})["ui"]["mode"] == "agent" + rig.e._handlers["nx_test_fail"] = Mock(side_effect=RuntimeError("native error")) + with pytest.raises(NXToolError): + host.execute("nx_test_fail", {}) + assert host.ui.AskLockStatus() == 1 + rig.part.ModelingViews.WorkView.UpdateDisplay.side_effect = RuntimeError("refresh") + result = host.execute("nx_list_bodies", {}) + assert not any("View refresh" in w for w in result["warnings"]) + rig.e._handlers["nx_test_edit"] = lambda: {} + edited = host.execute("nx_test_edit", {}) + assert any("View refresh" in w for w in edited["warnings"]) + rig.part.DrawingSheets = Collection() + rig.part.DrawingSheets.CurrentDrawingSheet = object() + drawing = host.execute("nx_test_edit", {}) + assert not any("View refresh" in w for w in drawing["warnings"]) + host.ui.LockAccess = Mock(side_effect=RuntimeError("lock failure")) + host.execute("nx_list_bodies", {}) + assert host.mode == "manual" and "Cannot restore" in host.last_error + + +def test_tick_modes_status_persistence_and_exception_handoff(host): + host.auto_start = True + host.tick() + assert host.mode == "agent" and not host.auto_start + host.requested_mode = "manual" + host.ticks = 9 + host.tick() + assert host.mode == "manual" and (host.state_dir / "ui-state.json").is_file() + host.requested_mode = "bad" + host.tick() + assert "mode must" in host.last_error + host.auto_start = True + host.ui.CanOpenPart = lambda: False + host.tick() + assert host.auto_start + host.dispatcher.drain.side_effect = RuntimeError("dispatcher") + host.tick() + assert not host.busy and host.last_error == "dispatcher" + host.busy = True + n = host.ticks + host.tick() + assert host.ticks == n + host.busy = False + host.stop_requested = True + host.tick() + assert host.stopped and not host.descriptor_path.exists() + host.user.KillTimer.assert_called_once() + host.server.stop.assert_called_once() + + +def test_stop_does_not_delete_another_session_descriptor(host): + replacement = BridgeDescriptor.create(12346, "other") + replacement.write(host.descriptor_path) + host.stop() + assert host.descriptor_path.exists() + host.descriptor_path.write_text("malformed") + host.stop() + + +def test_panel_commands_and_cleanup(host): + panel = ControlPanel.__new__(ControlPanel) + panel.host = host + panel.user = NS( + DefWindowProcW=Mock(return_value=9), + SetWindowTextW=Mock(), + UpdateWindow=Mock(), + DestroyWindow=Mock(), + UnregisterClassW=Mock(), + ) + panel.label = 2 + panel.hwnd = 3 + panel._class = NS(lpszClassName="class") + for code, mode in [(101, "manual"), (102, "agent")]: + assert panel._message(1, 0x111, code, 0) == 0 and host.requested_mode == mode + panel._message(1, 0x111, 103, 0) + assert host.stop_requested + host.stop_requested = False + panel._message(1, 0x10, 0, 0) + assert host.stop_requested + assert panel._message(1, 999, 0, 0) == 9 + assert panel._message(1, 0x111, None, 0) == 9 and host.last_error + panel.update("hello") + panel.close() + panel.user.DestroyWindow.assert_called_once_with(3) + + +def test_main_window_selection_requires_own_visible_window(monkeypatch): + user = NS( + EnumWindows=Mock(), + GetWindowThreadProcessId=Mock(), + GetWindowRect=Mock(), + IsWindowVisible=Mock(return_value=True), + ) + user.EnumWindows.side_effect = lambda fn, _: [fn(h, 0) for h in (1, 2, 3)] + user.GetWindowThreadProcessId.side_effect = lambda h, p: setattr( + p._obj, "value", os.getpid() if h != 3 else os.getpid() + 1 + ) + + def rect(h, p): + p._obj.right = h * 100 + p._obj.bottom = 100 + + user.GetWindowRect.side_effect = rect + monkeypatch.setattr(ctypes, "WinDLL", lambda *a, **k: user, raising=False) + monkeypatch.setattr(ctypes, "WINFUNCTYPE", lambda *a: lambda fn: fn, raising=False) + assert interactive.nx_main_window() == 2 + user.IsWindowVisible.return_value = False + with pytest.raises(RuntimeError, match="visible"): + interactive.nx_main_window() + + +def test_start_reuses_live_host_and_cleans_partial_initialization(monkeypatch, host): + monkeypatch.setattr(interactive, "_host", host) + monkeypatch.setattr(interactive, "_retired", []) + assert interactive.start("unused", "unused")["pid"] == os.getpid() + host.stopped = True + resources = NS(user=NS(KillTimer=Mock()), server=NS(stop=Mock()), panel=NS(close=Mock())) + + def fail(self, *_): + self.timer = 2 + self.user = resources.user + self.server = resources.server + self.panel = resources.panel + raise RuntimeError("initialization failed") + + monkeypatch.setattr(InteractiveHost, "__init__", fail) + with pytest.raises(RuntimeError): + interactive.start("unused", "unused") + resources.server.stop.assert_called_once() + resources.panel.close.assert_called_once() + assert len(interactive._retired) == 2 + + +def test_running_snapshot_is_readable_without_ui_queue_or_nx_calls(host, rig): + host.control("agent") + observed = [] + + def operation(): + def reader(): + observed.append(host.dispatch("nx_ui_control", {"mode": "status"})) + + thread = threading.Thread(target=reader) + thread.start() + thread.join(1) + assert not thread.is_alive() + state = __import__("json").loads((host.state_dir / "ui-state.json").read_text()) + assert state["running_method"] == "nx_test_busy" + assert "Running nx_test_busy" in host.panel.update.call_args.args[0] + return {} + + rig.e._handlers["nx_test_busy"] = operation + host.execute("nx_test_busy", {}) + assert observed[0]["activity"] == "running" + assert observed[0]["snapshot_only"] is True + assert observed[0]["operation_elapsed_seconds"] >= 0 + host.dispatcher.drain.assert_not_called() + assert host.status()["activity"] == "reserved_idle" + assert host.status()["last_duration_seconds"] >= 0 + assert "intentionally reserved" in host.panel_text() + assert not host.status()["nx_window_input_enabled"] + + +def test_diagnostic_write_failure_does_not_fail_committed_operation(host, rig): + host.control("agent") + host.publish = Mock(side_effect=OSError("disk full")) + result = host.execute("nx_list_bodies", {}) + assert result["status"] == "success" + assert host.running_method is None and host.operation_started is None + assert host.owns_lock + + +def test_panel_unchanged_text_does_not_repaint(host): + panel = ControlPanel.__new__(ControlPanel) + panel.user = NS(SetWindowTextW=Mock(), UpdateWindow=Mock()) + panel.label, panel.hwnd = 2, 3 + panel.update("Idle") + panel.update("Idle") + panel.user.SetWindowTextW.assert_called_once() + assert panel.user.UpdateWindow.call_count == 2 diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py new file mode 100644 index 0000000..c979fa2 --- /dev/null +++ b/tests/test_visual_tools.py @@ -0,0 +1,72 @@ +from types import SimpleNamespace + +import pytest + +from nx_mcp.runtime import NXToolError +from nx_mcp.visual_tools import VisualToolsMixin, unit_normal + + +def test_section_normal_rejects_degenerate_or_nonfinite_planes(): + for value in ([0, 0, 0], [float("nan"), 0, 1], [0, 1]): + with pytest.raises(NXToolError): + unit_normal(value) + assert unit_normal([0, 0, 5]) == [0, 0, 1] + + +def test_restore_preflights_all_references_before_any_mutation(): + host = VisualToolsMixin() + host._visual_part = lambda: None + host._work_part = lambda: None + host._part_id = lambda p: "part-1" + host._display_snapshots = { + "snapshot": { + "part_id": "part-1", + "records": [ + {"object": {"id": "live"}, "blanked": True}, + {"object": {"id": "stale"}, "blanked": False}, + ], + } + } + changes = [] + + def resolve(ref): + if ref == "stale": + raise NXToolError("NX_OBJECT_STALE", "closed part") + return SimpleNamespace(Blank=lambda: changes.append("blank")) + + host._resolve = resolve + with pytest.raises(NXToolError, match="closed part"): + host._restore_display("snapshot") + assert changes == [] and "snapshot" in host._display_snapshots + + +def test_restore_rejects_out_of_order_changes(): + host = VisualToolsMixin() + host._visual_part = lambda: None + host._work_part = lambda: None + host._part_id = lambda p: "part-1" + host._display_snapshots = { + key: {"part_id": "part-1", "records": []} for key in ("first", "second") + } + with pytest.raises(NXToolError, match="reverse order"): + host._restore_display("first") + + +@pytest.mark.asyncio +async def test_visual_tools_publish_enums_and_native_capture_description(tmp_path): + from nx_mcp.server import create_server + from nx_mcp.workspace import Workspace + + server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) + tools = {t.name: t for t in await server.list_tools()} + assert len(tools) == 189 + assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ + "show", + "hide", + "isolate", + ] + assert "native" in tools["nx_section_view"].description.lower() + assert "desktop to PNG" not in tools["nx_screenshot"].description + assert "inline" in tools["nx_screenshot"].description + assert tools["nx_sketch_diagnostics"].annotations.readOnlyHint + assert not tools["nx_set_display"].annotations.readOnlyHint diff --git a/tests/test_workspace.py b/tests/test_workspace.py index f8b9c39..d53c541 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -1,4 +1,3 @@ -import sys from pathlib import Path import pytest @@ -14,8 +13,6 @@ def test_workspace_accepts_relative_paths_inside_root(tmp_path: Path): @pytest.mark.parametrize("path", ["../outside.prt", "C:/outside.prt"]) def test_workspace_rejects_paths_outside_root(tmp_path: Path, path: str): - if sys.platform != "win32" and path.startswith("C:/"): - pytest.skip("drive-letter paths are only absolute on Windows") workspace = Workspace(tmp_path) with pytest.raises(WorkspaceViolation, match="workspace"): @@ -34,3 +31,25 @@ def test_workspace_rejects_absolute_path_outside_root(tmp_path: Path): with pytest.raises(WorkspaceViolation, match="workspace"): workspace.ensure_inside(tmp_path.parent / "outside.prt") + + +def test_resolve_accepts_absolute_inside_root(tmp_path): + path = tmp_path / "projects" / "controller" / "part.prt" + assert Workspace(tmp_path).resolve(str(path)) == path + + +@pytest.mark.parametrize("path", ["C:relative.prt", r"\relative.prt", ".NX-MCP/state.json"]) +def test_rejects_ambiguous_or_reserved_paths(tmp_path, path): + with pytest.raises(WorkspaceViolation): + Workspace(tmp_path).resolve(path) + + +def test_symlink_cannot_escape_workspace(tmp_path): + root = tmp_path / "root" + root.mkdir() + try: + (root / "escape").symlink_to(tmp_path, target_is_directory=True) + except OSError: + pytest.skip("Symlinks require host privileges") + with pytest.raises(WorkspaceViolation): + Workspace(root).resolve("escape/outside.prt")