From 8b377b8958d0c5d2eaf72efb3c09594ee235a51d Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:06:06 +0200 Subject: [PATCH 01/69] Isolate legacy experimental execution from sidecar dependencies --- examples/nx_runtime_probe.py | 7 +++++++ src/nx_mcp/experimental.py | 27 ++++++++++++++++----------- 2 files changed, 23 insertions(+), 11 deletions(-) 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/src/nx_mcp/experimental.py b/src/nx_mcp/experimental.py index f0f5100..a57cdc4 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 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 @@ -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, From 16ea158f77712b63b94af0f77357f16ad82acd53 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:06:06 +0200 Subject: [PATCH 02/69] Repair NX v2606 sketch and feature API workflows --- src/nx_mcp/nx_bridge.py | 261 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py index 4d65aa1..46f32a2 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,10 @@ class NXOpenExecutor: "nx_sketch_rectangle", "nx_finish_sketch", "nx_extrude", + "nx_revolve", + "nx_sketch_arc", + "nx_hole", + "nx_boolean", } def __init__( @@ -67,6 +73,11 @@ 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, } def execute(self, method: str, params: dict[str, Any]) -> dict[str, Any]: @@ -422,6 +433,256 @@ 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", + ) -> 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") + 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(*vectors[axis_key]) + origin = part.Points.CreatePoint(self.nxopen.Point3d(0.0, 0.0, 0.0)) + 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, + "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), + ) + 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 _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: From c062eeba64417c4253dcf7de6e3b22560da120c1 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:06:06 +0200 Subject: [PATCH 03/69] Add reference lifecycle, recovery, artifacts and NX2606 integration tools --- pyproject.toml | 3 + src/nx_mcp/bridge.py | 19 +- src/nx_mcp/capability_manifest.json | 344 +++++++ src/nx_mcp/certified.py | 6 +- src/nx_mcp/hardened.py | 1394 +++++++++++++++++++++++++++ src/nx_mcp/integration_server.py | 537 +++++++++++ src/nx_mcp/nx_bridge.py | 150 ++- src/nx_mcp/recovery.py | 72 ++ src/nx_mcp/runtime.py | 2 +- src/nx_mcp/utils/geometry.py | 19 +- src/nx_mcp/utils/selection.py | 2 +- src/nx_mcp/workspace.py | 2 + tests/test_hardening.py | 193 ++++ 13 files changed, 2723 insertions(+), 20 deletions(-) create mode 100644 src/nx_mcp/capability_manifest.json create mode 100644 src/nx_mcp/hardened.py create mode 100644 src/nx_mcp/integration_server.py create mode 100644 src/nx_mcp/recovery.py create mode 100644 tests/test_hardening.py diff --git a/pyproject.toml b/pyproject.toml index e6b0b19..06c8e8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,3 +83,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"] diff --git a/src/nx_mcp/bridge.py b/src/nx_mcp/bridge.py index 244c317..e53bc42 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 @@ -394,9 +394,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 +417,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( + getattr(self, "session_id", 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..147a00f --- /dev/null +++ b/src/nx_mcp/capability_manifest.json @@ -0,0 +1,344 @@ +{ + "revision": "2606-hardening-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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_finish_sketch": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Principal/custom sketch completion and subsequent extrusion" + }, + "nx_chamfer": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_mirror_body": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_hole": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_close_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Explicit saved source parts closed without whole-tree closure" + }, + "nx_measure_volume": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Part and nested assembly sum, returned in mm^3; no union/mass claim" + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_add_dimension": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "Already-loaded paths reused without close/recreation" + }, + "nx_measure_angle": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_delete_feature": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_reposition_component": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_add_base_view": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_checkpoint": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "In-session model checkpoint and available-state inspection" + }, + "nx_screenshot": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "Existing desktop capture retained and explicitly labeled; batch-model screenshot unavailable" + }, + "nx_list_bodies": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_save_part": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Save with documented native mark expiration" + }, + "nx_set_view": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_boolean": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_list_topology": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Face and edge enumeration; face references used in actual distance query" + }, + "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", + "scope": "Principal/custom normals; disconnected two-body extrusion; positive depth" + }, + "nx_list_sketches": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_list_features": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_sweep": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_fit_view": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_capabilities": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_blend": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_cancel_operation": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "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": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_list_components": { + "status": "tested", + "evidence_type": "real_NX_v2606", + "scope": "Two-level transforms and STEP round-trip pose equality" + }, + "nx_create_drawing": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + }, + "nx_sketch_constraint": { + "status": "experimental", + "evidence_type": "not_tested_in_this_release", + "scope": "No correctness or failure claim; preserve as experimental." + } + }, + "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" + ], + "unavailable": [ + "exact_interference_volume", + "general_solid_validity_audit", + "batch_model_viewport_image", + "full_sketch_editing_and_constraint_DOF", + "loft_shell_draft_threads_engraving", + "general_body_transforms", + "assembly_constraint_editing", + "color_material_transparency_visibility_controls" + ] +} diff --git a/src/nx_mcp/certified.py b/src/nx_mcp/certified.py index 8e79347..67c8eb3 100644 --- a/src/nx_mcp/certified.py +++ b/src/nx_mcp/certified.py @@ -54,7 +54,7 @@ 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 +199,8 @@ 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/hardened.py b/src/nx_mcp/hardened.py new file mode 100644 index 0000000..796b23f --- /dev/null +++ b/src/nx_mcp/hardened.py @@ -0,0 +1,1394 @@ +"""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.nx_bridge import NXOpenExecutor +from nx_mcp.runtime import NXToolError +from nx_mcp.recovery import OperationStore, timestamp + +READ_ONLY = { + "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_create_part", + "nx_open_part", + "nx_activate_part", + "nx_close_part", + "nx_save_part", + "nx_save_as", + "nx_export_step", + "nx_screenshot", + "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)) + + +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)] + + +def add(a, b): + return [x + y for x, y in zip(a, b)] + + +IDENTITY = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + + +class HardenedExecutor(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 + self._handlers.update( + { + "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, + } + ) + + 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) + handler = lambda **p: 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: + fingerprint = self.store.fingerprint(method, params) + 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 ( + 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 result.get("status") == "error": + raise NXToolError( + result.get("code", result.get("error_code", "NX_OPERATION_FAILED")), + result.get("message", "Operation failed"), + ) + 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 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) + self._history.append( + {"mark": mark, "part_id": part_id, "operation_id": op_id, "method": method} + ) + except Exception as error: + 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) + ) + ) + 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 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)], + } + 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(), + }: + if 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): + source = self.workspace.ensure_inside(path) + loaded = next( + (p for p in self.session.Parts if str(p.FullPath).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 + ) + result.update(already_loaded=already_loaded, path=str(source)) + 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", + } + + def _save_part(self): + part = self._work_part() + status = part.Save( + self.nxopen.BasePart.SaveComponents.TrueValue, + self.nxopen.BasePart.CloseAfterSave.FalseValue, + ) + if status and hasattr(status, "Dispose"): + status.Dispose() + state = self._checkpoint_state() + return { + "message": "Saved part; native NX save may invalidate undo marks", + "path": part.FullPath, + "recovery": state, + "warnings": [ + "NX v2606 save invalidates native undo/checkpoints. Establish a new checkpoint before further edits." + ], + } + + 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") + 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): + target = self.objects.resolve(part, expected_kind="part") if part else self._work_part() + pid = self._part_id(target) + tag = int(target.Tag) + if save: + 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, + ) + self.objects.invalidate_part(pid) + self._part_generations.pop(tag, None) + self._history = [h for h in self._history if h["part_id"] != pid] + self._checkpoints = {k: v for k, v in self._checkpoints.items() if v["part_id"] != pid} + return {"message": "Closed specified part; component tree and other parts preserved"} + + def _list_open_parts(self): + return { + "parts": [ + { + "part": self._reference(p, "part", p, "Part"), + "name": p.Name, + "path": p.FullPath, + "work": p == self.session.Parts.Work, + "display": p == self.session.Parts.Display, + "modified": bool(p.IsModified), + } + for p in self.session.Parts + ] + } + + 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) + ): + 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"]), + 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 _extrude(self, sketch_id, distance, reverse=False): + if not math.isfinite(distance): + raise NXToolError("NX_INVALID_ARGUMENT", "distance must be finite") + result = super()._extrude(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): + part = self._work_part() + result = [] + for c, path in self._walk_components(part): + p, m = c.GetPosition() + ref = self._reference(c, "component", part, "Component") + ref["occurrence_path"] = path + result.append( + { + "object": ref, + "name": c.Name, + "part_path": c.Prototype.FullPath, + "depth": len(path) - 1, + "translation": xyz(p), + "rotation_matrix": rows(m), + "coordinate_frame": "assembly", + "rotation": [m.Xx, m.Xy, m.Xz, m.Yx, m.Yy, m.Yz, m.Zx, m.Zy, m.Zz], + "suppressed": bool(c.IsSuppressed), + "reference_set": c.ReferenceSet, + } + ) + return { + "components": result, + "count": len(result), + "matrix_convention": "rotation_matrix is row-major; p_assembly = R p_local + translation. Legacy rotation lists axis vectors.", + } + + def _list_topology(self, body): + b = self._resolve(body, {"body"}) + part = self._work_part() + return { + "body": self._reference(b, "body", part, "Body"), + "faces": [self._reference(f, "face", part, "Face") for f in b.GetFaces()], + "edges": [self._reference(e, "edge", part, "Edge") for e in b.GetEdges()], + "solid": b.IsSolidBody, + } + + 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)], + "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": accuracy, + "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) != 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): + 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") + 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=self._units()) + 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: + builder.SettingsFile = str( + Path( + __import__("os").environ.get( + "UGII_BASE_DIR", r"C:\Program Files\Siemens\Designcenter2606" + ) + ) + / "STEP214UG" + / "ugstep214.def" + ) + 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 + builder.Commit() + 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" + ) + return { + "path": str(source), + "staging_directory": str(import_dir), + "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))] + 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)) 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): + 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 journal thread; batch model graphics unavailable", + api_detection={ + "step_import": hasattr(self.session.DexManager, "CreateStep214Importer"), + "native_pattern": bool( + part and hasattr(part.Features, "CreatePatternFeatureBuilder") + ), + "minimum_distance": hasattr(self.session.Measurement, "GetMinimumDistance"), + }, + 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.nx_version != "v2606": + for tool in manifest["tools"].values(): + tool.update(status="experimental", scope="This NX version has not been tested") + 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 = [ + ("body", part.Bodies), + ("feature", part.Features), + ("curve", part.Curves), + ("sketch", part.Sketches), + ("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): + 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 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: + 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): + import struct + + result = super()._screenshot(path) + file = self.workspace.ensure_inside(path) + with file.open("rb") as stream: + header = stream.read(24) + width, height = struct.unpack(">II", header[16:24]) + result.update( + path=str(file), + capture_kind="interactive_windows_desktop", + model_preview=False, + resolution=[width, height], + camera=None, + size=file.stat().st_size, + warnings=[ + "This image does not show the batch NX model. Use CAD export for model preview." + ], + ) + return result diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py new file mode 100644 index 0000000..9bad93a --- /dev/null +++ b/src/nx_mcp/integration_server.py @@ -0,0 +1,537 @@ +"""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 uuid +from typing import Any, Literal +from mcp.types import CallToolResult, TextContent, ToolAnnotations +from nx_mcp.runtime import NXToolError +from nx_mcp.workspace import WorkspaceViolation +from nx_mcp.recovery import OperationStore + + +# 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): + 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_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): + 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_batch(operations: list[dict[str, Any]]): + pass + + +def nx_capabilities(): + pass + + +def nx_rename_object(object_id: str, name: str): + pass + + +def nx_revolve( + angle: float = 360, + axis: Literal["X", "Y", "Z", "-X", "-Y", "-Z"] = "Z", + sketch_name: str | None = None, + boolean: Literal["none", "unite", "subtract", "intersect"] = "none", +): + pass + + +def nx_workspace_list(path: str = "."): + pass + + +def nx_download_file(path: str, offset: int = 0, length: int = 262144): + pass + + +def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, offset: int = 0): + pass + + +DESCRIPTIONS = { + "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": "Open or reuse a loaded workspace .prt and activate it; work/display flags are explicit. Does not recreate loaded parts.", + "nx_close_part": "Close only the specified loaded part (ID), or current work part; preserves its component tree and unrelated parts. save defaults true.", + "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 faces and edges of a body as session-scoped opaque references. References become stale after rollback/close; topology edits can invalidate them.", + "nx_rename_object": "Rename a referenced object and return its actual NX-normalized display name. Reacquire references afterward.", + "nx_workspace_list": "List files/directories within the configured workspace; sizes and SHA-256 checksums for files. Internal operation storage is excluded.", + "nx_download_file": "Retrieve a workspace artifact as base64 chunks up to 256 KiB, with full-file SHA-256, size and offset. Does not read outside workspace.", + "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": "NX-version-specific integration manifest. API presence, real-test evidence and unavailable capabilities are separate. Batch NX has no model viewport.", + "nx_screenshot": "Capture the interactive Windows desktop to PNG. This is NOT a screenshot of the batch NX model. Retrieve bytes through nx_download_file; batch-model camera rendering is unavailable.", +} + +READ_ONLY = { + "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_list", + "nx_download_file", +} +SIDE = { + "nx_workspace_list", + "nx_download_file", + "nx_upload_file", + "nx_operation_status", + "nx_cancel_operation", +} +PATHS = { + "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", +} + + +def envelope(payload, error=False): + 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) + } + 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) + 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() + params = 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): + if 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) + return envelope(result, error=result.get("status") == "error") + except (NXToolError, WorkspaceViolation, ValueError, TypeError) as e: + error = ( + e + if isinstance(e, NXToolError) + else NXToolError("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, (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, + idempotentHint=name in READ_ONLY or name == "nx_set_component_transform", + ), + ) + 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() + 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. 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_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, + } + path = workspace.resolve(p["path"]) + 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": + items = [] + for f in sorted(path.iterdir()): + if f.name == ".nx-mcp": + continue + workspace.ensure_inside(f) + items.append( + metadata(f) | {"kind": "file"} + if f.is_file() + else {"path": str(f.relative_to(workspace.root)), "kind": "directory"} + ) + return {"status": "success", "entries": items, "count": len(items)} + if method == "nx_download_file": + if p["offset"] < 0 or not 1 <= p["length"] <= 262144: + raise NXToolError("NX_INVALID_ARGUMENT", "Invalid chunk offset/length") + meta = metadata(path) + with path.open("rb") as stream: + stream.seek(p["offset"]) + data = stream.read(p["length"]) + return { + "status": "success", + **meta, + "offset": p["offset"], + "data_base64": base64.b64encode(data).decode(), + "eof": p["offset"] + len(data) >= 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/nx_bridge.py b/src/nx_mcp/nx_bridge.py index 46f32a2..517efd6 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -36,6 +36,8 @@ class NXOpenExecutor: "nx_sketch_arc", "nx_hole", "nx_boolean", + "nx_add_component", + "nx_reposition_component", } def __init__( @@ -78,6 +80,14 @@ def __init__( "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]: @@ -243,19 +253,32 @@ 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() - return { - "path": str(destination), - "message": f"Exported STEP: {destination.name}", - } + 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 and verified STEP file: {destination.name}"} def _create_sketch(self, plane: str = "XY", name: str | None = None) -> dict[str, Any]: normals = {"XY": (0.0, 0.0, 1.0), "XZ": (0.0, 1.0, 0.0), "YZ": (1.0, 0.0, 0.0)} @@ -544,6 +567,10 @@ def _sketch_arc_legacy( 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, @@ -642,6 +669,115 @@ def _boolean(self, boolean_type: str, targets: list[str]) -> dict[str, Any]: "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)], + "units": str(part.PartUnits), "bodies": rows, + "message": "UF bounding boxes; may be conservative for curved geometry"} + + def _measure_volume(self, body=None): + import NXOpen.UF + 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: + 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): + 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; batch bridge has no visible viewport"} + + 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") + 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) @@ -741,7 +877,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), diff --git a/src/nx_mcp/recovery.py b/src/nx_mcp/recovery.py new file mode 100644 index 0000000..0f74d92 --- /dev/null +++ b/src/nx_mcp/recovery.py @@ -0,0 +1,72 @@ +"""Durable request receipts. No NXOpen calls; safe for sidecar status queries.""" + +from __future__ import annotations +import hashlib +import json +import os +import re +from pathlib import Path +from datetime import datetime, timezone +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/runtime.py b/src/nx_mcp/runtime.py index 72fccbb..180fff8 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -5,7 +5,7 @@ from dataclasses import asdict, dataclass from typing import Any, Literal -ObjectKind = Literal["part", "sketch", "curve", "feature", "body"] +ObjectKind = Literal["part", "sketch", "curve", "feature", "body", "component", "face", "edge"] @dataclass(frozen=True) diff --git a/src/nx_mcp/utils/geometry.py b/src/nx_mcp/utils/geometry.py index fe5b4ec..9306aca 100644 --- a/src/nx_mcp/utils/geometry.py +++ b/src/nx_mcp/utils/geometry.py @@ -37,14 +37,19 @@ 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()}: + if 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/workspace.py b/src/nx_mcp/workspace.py index 3dc77ba..daead4d 100644 --- a/src/nx_mcp/workspace.py +++ b/src/nx_mcp/workspace.py @@ -24,4 +24,6 @@ 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 ".nx-mcp" in resolved.relative_to(self.root).parts: + raise WorkspaceViolation("Internal NX MCP state is not a user artifact") return resolved diff --git a/tests/test_hardening.py b/tests/test_hardening.py new file mode 100644 index 0000000..bc80457 --- /dev/null +++ b/tests/test_hardening.py @@ -0,0 +1,193 @@ +"""Safety and MCP contract regressions; these do not substitute for real NX tests.""" + +import base64 +import hashlib +import time +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"] + + def delayed(**params): + time.sleep(0.1) + return handler(**params) + + executor._handlers["nx_test_mutate"] = delayed + server = BridgeServer(executor.execute, token="test-token") + server.start() + try: + p = {"value": 9, "operation_id": "lost-response-123"} + with pytest.raises(NXToolError): + await BridgeClient("127.0.0.1", server.port, token="test-token", timeout=0.02).call( + "nx_test_mutate", p + ) + result = await BridgeClient("127.0.0.1", server.port, token="test-token", timeout=2).call( + "nx_test_mutate", p + ) + assert result["replayed"] and executor.session.values == [9] + finally: + 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' From adb26ab61a560cb804e8849a3c0e6cda168ca98c Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:06:07 +0200 Subject: [PATCH 04/69] Add visible NX UI bridge, native collision checks and viewport images --- INTERACTIVE-NX.md | 13 + examples/start_nx_interactive.py | 15 + pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/bridge.py | 35 ++- src/nx_mcp/capability_manifest.json | 32 ++- src/nx_mcp/hardened.py | 54 ++-- src/nx_mcp/inspection.py | 332 ++++++++++++++++++++++ src/nx_mcp/integration_server.py | 74 ++++- src/nx_mcp/interactive.py | 424 ++++++++++++++++++++++++++++ tests/test_interactive.py | 160 +++++++++++ 11 files changed, 1098 insertions(+), 45 deletions(-) create mode 100644 INTERACTIVE-NX.md create mode 100644 examples/start_nx_interactive.py create mode 100644 src/nx_mcp/inspection.py create mode 100644 src/nx_mcp/interactive.py create mode 100644 tests/test_interactive.py 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/examples/start_nx_interactive.py b/examples/start_nx_interactive.py new file mode 100644 index 0000000..7e40b41 --- /dev/null +++ b/examples/start_nx_interactive.py @@ -0,0 +1,15 @@ +"""Play once in the open NX UI. Returns immediately; NX retains the UI-thread host.""" + +import os, sys +from pathlib import Path + +root = Path(__file__).resolve().parents[1] / "src" +sys.path.insert(0, str(root)) +from nx_mcp.interactive import start + +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/pyproject.toml b/pyproject.toml index 06c8e8b..a8d58ff 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.dev1" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index fa6c364..62c3a9d 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.dev1" diff --git a/src/nx_mcp/bridge.py b/src/nx_mcp/bridge.py index e53bc42..0e68cc6 100644 --- a/src/nx_mcp/bridge.py +++ b/src/nx_mcp/bridge.py @@ -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" @@ -177,6 +179,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 +210,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 +263,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: diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 147a00f..6b45f98 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-hardening-r1", + "revision": "2606-interactive-r3", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -189,9 +189,9 @@ "scope": "In-session model checkpoint and available-state inspection" }, "nx_screenshot": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "Existing desktop capture retained and explicitly labeled; batch-model screenshot unavailable" + "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": "experimental", @@ -317,6 +317,26 @@ "status": "experimental", "evidence_type": "not_tested_in_this_release", "scope": "No correctness or failure claim; preserve as experimental." + }, + "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" } }, "limitations": [ @@ -332,13 +352,13 @@ "MCP desktop clients must refresh tool schemas after deployment" ], "unavailable": [ - "exact_interference_volume", "general_solid_validity_audit", "batch_model_viewport_image", "full_sketch_editing_and_constraint_DOF", "loft_shell_draft_threads_engraving", "general_body_transforms", "assembly_constraint_editing", - "color_material_transparency_visibility_controls" + "color_material_transparency_visibility_controls", + "union_of_all_pairwise_interference_volumes" ] } diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 796b23f..e258935 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -11,8 +11,12 @@ from nx_mcp.nx_bridge import NXOpenExecutor from nx_mcp.runtime import NXToolError from nx_mcp.recovery import OperationStore, timestamp +from nx_mcp.inspection import InspectionMixin READ_ONLY = { + "nx_view_info", + "nx_check_interference", + "nx_check_clearance", "nx_status", "nx_list_sketches", "nx_list_features", @@ -91,7 +95,7 @@ def add(a, b): IDENTITY = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] -class HardenedExecutor(NXOpenExecutor): +class HardenedExecutor(InspectionMixin, NXOpenExecutor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.session_id = uuid.uuid4().hex @@ -104,6 +108,9 @@ def __init__(self, *args, **kwargs): self._current_operation = None self._handlers.update( { + "nx_view_info": self._view_info, + "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, @@ -940,7 +947,8 @@ def _measure_distance(self, obj1, obj2): best = { "distance": dist, "closest_points": [xyz(p1), xyz(p2)], - "accuracy": accuracy, + "accuracy": None, + "accuracy_note": "No validated numerical error bound is exposed by this NX binding", "resolved_tags": [int(x.Tag), int(y.Tag)], } return { @@ -1257,7 +1265,12 @@ def _capabilities(self): manifest.update( session_id=self.session_id, actual_nx_version=self.nx_version, - execution="serialized NX journal thread; batch model graphics unavailable", + 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={ "step_import": hasattr(self.session.DexManager, "CreateStep214Importer"), "native_pattern": bool( @@ -1274,6 +1287,11 @@ def _capabilities(self): "sketch_XZ_normal": [0, -1, 0], }, ) + if self.session.IsBatch: + for name in ("nx_screenshot", "nx_ui_control"): + manifest["tools"][name].update( + status="unavailable", scope="Requires the interactive NX host" + ) if self.nx_version != "v2606": for tool in manifest["tools"].values(): tool.update(status="experimental", scope="This NX version has not been tested") @@ -1372,23 +1390,13 @@ def _export_step(self, path): ) return result - def _screenshot(self, path): - import struct - - result = super()._screenshot(path) - file = self.workspace.ensure_inside(path) - with file.open("rb") as stream: - header = stream.read(24) - width, height = struct.unpack(">II", header[16:24]) - result.update( - path=str(file), - capture_kind="interactive_windows_desktop", - model_preview=False, - resolution=[width, height], - camera=None, - size=file.stat().st_size, - warnings=[ - "This image does not show the batch NX model. Use CAD export for model preview." - ], - ) - 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/inspection.py b/src/nx_mcp/inspection.py new file mode 100644 index 0000000..1b15e43 --- /dev/null +++ b/src/nx_mcp/inspection.py @@ -0,0 +1,332 @@ +"""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), + ("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: + if builder: + builder.Destroy() + 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: + 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 index 9bad93a..a381b5a 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -8,12 +8,44 @@ import os import uuid from typing import Any, Literal -from mcp.types import CallToolResult, TextContent, ToolAnnotations +from mcp.types import CallToolResult, TextContent, ToolAnnotations, ImageContent from nx_mcp.runtime import NXToolError from nx_mcp.workspace import WorkspaceViolation from nx_mcp.recovery import OperationStore +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", @@ -164,6 +196,11 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of DESCRIPTIONS = { + "nx_ui_control": "Inspect the interactive NX host or switch between agent control and manual editing. 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.", @@ -195,6 +232,10 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of } READ_ONLY = { + "nx_view_info", + "nx_check_interference", + "nx_check_clearance", + "nx_ui_control", "nx_status", "nx_list_sketches", "nx_list_features", @@ -279,7 +320,9 @@ async def proxy(**kwargs): try: bound = signature.bind(**kwargs) bound.apply_defaults() - params = dict(bound.arguments) + 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", @@ -306,7 +349,30 @@ async def proxy(**kwargs): workspace.resolve(op["params"]["part_path"]) ) result = await bridge.call(method, params) - return envelope(result, error=result.get("status") == "error") + response = envelope(result, error=result.get("status") == "error") + if method == "nx_screenshot" and not response.isError: + file = workspace.ensure_inside(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 @@ -337,7 +403,7 @@ async def proxy(**kwargs): description=description, structured_output=False, annotations=ToolAnnotations( - readOnlyHint=name in READ_ONLY, + readOnlyHint=name in READ_ONLY and name != "nx_ui_control", idempotentHint=name in READ_ONLY or name == "nx_set_component_transform", ), ) diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py new file mode 100644 index 0000000..2fefae9 --- /dev/null +++ b/src/nx_mcp/interactive.py @@ -0,0 +1,424 @@ +"""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.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, + 440, + 145, + None, + 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, + 410, + 36, + 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, 55, 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): + self.user.SetWindowTextW(self.label, text) + + 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.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.dispatcher.call, token=token) + 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 status(self): + return { + "interactive": True, + "pid": os.getpid(), + "mode": self.mode, + "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": + # 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() + 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 + try: + result = self.executor.execute(method, params) + self.completed += 1 + part = self.session.Parts.Display + if part: + try: + part.ModelingViews.WorkView.UpdateDisplay() + except Exception as exc: + result.setdefault("warnings", []).append("View refresh: " + str(exc)) + return result + finally: + # 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") + + 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( + ( + "Agent control — model edits serialized" + if self.mode == "agent" + else "Paused — manual NX editing enabled" + ) + + "\n" + + (self.last_error or self.last_method or "Waiting for MCP requests") + ) + if self.ticks % 10 == 0: + temp = self.state_dir / "ui-state.tmp" + temp.write_text(json.dumps(self.status())) + temp.replace(self.state_dir / "ui-state.json") + 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/tests/test_interactive.py b/tests/test_interactive.py new file mode 100644 index 0000000..bbc5e0d --- /dev/null +++ b/tests/test_interactive.py @@ -0,0 +1,160 @@ +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: {} + + 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 From 0e897bde2b453f9d0b35b9e0f7775f84c8668b2e Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:06:07 +0200 Subject: [PATCH 05/69] Add collision highlighting, sections, appearance and sketch diagnostics --- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/capability_manifest.json | 58 +++- src/nx_mcp/hardened.py | 32 +- src/nx_mcp/integration_server.py | 58 +++- src/nx_mcp/interactive.py | 2 + src/nx_mcp/runtime.py | 2 +- src/nx_mcp/visual_tools.py | 505 ++++++++++++++++++++++++++++ tests/test_visual_tools.py | 72 ++++ 9 files changed, 721 insertions(+), 12 deletions(-) create mode 100644 src/nx_mcp/visual_tools.py create mode 100644 tests/test_visual_tools.py diff --git a/pyproject.toml b/pyproject.toml index a8d58ff..e5bd4c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev1" +version = "0.2.0.dev2" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 62c3a9d..d56b3de 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.dev1" +__version__ = "0.2.0.dev2" diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 6b45f98..c6c39f4 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-interactive-r3", + "revision": "2606-visual-tools-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -337,6 +337,56 @@ "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" } }, "limitations": [ @@ -354,11 +404,11 @@ "unavailable": [ "general_solid_validity_audit", "batch_model_viewport_image", - "full_sketch_editing_and_constraint_DOF", + "full_sketch_curve_and_constraint_editing", "loft_shell_draft_threads_engraving", "general_body_transforms", "assembly_constraint_editing", - "color_material_transparency_visibility_controls", - "union_of_all_pairwise_interference_volumes" + "union_of_all_pairwise_interference_volumes", + "material_assignment" ] } diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index e258935..e23e060 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -12,8 +12,12 @@ from nx_mcp.runtime import NXToolError from nx_mcp.recovery import OperationStore, timestamp from nx_mcp.inspection import InspectionMixin +from nx_mcp.visual_tools import VisualToolsMixin READ_ONLY = { + "nx_display_info", + "nx_list_sections", + "nx_sketch_diagnostics", "nx_view_info", "nx_check_interference", "nx_check_clearance", @@ -36,6 +40,8 @@ } # Files, session lifecycle, and undo itself cannot be reversed by a model undo mark. NON_MODEL = { + "nx_highlight_collisions", + "nx_clear_highlights", "nx_create_part", "nx_open_part", "nx_activate_part", @@ -95,7 +101,7 @@ def add(a, b): IDENTITY = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] -class HardenedExecutor(InspectionMixin, NXOpenExecutor): +class HardenedExecutor(VisualToolsMixin, InspectionMixin, NXOpenExecutor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.session_id = uuid.uuid4().hex @@ -109,6 +115,17 @@ def __init__(self, *args, **kwargs): self._handlers.update( { "nx_view_info": self._view_info, + "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, @@ -231,6 +248,8 @@ def execute(self, method, params): 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 @@ -273,7 +292,7 @@ def execute(self, method, params): "modified": result.get("modified"), "modified_tracking": "explicit only; null means not fully tracked", } - self._invalidate_deleted(before, after) + self._invalidate_deleted(before, after, invalidate_topology=method not in {"nx_set_display", "nx_set_visibility", "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} ) @@ -344,6 +363,7 @@ def _resolve(self, ref, kinds=None, part=None): "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(): @@ -1272,6 +1292,9 @@ def _capabilities(self): ), 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") @@ -1309,6 +1332,7 @@ def _snapshot(self, part): ("feature", part.Features), ("curve", part.Curves), ("sketch", part.Sketches), + ("section", getattr(part, "DynamicSections", [])), ("component", [c for c, _ in self._walk_components(part)]), ] return { @@ -1317,10 +1341,10 @@ def _snapshot(self, part): for v in values } - def _invalidate_deleted(self, before, after): + 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 entry.reference.kind in {"face", "edge"}: + 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 = { diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index a381b5a..0d6fb7b 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -14,6 +14,49 @@ from nx_mcp.recovery import OperationStore + +def nx_display_info(objects: list[str]): + 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 @@ -196,6 +239,17 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of DESCRIPTIONS = { + '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. 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 the interactive NX host or switch between agent control and manual editing. 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.", @@ -228,10 +282,12 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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": "NX-version-specific integration manifest. API presence, real-test evidence and unavailable capabilities are separate. Batch NX has no model viewport.", - "nx_screenshot": "Capture the interactive Windows desktop to PNG. This is NOT a screenshot of the batch NX model. Retrieve bytes through nx_download_file; batch-model camera rendering is unavailable.", } READ_ONLY = { + "nx_display_info", + "nx_list_sections", + "nx_sketch_diagnostics", "nx_view_info", "nx_check_interference", "nx_check_clearance", diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index 2fefae9..f4b2057 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -272,6 +272,8 @@ def control(self, mode="status"): 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: diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index 180fff8..beaefa8 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -5,7 +5,7 @@ from dataclasses import asdict, dataclass from typing import Any, Literal -ObjectKind = Literal["part", "sketch", "curve", "feature", "body", "component", "face", "edge"] +ObjectKind = Literal["part", "sketch", "curve", "feature", "body", "component", "face", "edge", "section", "constraint"] @dataclass(frozen=True) diff --git a/src/nx_mcp/visual_tools.py b/src/nx_mcp/visual_tools.py new file mode 100644 index 0000000..042fd7a --- /dev/null +++ b/src/nx_mcp/visual_tools.py @@ -0,0 +1,505 @@ +"""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): + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise NXToolError("NX_INVALID_ARGUMENT", "normal 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", "normal 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"} + ) + 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 = ( + "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): + self._visual_part() + 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"] = { + "name": exp.Name, + "formula": exp.RightHandSide, + "value": exp.Value, + } + 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: + if activated and self.session.ActiveSketch == sketch: + sketch.Deactivate( + self.nxopen.Sketch.ViewReorient.FalseValue, self.nxopen.Sketch.UpdateLevel.Model + ) + self.session.UndoToMark(mark, None) + self.session.DeleteUndoMark(mark, None) diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py new file mode 100644 index 0000000..7fbd3d2 --- /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) == 77 + 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 From 5f4bc1546be9b16d3e527fced0147971e54cdad9 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:11:06 +0200 Subject: [PATCH 06/69] Document fork setup, live validation and upstream readiness gaps --- README.md | 2 + docs/fork-status.md | 45 +++++ docs/fork-validation.md | 61 ++++++ docs/visual-tools.md | 36 ++++ examples/validate_visual_tools.py | 300 ++++++++++++++++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 docs/fork-status.md create mode 100644 docs/fork-validation.md create mode 100644 docs/visual-tools.md create mode 100644 examples/validate_visual_tools.py diff --git a/README.md b/README.md index 1e7c309..8db48eb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # NX MCP Server +> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev2`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 77 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. Broader upstream quality gates still need reconciliation; see [validation and PR readiness](docs/fork-validation.md). + 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: diff --git a/docs/fork-status.md b/docs/fork-status.md new file mode 100644 index 0000000..13ff9be --- /dev/null +++ b/docs/fork-status.md @@ -0,0 +1,45 @@ +# NX v2606 integration fork + +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. It imports the implementation deployed against Siemens NX v2606 as version `0.2.0.dev2`. The five implementation commits follow upstream `179086b6de28a53d340132aca7678fa6ed03b422` in deployment order. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. + +## Included changes + +- NX v2606 API repairs, sketch bases, object references and multi-body results. +- Durable operation receipts, retry deduplication, explicit checkpoints and rollback. +- Assembly-aware inspection, workspace artifact transfer and capability reporting. +- Serialized execution in graphical NX with Pause, Resume and Stop controls. +- Native interference and clearance queries, inline viewport PNGs and view metadata. +- Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. +- Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. + +The opt-in integration profile exposes 77 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. + +## Start the graphical bridge and sidecar + +Use Windows with native Siemens NX v2606 and Python 3.10 or newer. The tested sidecar used Python 3.12, MCP 1.29.1 and Pydantic 2.13.5. Install from this checkout: + +```powershell +python -m pip install -e ".[dev]" +``` + +Before launching NX, set `NX_MCP_WORKSPACE` to a dedicated CAD workspace and optionally set `NX_MCP_UI_DESCRIPTOR` to the desired descriptor file. In graphical NX, play `examples/start_nx_interactive.py`. The journal returns while its retained Win32 callback dispatches commands on the NX UI thread. Stop any batch bridge using the same workspace before attaching it. + +In a separate PowerShell window, configure the sidecar to use the same workspace and descriptor: + +```powershell +$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' +python -m nx_mcp.server +``` + +Use these same environment values in the MCP client's stdio server configuration. If `NX_MCP_UI_DESCRIPTOR` was customized on the NX side, set `NX_MCP_BRIDGE_DESCRIPTOR` to that exact path. A cross-machine HTTP deployment needs separate transport/authentication and network configuration; no private machine service is bundled here. + +See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual tool usage](visual-tools.md), and the runtime `nx_capabilities` result. Long native calls can temporarily block NX. Pause releases model input for manual editing and invalidates agent references/checkpoints; reacquire references on resume. NXOpen mutations are never issued concurrently. + +## Verification and upstream proposals + +The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. + +A series of focused pull requests is preferable to a single approximately 4,800-line integration diff. Reconcile the upstream quality gates before requesting a merge, and agree on the interactive scheduler and supported NX-version policy before proposing the larger architecture changes. diff --git a/docs/fork-validation.md b/docs/fork-validation.md new file mode 100644 index 0000000..57c1728 --- /dev/null +++ b/docs/fork-validation.md @@ -0,0 +1,61 @@ +# Validation and upstream PR readiness + +## Source provenance + +The five implementation commits import the previously deployed patches in order, starting at upstream `179086b6de28a53d340132aca7678fa6ed03b422`. All tracked runtime files, original examples and package metadata were compared byte-for-byte with the deployed source and matched. The subsequent documentation commit adds these notes and a configurable copy of the public visualization runner. No runtime changes were made while creating the fork. + +## Historical live-NX evidence + +The deployed version is `0.2.0.dev2`, tested against Siemens NX v2606 in a graphical session. The latest visualization release passed seven native feature groups and eleven public MCP groups, with 77 tools discoverable through the installed Windows stdio and HTTP transports. Its targeted local checks passed 37 tests with one platform skip. These are scoped results from the preceding implementation/deployment session, not a claim that upstream's entire CI suite passed. + +The eleven public groups covered schema/UI availability, underconstrained sketch diagnostics, color/transparency restoration, visibility restore ordering, native section lifecycle, nested collision highlighting, nested isolation, occurrence appearance without prototype recoloring, assembly sections preserving geometry, a fully constrained sketch fixture, and highlight cleanup on manual handoff. Native viewport images were retrieved with checksum verification. Positive and negative plane normals were visually checked on separated colored solids. + +Native validation does not cover every NX version, reference-set configuration, legacy tool, solver conflict state or geometry topology. Photorealistic rendering and material assignment remain outside this release. + +## Fresh fork comparison — 5 September 2026 + +Both source trees were tested on the same macOS/Python 3.12 environment with localhost socket access. The first sandboxed attempt was discarded as an environment-limited run; the following results use the required socket access: + +| Check | Unmodified upstream | Imported fork | +|---|---|---| +| Complete non-real-NX pytest suite | 161 passed, 1 skipped, 1 deselected | 174 passed, 7 failed, 1 skipped, 1 deselected | +| Ruff lint | Passed | 24 findings in imported implementation/test files | + +Command, with the checkout's `src` first on `PYTHONPATH`: + +```sh +python -m pytest -q -p no:cacheprovider -m "not real_nx" --basetemp /tmp/nx-tests +python -m ruff check . +``` + +The seven failing tests are new relative to this upstream baseline: + +- `tests/test_certified_server.py::test_experimental_file_tools_still_enforce_workspace_boundary`: the path is rejected, but the integration envelope returns `NX_INVALID_ARGUMENT` where upstream expects `NX_PATH_OUTSIDE_WORKSPACE`. +- `tests/test_nx_executor.py::test_open_save_export_and_close_part_lifecycle` and `test_mcp_sidecar_bridge_and_nx_executor_complete_core_workflow`: the fake NX module lacks the `StepCreator` enum required by the deployed export implementation. +- `tests/test_tools/test_measure.py::TestMeasureDistance::test_distance_success`, `TestMeasureAngle::test_angle_success`, `TestMeasureAngle::test_angle_custom_value`, and `tests/test_tools/test_modeling.py::TestSweep::test_sweep_success`: legacy mock tests return errors after shared lookup helpers changed. The mock/lookup contract needs reconciliation. These failures do not establish that angle or sweep are broken in real NX; those tools remain unverified there. + +Do not weaken tests merely to obtain a green run. Preserve stable public error codes, update fake seams to model verified API behavior, and add focused lookup regression coverage. Formatting, lint, mypy and coverage gates need a complete pass before an upstream merge request. Mypy, coverage and the hosted OS/Python matrix were not rerun during this fork import. + +## Reproduce public visualization checks + +`examples/validate_visual_tools.py` is an explicit live test, not part of ordinary pytest. It creates and saves disposable parts/assemblies, changes the active part and control mode, and leaves its fixture files for inspection. Run it only against a dedicated test NX session and workspace; it does not restore an unrelated user's session. + +Configure `NX_MCP_TEST_ENDPOINT` with a reachable HTTP MCP endpoint, `NX_VISUAL_RESULTS` with a local result directory, and `NX_FIXED_SKETCH_FIXTURE` with a workspace-relative part containing a fully constrained first sketch with persistent constraints. Create that simple fixture in NX first. No vendor or private CAD fixture is bundled. The runner assumes the endpoint's access is already configured; adapt its client transport if your endpoint requires additional authentication headers. + +```powershell +$env:NX_MCP_TEST_ENDPOINT = 'http://127.0.0.1:8765/mcp' +$env:NX_VISUAL_RESULTS = 'visual-results' +$env:NX_FIXED_SKETCH_FIXTURE = 'fixtures/fully-constrained.prt' +python examples/validate_visual_tools.py +``` + +The runner writes JSON results and native PNGs. It is the previously exercised runner with formatting and explicit endpoint configuration; the copied runner was syntax/lint checked, not executed against NX again for the fork import. + +## Proposed upstream sequence + +1. **Compatibility fixes:** isolate legacy imports from sidecar dependencies, repair NX2606 API use and sketch coordinate handling, and reconcile the core/legacy tests and stable error codes. +2. **Recovery and references:** agree on operation IDs, lifecycle semantics, checkpoint behavior and the public result contract. This is an architecture change requiring maintainer review. +3. **Graphical bridge and capture:** propose the serialized UI scheduler, manual handoff, capability gating and native viewport artifact delivery with NX-version-specific evidence. +4. **Inspection and visual tools:** propose assembly interference/clearance, highlighting, sections, appearance and solver diagnostics on the agreed foundations. + +The imported commits preserve deployment history but are not all independently PR-ready: some later commits depend on broad earlier hardening. Extract smaller patches with their own tests when preparing submissions. Discuss the architecture with the maintainer before asking them to review the complete integration. No pull request has been opened as part of this fork import. diff --git a/docs/visual-tools.md b/docs/visual-tools.md new file mode 100644 index 0000000..6e73717 --- /dev/null +++ b/docs/visual-tools.md @@ -0,0 +1,36 @@ +# NX MCP visualization tools — 5 September 2026 + +NX MCP `0.2.0.dev2` adds ten tools to the existing integration, for **77 public tools**. Deployed to the NX v2606 machine using the visible, serialized UI-thread bridge. Journal tools remain disabled. + +| Capability | Tools | Behavior | +|---|---|---| +| Collision highlighting | `nx_highlight_collisions`, `nx_clear_highlights` | Highlights native interference pairs, including nested body occurrences; clear pairs remain unhighlighted. Uses NX selection highlighting and leaves persistent colors alone. | +| Section views | `nx_section_view`, `nx_section_control`, `nx_list_sections` | Native single-plane clips with arbitrary origin/normal, caps, edit, enable/disable and delete. Model solids and measurements remain unchanged. | +| Appearance and visibility | `nx_set_display`, `nx_display_info`, `nx_set_visibility`, `nx_restore_display` | Named colors or NX color-table indices, 0–100 transparency, show/hide and nested isolation. Returns restorable snapshots. Shared prototypes are not recolored by occurrence overrides. | +| Sketch diagnostics | `nx_sketch_diagnostics` | Native solver status, remaining DOF, persistent constraints, dimension expressions and constraint-to-geometry links. Supports active and inactive sketches. | + +## 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. + diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py new file mode 100644 index 0000000..a5903cd --- /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) == 77, 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()) From 2148867ed86800b54749944a3eae2b3a9bcc6bd7 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:28:02 +0200 Subject: [PATCH 07/69] Repair upstream regressions and add reproducible NX dev3 deployment packages --- .github/workflows/release.yml | 19 ++ README.md | 2 +- constraints-windows.txt | 4 + docs/fork-validation.md | 8 + docs/releases.md | 32 ++ docs/visual-tools.md | 1 - examples/start_nx_interactive.py | 5 +- pyproject.toml | 2 +- requirements-build.txt | 5 + requirements-windows.lock | 548 ++++++++++++++++++++++++++++++ scripts/build_release.py | 122 +++++++ scripts/install_release.ps1 | 45 +++ scripts/restore_release.ps1 | 21 ++ src/nx_mcp/__init__.py | 2 +- src/nx_mcp/bridge.py | 3 +- src/nx_mcp/certified.py | 6 +- src/nx_mcp/experimental.py | 6 +- src/nx_mcp/hardened.py | 53 +-- src/nx_mcp/integration_server.py | 61 ++-- src/nx_mcp/interactive.py | 6 +- src/nx_mcp/nx_bridge.py | 182 +++++++--- src/nx_mcp/real_smoke.py | 13 +- src/nx_mcp/recovery.py | 4 +- src/nx_mcp/runtime.py | 13 +- src/nx_mcp/utils/geometry.py | 17 +- tests/test_hardening.py | 23 +- tests/test_lookup_contract.py | 25 ++ tests/test_nx_executor.py | 12 +- tests/test_tools/test_measure.py | 1 + tests/test_tools/test_modeling.py | 1 + 30 files changed, 1131 insertions(+), 111 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 constraints-windows.txt create mode 100644 docs/releases.md create mode 100644 requirements-build.txt create mode 100644 requirements-windows.lock create mode 100644 scripts/build_release.py create mode 100644 scripts/install_release.ps1 create mode 100644 scripts/restore_release.ps1 create mode 100644 tests/test_lookup_contract.py 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/README.md b/README.md index 8db48eb..514846f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev2`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 77 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. Broader upstream quality gates still need reconciliation; see [validation and PR readiness](docs/fork-validation.md). +> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev3`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 77 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. Test, lint and typing regressions are repaired; whole-project coverage remains below the inherited threshold. See [validation and PR readiness](docs/fork-validation.md). 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 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/fork-validation.md b/docs/fork-validation.md index 57c1728..9a18143 100644 --- a/docs/fork-validation.md +++ b/docs/fork-validation.md @@ -59,3 +59,11 @@ The runner writes JSON results and native PNGs. It is the previously exercised r 4. **Inspection and visual tools:** propose assembly interference/clearance, highlighting, sections, appearance and solver diagnostics on the agreed foundations. The imported commits preserve deployment history but are not all independently PR-ready: some later commits depend on broad earlier hardening. Extract smaller patches with their own tests when preparing submissions. Discuss the architecture with the maintainer before asking them to review the complete integration. No pull request has been opened as part of this fork import. + +## Follow-up quality release: 0.2.0.dev3 + +All seven import-time test failures and the 24 lint findings were addressed. The full local suite now passes 183 tests (one platform skip), lint and sidecar mypy pass. The smoke workflow checks undo before export's save boundary and reacquires the sketch reference after undo. Added lookup tests check journal identifiers, case normalization, ambiguity and deduplication. Fake NX collections now expose the iterable interface verified in NX, and the STEP fake models the installed enum and a solid-bearing result. These remain fake seam tests, not native feature certification. + +Whole-project branch coverage is approximately 51%, below the inherited 78% gate. The gate is deliberately retained: GUI/NX modules have substantial uncovered Python paths despite their separate live-NX acceptance checks. A green functional suite does not resolve that coverage gap. Consult the current GitHub workflow result for the exact total and platform matrix. + +Versioned offline build, dependency locks, install and rollback procedures are documented in [releases](releases.md). diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..a52c953 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,32 @@ +# Versioned offline releases + +Build from a clean commit on the fork with Python 3.12: + +```sh +python -m pip install -r requirements-build.txt +python scripts/build_release.py --output dist +``` + +The package contains the committed source, its built wheel, Windows/Python 3.12 dependency wheels, a hash-locked requirements file, a SHA256 manifest and the source commit/version. It excludes working-tree changes and machine configuration. GitHub's manual **Build offline NX release** workflow runs the same builder and retains the ZIP and checksum as artifacts. It does not publish a GitHub Release or deploy automatically. + +Dependencies are refreshed deliberately with: + +```sh +uv pip compile pyproject.toml -c constraints-windows.txt --python-platform windows --python-version 3.12 --generate-hashes --no-header -o requirements-windows.lock +``` + +Verify the downloaded ZIP checksum before extraction. Preserve the live session manifest and require all user parts to be saved before restarting NX. Stop the sidecar and its associated NX bridge, then run the package installer: + +```powershell +.\install_release.ps1 -InstallRoot C:\NX-MCP -BridgeDescriptor "$env:LOCALAPPDATA\nx-mcp\interactive-bridge.json" +``` + +The installer checks the package manifest, saves a rollback copy of the source and virtual environment, installs the hash-locked dependencies offline, replaces the source/wheel together and runs `pip check`. A failed installation restores the prior runtime. Host-specific launchers, network/authentication settings and CAD remain managed on the host. Restart the existing sidecar launcher and restore loaded parts from the preservation manifest. Run native validation before considering deployment complete. + +For an explicit rollback, stop that bridge/sidecar first and use the backup's script: + +```powershell +.\restore_release.ps1 -InstallRoot C:\NX-MCP -BackupRoot C:\NX-MCP\backups\release- -BridgeDescriptor "$env:LOCALAPPDATA\nx-mcp\interactive-bridge.json" +``` + +The rollback scripts restore runtime files, not CAD geometry or unsaved edits. Preserve CAD separately before deployment. Keep backups on the NX host; virtual environments or machine receipts can contain local paths and should not be published in the fork. diff --git a/docs/visual-tools.md b/docs/visual-tools.md index 6e73717..5844f3a 100644 --- a/docs/visual-tools.md +++ b/docs/visual-tools.md @@ -33,4 +33,3 @@ Use returned opaque IDs where possible. Capture any result with `nx_screenshot`; ## 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. - diff --git a/examples/start_nx_interactive.py b/examples/start_nx_interactive.py index 7e40b41..14e5ea0 100644 --- a/examples/start_nx_interactive.py +++ b/examples/start_nx_interactive.py @@ -1,11 +1,12 @@ """Play once in the open NX UI. Returns immediately; NX retains the UI-thread host.""" -import os, sys +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 +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( diff --git a/pyproject.toml b/pyproject.toml index e5bd4c9..554fe39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev2" +version = "0.2.0.dev3" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" 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/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/install_release.ps1 b/scripts/install_release.ps1 new file mode 100644 index 0000000..84dd6a9 --- /dev/null +++ b/scripts/install_release.ps1 @@ -0,0 +1,45 @@ +# 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')) { + $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 + @{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..719f403 --- /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')) { + $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 -eq '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/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index d56b3de..7d270da 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.dev2" +__version__ = "0.2.0.dev3" diff --git a/src/nx_mcp/bridge.py b/src/nx_mcp/bridge.py index 0e68cc6..78f7863 100644 --- a/src/nx_mcp/bridge.py +++ b/src/nx_mcp/bridge.py @@ -400,6 +400,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] = {} @@ -433,7 +434,7 @@ def resolve( entry = self._objects.get(object_id) if entry is None: foreign_session = bool( - getattr(self, "session_id", None) + self.session_id is not None and not object_id.startswith("obj_" + self.session_id + "_") ) code = ( diff --git a/src/nx_mcp/certified.py b/src/nx_mcp/certified.py index 67c8eb3..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="Siemens NX integration. Runtime support varies by NX version; no general certification is claimed.") + 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: @@ -201,6 +204,7 @@ async def nx_fit_view() -> OperationResult: if enable_experimental: from nx_mcp.integration_server import configure + configure(mcp, bridge, workspace) return mcp diff --git a/src/nx_mcp/experimental.py b/src/nx_mcp/experimental.py index a57cdc4..adf8224 100644 --- a/src/nx_mcp/experimental.py +++ b/src/nx_mcp/experimental.py @@ -6,7 +6,7 @@ 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 TYPE_CHECKING, Any @@ -40,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: diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index e23e060..a7a4d81 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -4,14 +4,16 @@ """ from __future__ import annotations + import inspect import math import uuid from pathlib import Path + +from nx_mcp.inspection import InspectionMixin from nx_mcp.nx_bridge import NXOpenExecutor -from nx_mcp.runtime import NXToolError from nx_mcp.recovery import OperationStore, timestamp -from nx_mcp.inspection import InspectionMixin +from nx_mcp.runtime import NXToolError from nx_mcp.visual_tools import VisualToolsMixin READ_ONLY = { @@ -67,7 +69,7 @@ def vector(value, name="vector"): def dot(a, b): - return sum(x * y for x, y in zip(a, b)) + return sum(x * y for x, y in zip(a, b, strict=False)) def cross(a, b): @@ -91,11 +93,11 @@ def matmul(a, b): def transpose(m): - return [list(v) for v in zip(*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)] + 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]] @@ -125,7 +127,6 @@ def __init__(self, *args, **kwargs): "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, @@ -192,9 +193,9 @@ def execute(self, method, params): if legacy is None: raise NXToolError("NX_TOOL_NOT_FOUND", method) inspect.signature(legacy).bind(**params) - handler = lambda **p: execute_legacy( - method, p, self.workspace, enable_journal=self.enable_journal - ) + + def handler(**p): + return execute_legacy(method, p, self.workspace, enable_journal=self.enable_journal) else: try: inspect.signature(handler).bind(**params) @@ -292,7 +293,20 @@ def execute(self, method, params): "modified": result.get("modified"), "modified_tracking": "explicit only; null means not fully tracked", } - self._invalidate_deleted(before, after, invalidate_topology=method not in {"nx_set_display", "nx_set_visibility", "nx_restore_display", "nx_section_view", "nx_section_control", "nx_set_view", "nx_fit_view"}) + self._invalidate_deleted( + before, + after, + invalidate_topology=method + not in { + "nx_set_display", + "nx_set_visibility", + "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} ) @@ -373,9 +387,8 @@ def _resolve(self, ref, kinds=None, part=None): if ref.casefold() in { self._name(v, "").casefold(), str(getattr(v, "JournalIdentifier", "")).casefold(), - }: - if all(int(c.Tag) != int(v.Tag) for c in candidates): - candidates.append(v) + } 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", @@ -543,7 +556,7 @@ def _create_sketch(self, plane="XY", name=None, origin=None, x_axis=None, y_axis 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) + for a, b in zip(actual[k], v, strict=False) ): raise NXToolError( "NX_FRAME_MISMATCH", @@ -944,7 +957,7 @@ def _get_bounding_box(self, body=None, scope="auto", precision="conservative"): return { "min": low, "max": high, - "dimensions": [b - a for a, b in zip(low, high)], + "dimensions": [b - a for a, b in zip(low, high, strict=False)], "units": self._units(), "coordinate_frame": "work_part", "bounds_type": precision, @@ -989,7 +1002,7 @@ def _pattern(self, features, pattern_type="linear", direction="X", spacing=10, c "NX_UNSUPPORTED_ARGUMENT", "Only native linear feature patterns are implemented" ) if ( - type(count) != int + type(count) is not int or count < 2 or count > 1000 or not math.isfinite(spacing) @@ -1198,12 +1211,12 @@ def _set_component_transform(self, component, translation, 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))] + 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)) or any( + 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( @@ -1292,7 +1305,9 @@ def _capabilities(self): ), interactive=not self.session.IsBatch, api_detection={ - "native_display_modification": hasattr(self.session.DisplayManager, "NewDisplayModification"), + "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"), diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 0d6fb7b..2b34a59 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -1,6 +1,7 @@ """Uniform MCP envelopes and workspace-scoped artifacts for the NX 2606 bridge.""" from __future__ import annotations + import base64 import hashlib import inspect @@ -8,19 +9,27 @@ import os import uuid from typing import Any, Literal -from mcp.types import CallToolResult, TextContent, ToolAnnotations, ImageContent + +from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations + +from nx_mcp.recovery import OperationStore from nx_mcp.runtime import NXToolError from nx_mcp.workspace import WorkspaceViolation -from nx_mcp.recovery import OperationStore - def nx_display_info(objects: list[str]): 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): +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 @@ -44,8 +53,13 @@ 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): +def nx_section_view( + origin: list[float], + normal: list[float], + section: str | None = None, + name: str = "MCP section", + cap: bool = True, +): pass @@ -239,17 +253,16 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of DESCRIPTIONS = { - '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. 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_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. 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 the interactive NX host or switch between agent control and manual editing. 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.", @@ -391,9 +404,8 @@ async def proxy(**kwargs): elif method in SIDE: result = artifact_call(method, params, workspace) else: - if path_key := PATHS.get(method): - if params.get(path_key) is not None: - params[path_key] = str(workspace.resolve(params[path_key])) + 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": @@ -433,7 +445,12 @@ async def proxy(**kwargs): error = ( e if isinstance(e, NXToolError) - else NXToolError("NX_INVALID_ARGUMENT", str(e)) + 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"]) diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index f4b2057..593fc54 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -283,7 +283,11 @@ def control(self, mode="status"): 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.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) diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py index 517efd6..e59a15e 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -259,7 +259,11 @@ def _export_step(self, path: str) -> dict[str, Any]: 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.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 @@ -272,13 +276,20 @@ def _export_step(self, path: str) -> dict[str, Any]: 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) + 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")): + 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 and verified STEP file: {destination.name}"} + return { + "path": str(destination), + "message": f"Exported and verified STEP file: {destination.name}", + } def _create_sketch(self, plane: str = "XY", name: str | None = None) -> dict[str, Any]: normals = {"XY": (0.0, 0.0, 1.0), "XZ": (0.0, 1.0, 0.0), "YZ": (1.0, 0.0, 0.0)} @@ -671,39 +682,59 @@ def _boolean(self, boolean_type: str, targets: list[str]) -> dict[str, Any]: 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}) + 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)], - "units": str(part.PartUnits), "bodies": rows, - "message": "UF bounding boxes; may be conservative for curved geometry"} + 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): - import NXOpen.UF 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"]] + 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: - rows.append({"body": self._reference(item, "body", part, "Body"), - "volume_mm3": float(props.Volume)}) + 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"} + 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() @@ -711,11 +742,20 @@ def _add_component(self, part_path, name=None): 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) + 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"} + return { + "component": component.Name, + "tag": int(component.Tag), + "path": str(path), + "message": "Component added at origin", + } finally: if status is not None: status.Dispose() @@ -724,56 +764,114 @@ 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) + 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) + 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): - setattr(matrix,key,value) - part.ComponentAssembly.MoveComponent(matches[0], self.nxopen.Vector3d(dx,dy,dz), matrix) + 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"} + 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) + getattr(self.nxopen.View.Canned, options[key]), self.nxopen.View.ScaleAdjustment.Fit + ) return {"message": "View orientation set; batch bridge has no visible viewport"} 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)) + 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") + 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()]} + 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]} 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 index 0f74d92..e709b0d 100644 --- a/src/nx_mcp/recovery.py +++ b/src/nx_mcp/recovery.py @@ -1,12 +1,14 @@ """Durable request receipts. No NXOpen calls; safe for sidecar status queries.""" from __future__ import annotations + import hashlib import json import os import re -from pathlib import Path from datetime import datetime, timezone +from pathlib import Path + from nx_mcp.runtime import NXToolError diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index beaefa8..36e35d7 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -5,7 +5,18 @@ from dataclasses import asdict, dataclass from typing import Any, Literal -ObjectKind = Literal["part", "sketch", "curve", "feature", "body", "component", "face", "edge", "section", "constraint"] +ObjectKind = Literal[ + "part", + "sketch", + "curve", + "feature", + "body", + "component", + "face", + "edge", + "section", + "constraint", +] @dataclass(frozen=True) diff --git a/src/nx_mcp/utils/geometry.py b/src/nx_mcp/utils/geometry.py index 9306aca..e50783a 100644 --- a/src/nx_mcp/utils/geometry.py +++ b/src/nx_mcp/utils/geometry.py @@ -43,13 +43,22 @@ def resolve_object_by_name( Returns a unique match, or None. Ambiguous names are rejected. """ from nx_mcp.runtime import NXToolError + target = name.casefold() matches = [] for collection in collections: for obj in collection: - if target in {str(getattr(obj, 'Name', '')).casefold(), str(getattr(obj, 'JournalIdentifier', '')).casefold()}: - if obj not in matches: - matches.append(obj) + 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') + raise NXToolError( + "NX_AMBIGUOUS_REFERENCE", "Name matches multiple objects; use an opaque reference" + ) return matches[0] if matches else None diff --git a/tests/test_hardening.py b/tests/test_hardening.py index bc80457..c3ce395 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -4,7 +4,9 @@ import hashlib import time 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 @@ -184,10 +186,17 @@ async def call(self, method, params): 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' +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_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_nx_executor.py b/tests/test_nx_executor.py index a397075..d60da0c 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,6 +282,7 @@ def UndoToMark(self, mark, name): FAKE_NXOPEN = SimpleNamespace( + StepCreator=SimpleNamespace(ExportFromOption=SimpleNamespace(ExistingPart="existing-part")), BasePart=SimpleNamespace( SaveComponents=SimpleNamespace(TrueValue=True), CloseAfterSave=SimpleNamespace(FalseValue=False), @@ -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_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() From ee6c66e494414f0edad08ce8ec92d25aee69010f Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 16:48:18 +0200 Subject: [PATCH 08/69] Record dev3 hosted checks, native deployment and rollback verification --- docs/dev3-validation.json | 37 +++++++++++++++++++++++++++++++++++++ docs/fork-validation.md | 2 ++ 2 files changed, 39 insertions(+) create mode 100644 docs/dev3-validation.json diff --git a/docs/dev3-validation.json b/docs/dev3-validation.json new file mode 100644 index 0000000..2e748ef --- /dev/null +++ b/docs/dev3-validation.json @@ -0,0 +1,37 @@ +{ + "version": "0.2.0.dev3", + "source_commit": "2148867ed86800b54749944a3eae2b3a9bcc6bd7", + "fork": "https://github.com/xuio/NX_MCP", + "nx_version": "v2606", + "tool_count": 77, + "local_tests": { + "passed": 183, + "skipped": 1, + "deselected": 1 + }, + "lint": "passed", + "type_check": "passed", + "pre_commit": "passed", + "hosted_test_matrix": { + "passed": 9, + "total": 9 + }, + "coverage": { + "percent": 50.63, + "required": 78, + "status": "failed", + "threshold_unchanged": true + }, + "public_native_groups": { + "passed": 11, + "total": 11 + }, + "windows_stdio": "passed", + "windows_http": "passed", + "offline_install_rollback_test": "passed", + "two_builds_byte_identical": true, + "archive_sha256": "f4387913f2d6adbd0671ecaa733286cb651bd1061636729cb243102f42b572c9", + "ci_url": "https://github.com/xuio/NX_MCP/actions/runs/33971909403", + "release_workflow_url": "https://github.com/xuio/NX_MCP/actions/runs/33972182302", + "release_workflow": "passed" +} diff --git a/docs/fork-validation.md b/docs/fork-validation.md index 9a18143..2c2f7f4 100644 --- a/docs/fork-validation.md +++ b/docs/fork-validation.md @@ -67,3 +67,5 @@ All seven import-time test failures and the 24 lint findings were addressed. The Whole-project branch coverage is approximately 51%, below the inherited 78% gate. The gate is deliberately retained: GUI/NX modules have substantial uncovered Python paths despite their separate live-NX acceptance checks. A green functional suite does not resolve that coverage gap. Consult the current GitHub workflow result for the exact total and platform matrix. Versioned offline build, dependency locks, install and rollback procedures are documented in [releases](releases.md). + +Final dev3 deployment evidence is summarized in [the validation receipt](dev3-validation.json). All nine hosted test combinations, eleven deployed native MCP groups, Windows stdio/HTTP checks, isolated rollback testing and the Windows release build passed. The retained full-project coverage gate reports 50.63% against 78%. From 5e4d66eacbee8c45257c5c0088c90bf32c447efc Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 17:14:36 +0200 Subject: [PATCH 09/69] Cover recovery failure paths and preserve native cleanup outcomes --- README.md | 2 +- docs/fork-validation.md | 12 + pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/hardened.py | 4 + src/nx_mcp/inspection.py | 8 +- src/nx_mcp/visual_tools.py | 22 +- tests/conftest.py | 2 + tests/fakes/__init__.py | 369 ++++++++++++++++++++++++++++++ tests/test_artifact_recovery.py | 184 +++++++++++++++ tests/test_authoring_contracts.py | 292 +++++++++++++++++++++++ tests/test_display_lifecycle.py | 266 +++++++++++++++++++++ tests/test_native_inspection.py | 252 ++++++++++++++++++++ tests/test_recovery_state.py | 198 ++++++++++++++++ tests/test_ui_recovery.py | 224 ++++++++++++++++++ 15 files changed, 1827 insertions(+), 12 deletions(-) create mode 100644 tests/fakes/__init__.py create mode 100644 tests/test_artifact_recovery.py create mode 100644 tests/test_authoring_contracts.py create mode 100644 tests/test_display_lifecycle.py create mode 100644 tests/test_native_inspection.py create mode 100644 tests/test_recovery_state.py create mode 100644 tests/test_ui_recovery.py diff --git a/README.md b/README.md index 514846f..6d733c7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev3`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 77 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. Test, lint and typing regressions are repaired; whole-project coverage remains below the inherited threshold. See [validation and PR readiness](docs/fork-validation.md). +> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev4`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 77 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. The full local suite passes with 303 tests and 79.59% whole-project branch coverage, above the unchanged 78% gate. See [validation and PR readiness](docs/fork-validation.md). 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 diff --git a/docs/fork-validation.md b/docs/fork-validation.md index 2c2f7f4..b9b98e3 100644 --- a/docs/fork-validation.md +++ b/docs/fork-validation.md @@ -69,3 +69,15 @@ Whole-project branch coverage is approximately 51%, below the inherited 78% gate Versioned offline build, dependency locks, install and rollback procedures are documented in [releases](releases.md). Final dev3 deployment evidence is summarized in [the validation receipt](dev3-validation.json). All nine hosted test combinations, eleven deployed native MCP groups, Windows stdio/HTTP checks, isolated rollback testing and the Windows release build passed. The retained full-project coverage gate reports 50.63% against 78%. + +## Recovery coverage release: 0.2.0.dev4 + +The expanded local suite passes **303 tests**, with one platform skip and one real-NX deselection. Whole-project line/branch coverage reaches **79.59%**, above the unchanged **78%** gate. No coverage exclusions or threshold reductions were introduced. Stateful fake NX seams cover rollback, stale references, save boundaries, interrupted uploads, display snapshots, native inspection cleanup, coordinate/transform contracts and UI handoff failures. These tests verify Python control flow and arguments, not the Siemens geometry kernel. + +Fault injection reproduced three runtime defects before repair: + +- A failed screenshot-builder `Destroy()` skipped restoration of the original rendering style. Restoration now runs in a nested `finally`. +- A failed sketch `Deactivate()` skipped rollback of the temporary work region. Rollback now runs independently of deactivation; rollback failure is explicitly reported as partial. +- The executor overwrote an inspection handler's explicit partial-cleanup outcome with `not_started` when no outer undo mark existed. It now preserves that outcome, while a real outer rollback still determines its own result. + +The deployment follows the existing offline release and saved-session procedure. Historical dev3 results and receipts above remain unchanged as historical evidence. diff --git a/pyproject.toml b/pyproject.toml index 554fe39..7a2ec72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev3" +version = "0.2.0.dev4" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 7d270da..05d689b 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.dev3" +__version__ = "0.2.0.dev4" diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index a7a4d81..9caad27 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -333,6 +333,10 @@ def handler(**p): "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( diff --git a/src/nx_mcp/inspection.py b/src/nx_mcp/inspection.py index 1b15e43..997adb9 100644 --- a/src/nx_mcp/inspection.py +++ b/src/nx_mcp/inspection.py @@ -121,9 +121,11 @@ def _capture_view( builder.EnhanceEdges = True builder.Commit() finally: - if builder: - builder.Destroy() - view.RenderingStyle = old_style + 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") diff --git a/src/nx_mcp/visual_tools.py b/src/nx_mcp/visual_tools.py index 042fd7a..b55af50 100644 --- a/src/nx_mcp/visual_tools.py +++ b/src/nx_mcp/visual_tools.py @@ -497,9 +497,19 @@ def _sketch_diagnostics(self, sketch_id): ], } finally: - if activated and self.session.ActiveSketch == sketch: - sketch.Deactivate( - self.nxopen.Sketch.ViewReorient.FalseValue, self.nxopen.Sketch.UpdateLevel.Model - ) - self.session.UndoToMark(mark, None) - self.session.DeleteUndoMark(mark, None) + 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/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..5327d45 --- /dev/null +++ b/tests/fakes/__init__.py @@ -0,0 +1,369 @@ +"""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] + 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], 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.Sketches = Collection() + self.DynamicSections = 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, *_): + self.IsModified = False + self.session.marks.clear() + return NS(Dispose=Mock()) + + def SaveAs(self, path): + self.FullPath = path + return NS(Dispose=Mock()) + + 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.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 = { + "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/test_artifact_recovery.py b/tests/test_artifact_recovery.py new file mode 100644 index 0000000..e3bbb87 --- /dev/null +++ b/tests/test_artifact_recovery.py @@ -0,0 +1,184 @@ +"""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"]) +async def test_inline_capture_delivery_checks_committed_artifact(tmp_path, kind): + 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("nx_screenshot", {"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" + ) diff --git a/tests/test_authoring_contracts.py b/tests/test_authoring_contracts.py new file mode 100644 index 0000000..431c255 --- /dev/null +++ b/tests/test_authoring_contracts.py @@ -0,0 +1,292 @@ +"""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("PRODUCT('test','test'); NEXT_ASSEMBLY_USAGE_OCCURRENCE") + 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) diff --git a/tests/test_display_lifecycle.py b/tests/test_display_lifecycle.py new file mode 100644 index 0000000..9fa91d2 --- /dev/null +++ b/tests/test_display_lifecycle.py @@ -0,0 +1,266 @@ +"""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 + constraint.AssociatedExpression = NS(Name="p1", RightHandSide="10", Value=10) + 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_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_recovery_state.py b/tests/test_recovery_state.py new file mode 100644 index 0000000..11868c2 --- /dev/null +++ b/tests/test_recovery_state.py @@ -0,0 +1,198 @@ +"""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 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") + assert not rig.e._open_part(str(path), work=False, display=False)["already_loaded"] + 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 diff --git a/tests/test_ui_recovery.py b/tests/test_ui_recovery.py new file mode 100644 index 0000000..97bb2c9 --- /dev/null +++ b/tests/test_ui_recovery.py @@ -0,0 +1,224 @@ +"""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 + +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, + 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 any("View refresh" in w for w in result["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(), + 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 From 6967a446d5ff3981c7105cee08a416615d630a98 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 17:26:20 +0200 Subject: [PATCH 10/69] Record dev4 CI and deployed NX validation --- docs/dev4-validation.json | 72 +++++++++++++++++++++++++++++++++++++++ docs/fork-validation.md | 2 ++ 2 files changed, 74 insertions(+) create mode 100644 docs/dev4-validation.json diff --git a/docs/dev4-validation.json b/docs/dev4-validation.json new file mode 100644 index 0000000..e8466ed --- /dev/null +++ b/docs/dev4-validation.json @@ -0,0 +1,72 @@ +{ + "version": "0.2.0.dev4", + "source_commit": "5e4d66eacbee8c45257c5c0088c90bf32c447efc", + "fork": "https://github.com/xuio/NX_MCP", + "nx_version": "v2606", + "tool_count": 77, + "local_tests": { + "passed": 303, + "skipped": 1, + "deselected": 1 + }, + "lint": "passed", + "type_check": "passed", + "pre_commit": "passed", + "hosted_ci": { + "status": "passed", + "test_matrix_passed": 9, + "test_matrix_total": 9, + "url": "https://github.com/xuio/NX_MCP/actions/runs/33974202492" + }, + "coverage": { + "percent": 79.59, + "required": 78, + "status": "passed", + "threshold_unchanged": true, + "scope_unchanged": true + }, + "runtime_fixes": [ + "Restore rendering style even when screenshot builder destruction fails", + "Rollback temporary sketch work region even when deactivation fails", + "Preserve explicit partial outcome when native inspection cleanup fails" + ], + "public_native_groups": { + "passed": 11, + "total": 11, + "names": [ + "schemas_and_visible_ui", + "underconstrained_diagnostics_preserve_state", + "color_transparency_and_face_restore", + "visibility_restore_and_order_preflight", + "native_section_lifecycle_and_saved_state", + "nested_collision_highlighting", + "nested_isolation_and_previous_visibility", + "occurrence_appearance_does_not_recolor_prototype", + "assembly_section_preserves_geometry", + "fully_constrained_fixture_diagnostics", + "manual_handoff_clears_highlights" + ] + }, + "windows_stdio": "passed", + "windows_http": "passed", + "archive_sha256": "b8271895eb77b7e4a257e9a9de9cb7f7ef37cb59c50b7bec19add8f3e5c2fa1e", + "release_workflow": { + "status": "passed", + "url": "https://github.com/xuio/NX_MCP/actions/runs/33974451357", + "archive_sha256": "808048064db0e3114b7f7e4dd5d889bec4e32967bc0d24e8dace7072275b98b0", + "source_byte_identical_to_local": true, + "archive_byte_identical_to_local": false, + "difference": "Windows-generated metadata uses CRLF, changing wheel RECORD and manifest hashes; archive entry ordering also differs." + }, + "session_preservation": { + "restored_parts": 38, + "modified_parts": 0, + "verified_component_placements": 116, + "design_geometry_changed": false + }, + "limitations": [ + "Fault-injected cleanup failures are covered by local stateful NX test doubles, not forced in the Siemens kernel.", + "Native acceptance covers the 11 listed groups on NX v2606; it does not certify all exposed tools." + ], + "same_platform_rebuild_byte_identical": true +} diff --git a/docs/fork-validation.md b/docs/fork-validation.md index b9b98e3..d6d7e4e 100644 --- a/docs/fork-validation.md +++ b/docs/fork-validation.md @@ -81,3 +81,5 @@ Fault injection reproduced three runtime defects before repair: - The executor overwrote an inspection handler's explicit partial-cleanup outcome with `not_started` when no outer undo mark existed. It now preserves that outcome, while a real outer rollback still determines its own result. The deployment follows the existing offline release and saved-session procedure. Historical dev3 results and receipts above remain unchanged as historical evidence. + +Final dev4 deployment evidence is summarized in [the validation receipt](dev4-validation.json). All hosted CI jobs, eleven deployed graphical NX groups, and Windows stdio/HTTP checks pass. The original 38 saved parts and all 116 component placements were restored unchanged. Local and hosted Windows packages contain byte-identical source; generated metadata line endings and archive ordering differ across build platforms. From 1dafbc996f9dbf0c4727c70cce7e914d72ee9aac Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 18:30:10 +0200 Subject: [PATCH 11/69] Add NX authoring, inspection reports and reversible change previews --- README.md | 4 +- docs/authoring-review.md | 41 ++ docs/fork-validation.md | 6 + examples/validate_authoring_tools.py | 348 +++++++++++ examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/authoring.py | 656 ++++++++++++++++++++ src/nx_mcp/authoring_server.py | 148 +++++ src/nx_mcp/capability_manifest.json | 87 ++- src/nx_mcp/hardened.py | 38 +- src/nx_mcp/integration_server.py | 25 + src/nx_mcp/interactive.py | 1 + src/nx_mcp/review_tools.py | 550 +++++++++++++++++ src/nx_mcp/runtime.py | 1 + tests/test_authoring_review.py | 860 +++++++++++++++++++++++++++ tests/test_visual_tools.py | 2 +- 17 files changed, 2765 insertions(+), 8 deletions(-) create mode 100644 docs/authoring-review.md create mode 100644 examples/validate_authoring_tools.py create mode 100644 src/nx_mcp/authoring.py create mode 100644 src/nx_mcp/authoring_server.py create mode 100644 src/nx_mcp/review_tools.py create mode 100644 tests/test_authoring_review.py diff --git a/README.md b/README.md index 6d733c7..6419875 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev4`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 77 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. The full local suite passes with 303 tests and 79.59% whole-project branch coverage, above the unchanged 78% gate. See [validation and PR readiness](docs/fork-validation.md). +> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev5`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 94 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. The full local suite passes with 393 tests and 81.49% whole-project branch coverage, above the unchanged 78% gate. See [validation and PR readiness](docs/fork-validation.md). 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 @@ -138,3 +138,5 @@ release gates. src="https://raw.githubusercontent.com/DreamEnding/NX_MCP/star-history/assets/star-history.svg" /> + +Authoring and review tools add geometric selection, expression binding, model health, sketch editing, assembly maintenance, saved presentations, inspection reports, compact summaries, and reversible previews. See [supported operations and limits](docs/authoring-review.md). diff --git a/docs/authoring-review.md b/docs/authoring-review.md new file mode 100644 index 0000000..6c62828 --- /dev/null +++ b/docs/authoring-review.md @@ -0,0 +1,41 @@ +# Authoring and review tools (NX v2606) + +This release adds 17 tools to the existing 77-tool profile. All native calls run serially on the NX UI thread. Existing operation IDs, deduplication and model rollback envelopes apply. Names remain separate from opaque session/owner-scoped references. + +## 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 nearest/highest/lowest bounding-box center. Coordinates and radii use work-part units. Bounds and rankings are conservative: they are not exact nearest-surface measurements. Use `nx_measure_distance` for BREP minimum distance. 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. + +## Validation + +Local tests use stateful NX seams to check ownership, error cleanup, stale preview rejection, retry behavior, artifact integrity and display restoration. They do not simulate the Siemens kernel. + +The native runner exercises eight groups on disposable geometry: expression binding/rollback and health, geometric selection/highlight, sketch reopening/constraints/deletion, assembly maintenance/patterns, saved presentations/camera, inspection ZIPs, preview acceptance/staleness, and summary pagination. The separate eleven-group visualization runner protects the preceding release. + +Run `examples/validate_authoring_tools.py` with `NX_MCP_TEST_ENDPOINT` and optionally `NX_AUTHORING_RESULTS`. It creates a unique workspace subdirectory and leaves test parts for inspection. It changes active parts and does not restore an unrelated user's session; use a dedicated validation session or preserve/restore the original session externally. The runner verifies downloaded artifact checksums and ZIP manifests. diff --git a/docs/fork-validation.md b/docs/fork-validation.md index d6d7e4e..c73abb4 100644 --- a/docs/fork-validation.md +++ b/docs/fork-validation.md @@ -83,3 +83,9 @@ Fault injection reproduced three runtime defects before repair: The deployment follows the existing offline release and saved-session procedure. Historical dev3 results and receipts above remain unchanged as historical evidence. Final dev4 deployment evidence is summarized in [the validation receipt](dev4-validation.json). All hosted CI jobs, eleven deployed graphical NX groups, and Windows stdio/HTTP checks pass. The original 38 saved parts and all 116 component placements were restored unchanged. Local and hosted Windows packages contain byte-identical source; generated metadata line endings and archive ordering differ across build platforms. + +## Authoring and review release: 0.2.0.dev5 + +The opt-in profile adds 17 tools, for 94 total. The local suite passes 393 tests with 81.49% whole-project branch coverage against the unchanged 78% gate. Eight native acceptance groups pass for expressions/binding and health, geometry selection, sketch edits, component maintenance/instances, saved presentations, inspection artifacts, reversible previews and summaries. See [contracts and limits](authoring-review.md). Native probes caught differences in expression value units, face normal conventions, face lookup and sketch constraint enums before deployment. Circular-edge queries use the verified direct UF curve API. + +The public runner and preceding eleven visualization groups are rerun after deployment; their final receipts distinguish staged handler tests from public transport validation. diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py new file mode 100644 index 0000000..be78ffb --- /dev/null +++ b/examples/validate_authoring_tools.py @@ -0,0 +1,348 @@ +"""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) == 94 + 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["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_visual_tools.py b/examples/validate_visual_tools.py index a5903cd..7c13719 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 77, len(names) + assert len(names) == 94, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 7a2ec72..92e6d34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev4" +version = "0.2.0.dev5" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 05d689b..a5b203e 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.dev4" +__version__ = "0.2.0.dev5" diff --git a/src/nx_mcp/authoring.py b/src/nx_mcp/authoring.py new file mode 100644 index 0000000..7ac3db2 --- /dev/null +++ b/src/nx_mcp/authoring.py @@ -0,0 +1,656 @@ +"""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") + 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)) + 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) + row["rank_value"] = ( + row["distance_to_bounds_center"] + 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": "conservative bounding-box center; use nx_measure_distance for exact BREP distance", + "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 _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, + "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..31eece9 --- /dev/null +++ b/src/nx_mcp/authoring_server.py @@ -0,0 +1,148 @@ +"""Public signatures and contracts for authoring/review tools.""" + +from __future__ import annotations + +from typing import Any, Literal + +READ_ONLY = {"nx_find_geometry", "nx_list_expressions", "nx_model_health", "nx_model_summary"} +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. Rank conservative bounds centers, not exact surface distances. 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, +): + """Edit 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, + } + ) diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index c6c39f4..43915ef 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-visual-tools-r1", + "revision": "2606-authoring-review-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -387,6 +387,91 @@ "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", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" + }, + "nx_highlight_objects": { + "status": "tested", + "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.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/authoring-review.md for supported operations and limits.", + "evidence_type": "real_NX_v2606_and_local_stateful_seams" } }, "limitations": [ diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 9caad27..d28c8f7 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -10,9 +10,13 @@ import uuid from pathlib import Path +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.inspection import InspectionMixin from nx_mcp.nx_bridge import NXOpenExecutor from nx_mcp.recovery import OperationStore, timestamp +from nx_mcp.review_tools import ReviewToolsMixin from nx_mcp.runtime import NXToolError from nx_mcp.visual_tools import VisualToolsMixin @@ -103,7 +107,13 @@ def add(a, b): IDENTITY = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] -class HardenedExecutor(VisualToolsMixin, InspectionMixin, NXOpenExecutor): +READ_ONLY.update(AUTHORING_READ_ONLY) +NON_MODEL.update(AUTHORING_NON_MODEL) + + +class HardenedExecutor( + AuthoringMixin, ReviewToolsMixin, VisualToolsMixin, InspectionMixin, NXOpenExecutor +): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.session_id = uuid.uuid4().hex @@ -117,6 +127,23 @@ def __init__(self, *args, **kwargs): self._handlers.update( { "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, @@ -298,6 +325,8 @@ def handler(**p): after, invalidate_topology=method not in { + "nx_set_camera", + "nx_restore_presentation", "nx_set_display", "nx_set_visibility", "nx_restore_display", @@ -311,6 +340,8 @@ def handler(**p): {"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: @@ -349,6 +380,8 @@ def handler(**p): 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", @@ -608,7 +641,7 @@ def _sketch_arc_legacy(self, cx, cy, radius, start_angle, end_angle, sketch_id=N self._point_on_sketch(sketch, {"x": cx, "y": cy}), self.nxopen.Vector3d(*f["x_axis"]), self.nxopen.Vector3d(*f["y_axis"]), - radius, + float(radius), math.radians(start_angle), math.radians(end_angle), ) @@ -1347,6 +1380,7 @@ def _finish_sketch(self, sketch_id): def _snapshot(self, part): groups = [ + ("expression", getattr(part, "Expressions", [])), ("body", part.Bodies), ("feature", part.Features), ("curve", part.Curves), diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 2b34a59..f67f304 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -12,6 +12,7 @@ from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations +from nx_mcp import authoring_server from nx_mcp.recovery import OperationStore from nx_mcp.runtime import NXToolError from nx_mcp.workspace import WorkspaceViolation @@ -324,6 +325,15 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_workspace_list", "nx_download_file", } +READ_ONLY.update(authoring_server.READ_ONLY) +DESCRIPTIONS.update( + { + name: obj.__doc__ or name + for name, obj in vars(authoring_server).items() + if name.startswith("nx_") and inspect.isfunction(obj) + } +) + SIDE = { "nx_workspace_list", "nx_download_file", @@ -332,6 +342,10 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_cancel_operation", } PATHS = { + "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", @@ -361,6 +375,13 @@ def configure(mcp, bridge, workspace): 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).items() + if name.startswith("nx_") and inspect.isfunction(obj) + } + ) names = set(existing) | set(definitions) for name in names: old = existing.get(name) @@ -484,6 +505,10 @@ async def proxy(**kwargs): 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} + ) original_call = mcp.call_tool async def uniform_call(name, arguments): diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index 593fc54..d9ea394 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -280,6 +280,7 @@ def control(self, mode="status"): 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() diff --git a/src/nx_mcp/review_tools.py b/src/nx_mcp/review_tools.py new file mode 100644 index 0000000..da6b81c --- /dev/null +++ b/src/nx_mcp/review_tools.py @@ -0,0 +1,550 @@ +"""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), + "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), + "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 36e35d7..cb31cab 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -16,6 +16,7 @@ "edge", "section", "constraint", + "expression", ] diff --git a/tests/test_authoring_review.py b/tests/test_authoring_review.py new file mode 100644 index 0000000..90f7b53 --- /dev/null +++ b/tests/test_authoring_review.py @@ -0,0 +1,860 @@ +"""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) + ) + + 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 + 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_visual_tools.py b/tests/test_visual_tools.py index 7fbd3d2..3c5f3a8 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 77 + assert len(tools) == 94 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 9b89fb5daaefdd900a7c404b3329f9791749ac88 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 18:40:02 +0200 Subject: [PATCH 12/69] Return workspace-relative artifact paths for reports and presentations --- examples/validate_authoring_tools.py | 4 +++- src/nx_mcp/review_tools.py | 2 ++ tests/test_authoring_review.py | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index be78ffb..99402fa 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -324,7 +324,9 @@ def collect(value): for artifact in artifacts: content = bytearray() while True: - chunk = await call("nx_download_file", path=artifact["path"], offset=len(content)) + chunk = await call( + "nx_download_file", path=artifact["artifact_path"], offset=len(content) + ) content.extend(base64.b64decode(chunk["data_base64"])) if chunk["eof"]: break diff --git a/src/nx_mcp/review_tools.py b/src/nx_mcp/review_tools.py index da6b81c..a340eec 100644 --- a/src/nx_mcp/review_tools.py +++ b/src/nx_mcp/review_tools.py @@ -165,6 +165,7 @@ def _save_presentation(self, path): 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), @@ -394,6 +395,7 @@ def _inspection_report( 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"]), diff --git a/tests/test_authoring_review.py b/tests/test_authoring_review.py index 90f7b53..f3df993 100644 --- a/tests/test_authoring_review.py +++ b/tests/test_authoring_review.py @@ -699,6 +699,7 @@ def capture(path=None, **kw): {"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", From 8f22895a248314b07bd9b19db862adb1c192cf34 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 18:55:20 +0200 Subject: [PATCH 13/69] Record deployed dev5 native and public MCP acceptance --- docs/dev5-validation.json | 86 +++++++++++++++++++++++++++++++++++++++ docs/fork-validation.md | 2 + 2 files changed, 88 insertions(+) create mode 100644 docs/dev5-validation.json diff --git a/docs/dev5-validation.json b/docs/dev5-validation.json new file mode 100644 index 0000000..29a6e35 --- /dev/null +++ b/docs/dev5-validation.json @@ -0,0 +1,86 @@ +{ + "version": "0.2.0.dev5", + "source_commit": "9b89fb5daaefdd900a7c404b3329f9791749ac88", + "fork": "https://github.com/xuio/NX_MCP", + "nx_version": "v2606", + "tool_count": 94, + "new_tool_count": 17, + "features": [ + "geometric selection and highlighting", + "expressions and supported parameter binding", + "model health and pending-update rebuild", + "atomic sketch editing", + "inspection report ZIPs with native viewport images", + "component maintenance and independent instance patterns", + "camera and saved presentations", + "reversible change previews with guarded accept/discard", + "paginated model summaries" + ], + "local_tests": { + "passed": 393, + "skipped": 1, + "deselected": 1 + }, + "coverage": { + "percent": 81.49, + "required": 78, + "threshold_unchanged": true, + "scope_unchanged": true + }, + "lint": "passed", + "type_check": "passed", + "pre_commit": "passed", + "hosted_ci": { + "status": "passed", + "url": "https://github.com/xuio/NX_MCP/actions/runs/33978613971", + "test_matrix_passed": 9, + "test_matrix_total": 9 + }, + "hosted_release": { + "status": "passed", + "url": "https://github.com/xuio/NX_MCP/actions/runs/33978733276" + }, + "native_authoring": { + "passed": 8, + "total": 8, + "groups": [ + "expressions_binding_health_rollback", + "geometric_selection_and_highlight", + "sketch_reopen_edit_constraints_delete", + "assembly_maintenance_and_instances", + "saved_presentation_and_camera", + "inspection_report_artifacts_restore", + "change_preview_accept_and_staleness", + "compact_summary_pagination" + ] + }, + "native_visualization": { + "passed": 11, + "total": 11 + }, + "supplemental_clash_report": { + "known_overlap_mm3": 500, + "geometry_preserved": true, + "camera_preserved": true, + "saved_state_preserved": true, + "zip_and_file_checksums_verified": true + }, + "windows_stdio": "passed", + "windows_http": "passed", + "artifact_downloads": "ZIP and PNG SHA-256 verified; ZIP member manifest verified", + "session_preservation": { + "restored_parts": 38, + "modified_parts": 0, + "verified_component_placements": 116, + "design_geometry_changed": false + }, + "archive_sha256": "1a5ca7bd3aeccaf004acc42dc8b09365dc33cf8607c746a9789eea00ed107cc0", + "limitations": [ + "Component patterns create independent occurrences; they are not associative native component patterns.", + "Parameter binding and sketch operations are limited to the explicitly documented types.", + "Geometric candidate ranking uses conservative bounds centers; use native distance for exact clearance.", + "Saved presentations require compatible journal references after model revisions.", + "Viewport PNGs are native raster captures; materials and photorealistic rendering are not added.", + "Native and local results describe tested scope, not general NX certification." + ] +} diff --git a/docs/fork-validation.md b/docs/fork-validation.md index c73abb4..456993a 100644 --- a/docs/fork-validation.md +++ b/docs/fork-validation.md @@ -89,3 +89,5 @@ Final dev4 deployment evidence is summarized in [the validation receipt](dev4-va The opt-in profile adds 17 tools, for 94 total. The local suite passes 393 tests with 81.49% whole-project branch coverage against the unchanged 78% gate. Eight native acceptance groups pass for expressions/binding and health, geometry selection, sketch edits, component maintenance/instances, saved presentations, inspection artifacts, reversible previews and summaries. See [contracts and limits](authoring-review.md). Native probes caught differences in expression value units, face normal conventions, face lookup and sketch constraint enums before deployment. Circular-edge queries use the verified direct UF curve API. The public runner and preceding eleven visualization groups are rerun after deployment; their final receipts distinguish staged handler tests from public transport validation. + +Final dev5 deployment evidence is recorded in [the validation receipt](dev5-validation.json). The corrected deployed commit passes all hosted CI jobs, all eight public authoring groups, the existing eleven visualization groups, Windows stdio/HTTP checks, and a supplemental 500 mm³ interference report with native close-up and preserved saved state. Downloaded report/PNG checksums and ZIP member hashes were verified. All 38 saved user parts and 116 component placements were restored unchanged. Report and presentation results include a workspace-relative `artifact_path` for download tools. From d9b5a1c5dd46c4e75ece66425a9cf1844aad0034 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 19:36:02 +0200 Subject: [PATCH 14/69] Add exact selection, native assembly patterns and verified sketch relations --- README.md | 2 + docs/advanced-authoring.md | 41 ++ docs/authoring-review.md | 4 +- docs/upstream-review.md | 45 ++ examples/validate_advanced_tools.py | 328 +++++++++++++ examples/validate_authoring_tools.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/advanced_authoring.py | 707 +++++++++++++++++++++++++++ src/nx_mcp/authoring.py | 39 +- src/nx_mcp/authoring_server.py | 70 ++- src/nx_mcp/capability_manifest.json | 56 ++- src/nx_mcp/hardened.py | 26 +- src/nx_mcp/runtime.py | 1 + src/nx_mcp/visual_tools.py | 6 +- tests/test_advanced_authoring.py | 489 ++++++++++++++++++ tests/test_authoring_review.py | 4 +- tests/test_display_lifecycle.py | 7 +- tests/test_visual_tools.py | 2 +- 20 files changed, 1807 insertions(+), 28 deletions(-) create mode 100644 docs/advanced-authoring.md create mode 100644 docs/upstream-review.md create mode 100644 examples/validate_advanced_tools.py create mode 100644 src/nx_mcp/advanced_authoring.py create mode 100644 tests/test_advanced_authoring.py diff --git a/README.md b/README.md index 6419875..d2facdf 100644 --- a/README.md +++ b/README.md @@ -140,3 +140,5 @@ release gates. Authoring and review tools add geometric selection, expression binding, model health, sketch editing, assembly maintenance, saved presentations, inspection reports, compact summaries, and reversible previews. See [supported operations and limits](docs/authoring-review.md). + +Advanced NX 2606 tools: [exact selection, associative component patterns and sketch dimensions](docs/advanced-authoring.md). Proposed upstream review slices are documented in the [review package](docs/upstream-review.md); no PR is opened by the release workflow. diff --git a/docs/advanced-authoring.md b/docs/advanced-authoring.md new file mode 100644 index 0000000..9945726 --- /dev/null +++ b/docs/advanced-authoring.md @@ -0,0 +1,41 @@ +# Advanced authoring on NX 2606 + +Version `0.2.0.dev6` adds ten tools (104 in the opt-in integration profile). Every native call remains serialized on the NX thread. Model mutations retain operation-ID deduplication, checkpoint rollback, and owner-scoped references. The ordinary-instance `nx_pattern_components` tool is unchanged. + +## 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. + +## Validation + +Run `examples/validate_advanced_tools.py` against a disposable NX workspace via `NX_MCP_TEST_ENDPOINT`; optionally set `NX_ADVANCED_RESULTS` for local receipts. It creates fixture parts and does not close or save unrelated user parts. Restore your original active part afterward. The local regression suite distinguishes fake NX boundary tests from native geometry tests. Deployment acceptance and scoped limitations are recorded separately in `dev6-validation.json`. diff --git a/docs/authoring-review.md b/docs/authoring-review.md index 6c62828..511e02f 100644 --- a/docs/authoring-review.md +++ b/docs/authoring-review.md @@ -1,10 +1,10 @@ # Authoring and review tools (NX v2606) -This release adds 17 tools to the existing 77-tool profile. All native calls run serially on the NX UI thread. Existing operation IDs, deduplication and model rollback envelopes apply. Names remain separate from opaque session/owner-scoped references. +The dev5 release added 17 tools to the existing 77-tool profile. Dev6 adds ten more; see [advanced authoring](advanced-authoring.md). All native calls run serially on the NX UI thread. Existing operation IDs, deduplication and model rollback envelopes apply. Names remain separate from opaque session/owner-scoped references. ## 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 nearest/highest/lowest bounding-box center. Coordinates and radii use work-part units. Bounds and rankings are conservative: they are not exact nearest-surface measurements. Use `nx_measure_distance` for BREP minimum distance. Candidate results are paginated; use `nx_highlight_objects` to inspect choices, then `nx_clear_highlights`. +`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. diff --git a/docs/upstream-review.md b/docs/upstream-review.md new file mode 100644 index 0000000..3f8e21b --- /dev/null +++ b/docs/upstream-review.md @@ -0,0 +1,45 @@ +# Upstream review package — proposal only + +No pull request has been opened. This document prepares the discussion with DreamEnding/NX_MCP; it does not imply maintainer agreement or a supported-version commitment from Siemens. + +Comparison base: `179086b6de28a53d340132aca7678fa6ed03b422`, the recorded upstream base of this fork. The fork retains upstream history and the MIT license. Review the actual current diff with: + +```sh +git diff --stat 179086b6de28a53d340132aca7678fa6ed03b422...master +git log --reverse --oneline 179086b6de28a53d340132aca7678fa6ed03b422..master +``` + +The integration has grown beyond a suitable single PR. These are proposed review slices, in dependency order. Existing shared modules span slices; preparing mergeable branches will require extracting cohesive changes, not blindly cherry-picking deployment commits. + +| Proposed slice | Concrete change and principal files | Reviewer evidence | +| --- | --- | --- | +| 1. NX 2606 correctness repairs | Sketch local-to-world mapping, default extrusion normal, supported feature lookup/builders, STEP translator, loaded-part activation and multi-body results. `nx_bridge.py`, `hardened.py`, `utils/selection.py`. | Principal/custom plane solids, edited bounds/volume, import round trips, existing workflow regression tests. | +| 2. References and recovery | Owner/session/generation references, stale rejection, durable mutation IDs, deduplication and checkpoint rollback. `runtime.py`, `recovery.py`, executor and bridge boundaries. | Retry/failure/rollback tests; explicit partial/unknown outcomes; save-boundary semantics. | +| 3. Assembly inspection and artifacts | Occurrence-aware bounds/distance/interference/clearance, workspace transfers, structured results and capability metadata. `inspection.py`, `integration_server.py`, `workspace.py`. | Native transformed fixtures, analytic overlap volumes, ZIP/PNG checksums and round-trip receipts. | +| 4. Graphical NX host | Serialized Win32 UI-thread dispatch, manual handoff and view capture. `interactive.py`, graphical startup example, `bridge.py`. | Native thread identity, visible viewport artifacts, handoff and stop tests. Windows-specific scheduler must remain isolated. | +| 5. Authoring and presentation | Display/sections, expressions, atomic sketch edits, report/presentation/preview tools, exact selection and associative component patterns. `visual_tools.py`, `authoring.py`, `advanced_authoring.py`, `review_tools.py`. | Public MCP acceptance runners, editable native patterns, saved/reopened geometry rules, parameter and constraint checks. | +| 6. Reproducible release and documentation | Hash-locked Windows dependencies, offline packaging/install/rollback, CI and scoped validation receipts. `scripts/`, lock files, release workflow, docs. | Hosted OS/Python matrix, coverage gate, Windows release artifact and installed-source hashes. | + +## Draft description for the first proposal + +**Title:** Correct sketch coordinate mapping and native feature lookup on NX 2606 + +Sketch profiles requested in XZ could previously be created in XY, and feature lookup used an unsupported collection method. Map sketch-local points through the requested basis, derive extrusion direction from the sketch normal, and use the installed collection API. Return sketch origin/basis/normal so callers can verify the coordinate frame. + +Validation should accompany the extracted branch: XY/XZ/YZ and arbitrary-basis curve coordinates, independently expected solid bounds/volumes, a feature edit, and failure rollback. Keep unrelated assembly/UI tools out of this first review so the geometry fix is straightforward to assess. + +## Compatibility and decisions for maintainers + +- The larger integration is opt-in; keep the upstream default surface stable. Version-specific capability status must mean a documented tested scope, not general certification. +- Keep opaque IDs distinct from names/journals and use consistent structured success/error envelopes. Review that additive metadata is acceptable to existing clients. +- Nearest geometry ordering changes in dev6 from bounds-center distance to actual BREP distance. Independent component patterns retain their tool; native associative patterns use distinct tools. +- NXOpen mutations must remain serialized. The graphical timer is Windows-specific; any alternative host needs equivalent thread and handoff guarantees. +- Journal execution remains separately disabled by default. File transfer remains confined to the configured workspace. Private CAD, deployment credentials and host provisioning are excluded from the public fork. +- Confirm an NX version/CI policy and how maintainers want native evidence supplied. Mock tests cannot establish geometric correctness or SDK availability. +- Decide whether the larger authoring tools belong in core, an optional profile or a separate package before extracting those review branches. + +## Evidence and scope + +See [fork validation](fork-validation.md), [dev5 acceptance](dev5-validation.json), [advanced authoring](advanced-authoring.md), and the current `nx_capabilities` manifest. Retain historical receipts as historical; do not rewrite old test counts as current results. Deployment commits include documentation-only follow-ups, so cite the runtime source commit recorded in each receipt. + +No claim is made that every boolean, blend, chamfer, hole, sweep, mirror, mate, drawing or PDF operation is broken or verified. Controller design clashes and unresolved envelopes are design evidence, not MCP defects. A scoped native pass is evidence for its fixture and API, not a universal NX certificate. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py new file mode 100644 index 0000000..aed2431 --- /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) == 104 + 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_authoring_tools.py b/examples/validate_authoring_tools.py index 99402fa..0befedb 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 94 + assert len(tools) == 104 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 7c13719..26a4246 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 94, len(names) + assert len(names) == 104, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 92e6d34..442264e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev5" +version = "0.2.0.dev6" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index a5b203e..1860bab 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.dev5" +__version__ = "0.2.0.dev6" diff --git a/src/nx_mcp/advanced_authoring.py b/src/nx_mcp/advanced_authoring.py new file mode 100644 index 0000000..462fac7 --- /dev/null +++ b/src/nx_mcp/advanced_authoring.py @@ -0,0 +1,707 @@ +"""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 str(service.PatternType), + "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, + ) + 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): + import NXOpen.GeometricUtilities + + if spacing is None and count is None: + raise NXToolError("NX_INVALID_ARGUMENT", "Supply spacing and/or count") + self._pattern_inputs(spacing, count) + obj = self._resolve(pattern, {"component_pattern"}) + b = self._work_part().ComponentAssembly.CreateComponentPatternBuilder(obj) + try: + if ( + not b.Associative + or b.PatternService.PatternType + != NXOpen.GeometricUtilities.PatternDefinition.PatternEnum.Linear + ): + raise NXToolError( + "NX_UNSUPPORTED_EDIT", "Only associative linear component patterns are editable" + ) + d = b.PatternService.RectangularDefinition + if count is not None: + d.XSpacing.NCopies.RightHandSide = str(count) + if spacing is not None: + d.XSpacing.PitchDistance.RightHandSide = str(float(spacing)) + b.Commit() + finally: + b.Destroy() + self._update_model() + result = self._component_pattern_record(obj) + if count is not None and result["total_instances"] != count: + 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/authoring.py b/src/nx_mcp/authoring.py index 7ac3db2..25d141f 100644 --- a/src/nx_mcp/authoring.py +++ b/src/nx_mcp/authoring.py @@ -237,7 +237,7 @@ def _find_geometry( 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") + self._require_api(uf.Modeling, "AskFaceData", "AskMinimumDist3") values = self._geometry(owner) entities = {} for body in values: @@ -265,7 +265,13 @@ def _find_geometry( if code == 22: row["normal"] = list(direct) if code == 16: - row.update(radius=rad, axis=list(direct), axis_point=list(point)) + 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( @@ -285,18 +291,35 @@ def _find_geometry( 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) - row["rank_value"] = ( - row["distance_to_bounds_center"] - if order == "nearest" - else center["XYZ".index(axis)] - ) + 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": "conservative bounding-box center; use nx_measure_distance for exact BREP distance", + "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", } diff --git a/src/nx_mcp/authoring_server.py b/src/nx_mcp/authoring_server.py index 31eece9..6be9bd4 100644 --- a/src/nx_mcp/authoring_server.py +++ b/src/nx_mcp/authoring_server.py @@ -4,7 +4,17 @@ from typing import Any, Literal -READ_ONLY = {"nx_find_geometry", "nx_list_expressions", "nx_model_health", "nx_model_summary"} +READ_ONLY = { + "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", @@ -26,7 +36,7 @@ def nx_find_geometry( offset: int = 0, limit: int = 50, ): - """Find geometry within a body/feature/component or full assembly. Coordinates and radii use work-part units/frame. Rank conservative bounds centers, not exact surface distances. 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.""" + """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]): @@ -146,3 +156,59 @@ def nx_finish_preview(preview_id: str, action: Literal["accept", "discard"]): "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): + """Edit pitch and/or total count (including seed, 2–100) of a native associative linear component pattern by ID. Read back native parameters and member poses. Unsupported native pattern types are rejected before editing.""" + + +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.""" diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 43915ef..9740559 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-authoring-review-r1", + "revision": "2606-advanced-authoring-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -390,8 +390,8 @@ }, "nx_find_geometry": { "status": "tested", - "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.md for supported operations and limits.", - "evidence_type": "real_NX_v2606_and_local_stateful_seams" + "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", @@ -472,6 +472,56 @@ "status": "tested", "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.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_and_local_boundary_tests", + "scope": "Native linear pitch/count edited to 4 total at 20 mm; poses read back; association persists after save/reopen." + }, + "nx_list_component_patterns": { + "status": "tested", + "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", + "scope": "Native GetAllComponentPatterns enumeration, expression and member read-back; non-assembly guard." + }, + "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." } }, "limitations": [ diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index d28c8f7..73aa569 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -10,6 +10,7 @@ import uuid from pathlib import Path +from nx_mcp.advanced_authoring import AdvancedAuthoringMixin 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 @@ -112,7 +113,12 @@ def add(a, b): class HardenedExecutor( - AuthoringMixin, ReviewToolsMixin, VisualToolsMixin, InspectionMixin, NXOpenExecutor + AdvancedAuthoringMixin, + AuthoringMixin, + ReviewToolsMixin, + VisualToolsMixin, + InspectionMixin, + NXOpenExecutor, ): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -126,6 +132,16 @@ def __init__(self, *args, **kwargs): self._current_operation = None self._handlers.update( { + "nx_resolve_geometry": self._resolve_geometry, + "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, @@ -1352,6 +1368,10 @@ def _capabilities(self): 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", @@ -1380,6 +1400,10 @@ def _finish_sketch(self, sketch_id): def _snapshot(self, part): groups = [ + ( + "component_pattern", + self._component_patterns(part), + ), ("expression", getattr(part, "Expressions", [])), ("body", part.Bodies), ("feature", part.Features), diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index cb31cab..7b2d5eb 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -17,6 +17,7 @@ "section", "constraint", "expression", + "component_pattern", ] diff --git a/src/nx_mcp/visual_tools.py b/src/nx_mcp/visual_tools.py index b55af50..0276a00 100644 --- a/src/nx_mcp/visual_tools.py +++ b/src/nx_mcp/visual_tools.py @@ -457,11 +457,7 @@ def _sketch_diagnostics(self, sketch_id): } if hasattr(constraint, "AssociatedExpression") and constraint.AssociatedExpression: exp = constraint.AssociatedExpression - row["expression"] = { - "name": exp.Name, - "formula": exp.RightHandSide, - "value": exp.Value, - } + row["expression"] = self._expression_record(exp) constraint_records.append(row) geometry = [] for curve in sketch.GetAllGeometry(): diff --git a/tests/test_advanced_authoring.py b/tests/test_advanced_authoring.py new file mode 100644 index 0000000..6dcab8a --- /dev/null +++ b/tests/test_advanced_authoring.py @@ -0,0 +1,489 @@ +"""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", + RectangularDefinition=NS( + 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="Only 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_authoring_review.py b/tests/test_authoring_review.py index f3df993..cb9f320 100644 --- a/tests/test_authoring_review.py +++ b/tests/test_authoring_review.py @@ -63,7 +63,9 @@ def edit(exp, formula): ), ) rig.uf.Modeling = NS( - AskBodyConsistency=Mock(return_value=(0, [], [])), AskFaceData=lambda tag: geom(tag) + 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): diff --git a/tests/test_display_lifecycle.py b/tests/test_display_lifecycle.py index 9fa91d2..f245ae1 100644 --- a/tests/test_display_lifecycle.py +++ b/tests/test_display_lifecycle.py @@ -201,7 +201,12 @@ def test_solver_status_and_constraint_links_preserve_edit_state(rig, status, dof sk.geometry = [curve] constraint = Object() constraint.ConstraintType = 1 - constraint.AssociatedExpression = NS(Name="p1", RightHandSide="10", Value=10) + 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()) diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py index 3c5f3a8..160d2b9 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 94 + assert len(tools) == 104 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 94615a6695d4a5622a591efbf8fa7b32994462e5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 19:51:31 +0200 Subject: [PATCH 15/69] Record dev6 deployment acceptance and upstream review status --- docs/dev6-validation.json | 92 +++++++++++++++++++++++++++++++++++++++ docs/fork-status.md | 6 +-- 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 docs/dev6-validation.json diff --git a/docs/dev6-validation.json b/docs/dev6-validation.json new file mode 100644 index 0000000..f99cf4d --- /dev/null +++ b/docs/dev6-validation.json @@ -0,0 +1,92 @@ +{ + "version": "0.2.0.dev6", + "source_commit": "d9b5a1c5dd46c4e75ece66425a9cf1844aad0034", + "fork": "https://github.com/xuio/NX_MCP", + "nx_version": "v2606", + "tool_count": 104, + "new_tool_count": 10, + "features": [ + "native BREP nearest geometry and reusable selection rules", + "bore axes and coaxial grouping", + "native associative component patterns with count/pitch editing", + "owned feature expression editing", + "native sketch dimensions and modern solver relations with geometric residual verification", + "bounded conflict diagnostics and explicit persistent-relation contradictions", + "prepared upstream review slices and draft first description; no PR opened" + ], + "local_tests": { + "passed": 453, + "skipped": 1, + "deselected": 1 + }, + "coverage": { + "percent": 82.68, + "required": 78, + "threshold_unchanged": true, + "scope_unchanged": true + }, + "lint": "passed", + "type_check": "passed (25 source files)", + "pre_commit": "passed", + "hosted_ci": { + "status": "passed", + "jobs_passed": 13, + "url": "https://github.com/xuio/NX_MCP/actions/runs/33981485352" + }, + "hosted_release": { + "status": "passed", + "url": "https://github.com/xuio/NX_MCP/actions/runs/33981514750" + }, + "native_advanced_staging": { + "passed": 9, + "total": 9, + "groups": [ + "exact_selection_edit_reopen_ambiguity", + "bore_axis_recognition", + "native_pattern_261_5_span_edit_reopen", + "native_dimensions_and_expression_edit", + "native_relations_and_diagnostics", + "owned_feature_parameters", + "horizontal_vertical_diameter_dimensions", + "two_curve_relation_types", + "conflict_sensitivity_and_restoration" + ] + }, + "deployed_public_mcp": { + "advanced": { + "passed": 8, + "total": 8 + }, + "prior_authoring": { + "passed": 8, + "total": 8 + }, + "prior_visualization": { + "passed": 11, + "total": 11, + "rerun": "Initial invocation omitted the fixed-fixture environment setting; the one affected check was rerun successfully. Original receipt retained." + }, + "supplemental_relation_persistence": "passed: equal radius follows driving-expression edits and survives save/reopen", + "total_successful_workflow_checks": 28 + }, + "windows_stdio": "passed: 104 tools", + "windows_http": "passed: 104 tools and native inline PNG checksum", + "installed_source_hashes": "match hosted release", + "session_preservation": { + "restored_parts": 38, + "modified_parts": 0, + "verified_component_placements": 116, + "design_geometry_changed": false + }, + "archive_sha256": "c4ee680145204109a1d2f86636c6379a9e597f29194845b1fc41a0c3e918fc2c", + "deployment_transport": "QGA completion response was unavailable; reconciled installed version, runtime backup, restarted bridge and all installed source hashes before proceeding. Installation was not retried.", + "limitations": [ + "Selectors re-evaluate geometric rules; they do not promise permanent topological identity across arbitrary edits.", + "Highest/lowest ordering still uses conservative bounds centers.", + "Bore recognition reports inward cylindrical faces; no threads, blind/through classification or manufacturing-feature inference.", + "Constraint sensitivity is bounded and is not a minimal conflicting set. Explicit legacy contradiction detection covers horizontal/vertical on a nonzero line only.", + "Modern sketch relation branches were validated natively. Legacy branch and reference-dimension guards have local boundary coverage only. Coincident selections support line endpoints and arc centers.", + "General feature expression APIs were validated natively on extrusion expressions; untested feature constructors remain scoped experimental.", + "Runtime source commit is fixed above; later documentation-only commits do not change the deployed runtime." + ] +} diff --git a/docs/fork-status.md b/docs/fork-status.md index 13ff9be..be207ee 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. It imports the implementation deployed against Siemens NX v2606 as version `0.2.0.dev2`. The five implementation commits follow upstream `179086b6de28a53d340132aca7678fa6ed03b422` in deployment order. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev6`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. ## Included changes @@ -12,7 +12,7 @@ This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserv - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The opt-in integration profile exposes 77 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev6 opt-in integration profile exposes 104 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -42,4 +42,4 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to a single approximately 4,800-line integration diff. Reconcile the upstream quality gates before requesting a merge, and agree on the interactive scheduler and supported NX-version policy before proposing the larger architecture changes. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev6 acceptance](dev6-validation.json). No pull request has been opened. From 740a034a92b99d0b1b137d06ad2c43401a29397b Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 20:03:15 +0200 Subject: [PATCH 16/69] Support explicit project folders and in-workspace absolute file paths --- docs/fork-status.md | 6 ++-- docs/project-folders.md | 30 +++++++++++++++++ examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/hardened.py | 7 +++- src/nx_mcp/integration_server.py | 49 +++++++++++++++++++++++++--- src/nx_mcp/workspace.py | 21 +++++++----- tests/test_artifact_recovery.py | 30 +++++++++++++++++ tests/test_authoring_contracts.py | 19 +++++++++++ tests/test_recovery_state.py | 9 +++++ tests/test_visual_tools.py | 2 +- tests/test_workspace.py | 25 ++++++++++++-- 15 files changed, 183 insertions(+), 25 deletions(-) create mode 100644 docs/project-folders.md diff --git a/docs/fork-status.md b/docs/fork-status.md index be207ee..6c95f67 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev6`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev7`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. ## Included changes @@ -12,7 +12,7 @@ This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserv - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev6 opt-in integration profile exposes 104 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev7 opt-in integration profile exposes 106 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -43,3 +43,5 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev6 acceptance](dev6-validation.json). No pull request has been opened. + +Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/docs/project-folders.md b/docs/project-folders.md new file mode 100644 index 0000000..5b0f2fc --- /dev/null +++ b/docs/project-folders.md @@ -0,0 +1,30 @@ +# Project folders and explicit file paths + +The integration profile accepts either workspace-relative paths or absolute paths inside the configured `NX_MCP_WORKSPACE`. Paths refer to the **NX server**, not the Mac running the client. Call `nx_workspace_info({})` to discover the actual root. Forward slashes work on Windows and avoid JSON backslash escaping. + +For a workspace at `D:/CAD/NX_MCP_WORKSPACE`, these identify the same file: + +- `projects/controller/parts/controller_base.prt` +- `D:/CAD/NX_MCP_WORKSPACE/projects/controller/parts/controller_base.prt` + +There is no mutable current-directory setting. Include the project prefix on every file call so multiple tasks cannot redirect each other's files. + +## 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. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index aed2431..2c9a195 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 104 + assert len(tools) == 106 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 0befedb..12dee03 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 104 + assert len(tools) == 106 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 26a4246..31d67ab 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 104, len(names) + assert len(names) == 106, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 442264e..929e36c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev6" +version = "0.2.0.dev7" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 1860bab..87b1f18 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.dev6" +__version__ = "0.2.0.dev7" diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 73aa569..b3f0cec 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -458,7 +458,11 @@ def _find_sketch(self, name): def _open_part(self, path, work=True, display=True): source = self.workspace.ensure_inside(path) loaded = next( - (p for p in self.session.Parts if str(p.FullPath).casefold() == str(source).casefold()), + ( + p + for p in self.session.Parts + if str(Path(p.FullPath).resolve()).casefold() == str(source).casefold() + ), None, ) already_loaded = loaded is not None @@ -522,6 +526,7 @@ def _save_as(self, path): 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) status = part.SaveAs(str(dest)) if status and hasattr(status, "Dispose"): status.Dispose() diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index f67f304..3e547e1 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -241,6 +241,14 @@ def nx_revolve( pass +def nx_workspace_info(): + pass + + +def nx_create_directory(path: str): + pass + + def nx_workspace_list(path: str = "."): pass @@ -254,6 +262,10 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of DESCRIPTIONS = { + "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_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. 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.", @@ -277,7 +289,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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": "Open or reuse a loaded workspace .prt and activate it; work/display flags are explicit. Does not recreate loaded parts.", + "nx_open_part": "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": "Close only the specified loaded part (ID), or current work part; preserves its component tree and unrelated parts. save defaults true.", "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.", @@ -322,6 +334,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_checkpoint_state", "nx_capabilities", "nx_operation_status", + "nx_workspace_info", "nx_workspace_list", "nx_download_file", } @@ -335,6 +348,8 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of ) SIDE = { + "nx_create_directory", + "nx_workspace_info", "nx_workspace_list", "nx_download_file", "nx_upload_file", @@ -498,7 +513,8 @@ async def proxy(**kwargs): structured_output=False, annotations=ToolAnnotations( readOnlyHint=name in READ_ONLY and name != "nx_ui_control", - idempotentHint=name in READ_ONLY or name == "nx_set_component_transform", + idempotentHint=name in READ_ONLY + or name in {"nx_set_component_transform", "nx_create_directory"}, ), ) tool = mcp._tool_manager.get_tool(name) @@ -524,7 +540,7 @@ async def uniform_call(name, arguments): 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. Use client-supplied operation_id for mutation retry; query receipts after transport failure. No general certification is claimed." + 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): @@ -541,7 +557,30 @@ def artifact_call(method, p, workspace): "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") @@ -559,7 +598,7 @@ def metadata(file): if method == "nx_workspace_list": items = [] for f in sorted(path.iterdir()): - if f.name == ".nx-mcp": + if f.name.casefold() == ".nx-mcp": continue workspace.ensure_inside(f) items.append( @@ -567,7 +606,7 @@ def metadata(file): if f.is_file() else {"path": str(f.relative_to(workspace.root)), "kind": "directory"} ) - return {"status": "success", "entries": items, "count": len(items)} + return {"status": "success", "path": str(path), "entries": items, "count": len(items)} if method == "nx_download_file": if p["offset"] < 0 or not 1 <= p["length"] <= 262144: raise NXToolError("NX_INVALID_ARGUMENT", "Invalid chunk offset/length") diff --git a/src/nx_mcp/workspace.py b/src/nx_mcp/workspace.py index daead4d..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,17 +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 ".nx-mcp" in resolved.relative_to(self.root).parts: + 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/test_artifact_recovery.py b/tests/test_artifact_recovery.py index e3bbb87..0b0c76f 100644 --- a/tests/test_artifact_recovery.py +++ b/tests/test_artifact_recovery.py @@ -182,3 +182,33 @@ async def test_inline_capture_delivery_checks_committed_artifact(tmp_path, kind) 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 diff --git a/tests/test_authoring_contracts.py b/tests/test_authoring_contracts.py index 431c255..f8d6cb2 100644 --- a/tests/test_authoring_contracts.py +++ b/tests/test_authoring_contracts.py @@ -290,3 +290,22 @@ def test_step_import_validates_conflicts_and_reports_no_output(rig, tmp_path): ]: 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" diff --git a/tests/test_recovery_state.py b/tests/test_recovery_state.py index 11868c2..8e0327c 100644 --- a/tests/test_recovery_state.py +++ b/tests/test_recovery_state.py @@ -196,3 +196,12 @@ def test_guard_failures_never_create_undo_marks(rig): 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 diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py index 160d2b9..84dd244 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 104 + assert len(tools) == 106 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", 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") From 3d86a6c7f377d7b59848f575ec9904c31dd1ed15 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 20:11:29 +0200 Subject: [PATCH 17/69] Document deployed project-folder acceptance and reproducible native checks --- docs/dev7-validation.json | 77 ++++++++++++++ docs/fork-status.md | 2 +- docs/project-folders.md | 4 + examples/validate_project_folders.py | 153 +++++++++++++++++++++++++++ 4 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 docs/dev7-validation.json create mode 100644 examples/validate_project_folders.py diff --git a/docs/dev7-validation.json b/docs/dev7-validation.json new file mode 100644 index 0000000..7ca9e91 --- /dev/null +++ b/docs/dev7-validation.json @@ -0,0 +1,77 @@ +{ + "version": "0.2.0.dev7", + "runtime_commit": "740a034a92b99d0b1b137d06ad2c43401a29397b", + "archive_sha256": "a0fb304234f9cdbd2da6ee3fc3b1d11ca2aefbd10ba02f776194fae88bbe32aa", + "validated_at": "2026-09-05T18:11:28.561519+00:00", + "nx_version": "v2606", + "tool_count": 106, + "hosted_ci": { + "url": "https://github.com/xuio/NX_MCP/actions/runs/33982851326", + "status": "passed", + "jobs": 13 + }, + "hosted_release": "https://github.com/xuio/NX_MCP/actions/runs/33982850744", + "local_validation": { + "suite_before_final_path_normalization_test": { + "passed": 462, + "branch_coverage_percent": 82.9 + }, + "path_normalization_and_recovery_suite": { + "passed": 12 + }, + "mypy": "passed", + "pre_commit": "passed" + }, + "native_checks": [ + { + "name": "workspace_discovery", + "passed": true + }, + { + "name": "directory_creation_absolute_relative_retry", + "passed": true + }, + { + "name": "nested_part_creation_save_1000mm3", + "passed": true + }, + { + "name": "absolute_save_as_creates_missing_parents_preserves_volume", + "passed": true + }, + { + "name": "save_as_no_overwrite", + "passed": true + }, + { + "name": "absolute_reopen_relative_reuse_and_activation", + "passed": true + }, + { + "name": "nested_absolute_step_export_download_upload_integrity", + "passed": true + }, + { + "name": "outside_and_reserved_paths_rejected", + "passed": true + } + ], + "native_passed": 8, + "deployment": { + "installed_runtime_hashes": "match release", + "stdio": "passed", + "http": "passed", + "inline_viewport_png_checksum": "passed", + "session_restored": true, + "original_parts": 38, + "assembly_occurrences": 116, + "backup_created": true + }, + "scope": "Absolute paths inside the configured NX-host workspace and explicit project subfolders. No outside-root access or project relocation.", + "runner_corrections": [ + "Initial harness used volume instead of documented volume_mm3; corrected.", + "Harness expected export directory to contain only STEP; native export also writes translator logs. Corrected to identify STEP by filename." + ], + "runtime_failures_in_acceptance": 0, + "pull_request_opened": false +} diff --git a/docs/fork-status.md b/docs/fork-status.md index 6c95f67..9dfdde2 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -42,6 +42,6 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev6 acceptance](dev6-validation.json). No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current folder-support runtime CI and native evidence are recorded in [dev7 acceptance](dev7-validation.json); [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/docs/project-folders.md b/docs/project-folders.md index 5b0f2fc..d1dab87 100644 --- a/docs/project-folders.md +++ b/docs/project-folders.md @@ -28,3 +28,7 @@ Use subfolders such as `parts/`, `assemblies/`, `revisions/`, `vendor/`, and `ex 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. + +## Live acceptance + +With a saved work part open in NX, set `NX_MCP_URL` to the integration endpoint and run `python examples/validate_project_folders.py`. Set `NX_VALIDATION_OUTPUT` for the local receipt directory. The runner creates disposable parts in a unique workspace subfolder, verifies a 1,000 mm³ solid across Save As and reopen, checks loaded-part reuse and activation, tests STEP artifact checksums and uploads, and rejects outside-root and reserved-state paths. It closes its fixtures and restores the original work part. Validation artifacts remain in the unique test subfolder. diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py new file mode 100644 index 0000000..86ca886 --- /dev/null +++ b/examples/validate_project_folders.py @@ -0,0 +1,153 @@ +"""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) == 106 + 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()) From d381426d31a25398daaea5910519a2bce24b4d38 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 22:01:40 +0200 Subject: [PATCH 18/69] Add native engineering authoring and repair NX 2606 modeling and drafting --- README.md | 2 +- docs/dev8-validation.json | 57 + docs/engineering-tools.md | 43 + docs/fork-status.md | 6 +- examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 413 ++++++ examples/validate_project_folders.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/advanced_authoring.py | 102 +- src/nx_mcp/authoring_server.py | 242 +++- src/nx_mcp/capability_manifest.json | 176 ++- src/nx_mcp/engineering.py | 1629 ++++++++++++++++++++++++ src/nx_mcp/hardened.py | 42 +- src/nx_mcp/inspection.py | 4 + src/nx_mcp/integration_server.py | 4 +- src/nx_mcp/runtime.py | 4 + tests/test_advanced_authoring.py | 4 +- tests/test_artifact_recovery.py | 5 +- tests/test_engineering.py | 853 +++++++++++++ tests/test_visual_tools.py | 2 +- 23 files changed, 3520 insertions(+), 80 deletions(-) create mode 100644 docs/dev8-validation.json create mode 100644 docs/engineering-tools.md create mode 100644 examples/validate_engineering_tools.py create mode 100644 src/nx_mcp/engineering.py create mode 100644 tests/test_engineering.py diff --git a/README.md b/README.md index d2facdf..7af208c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork contains the deployed NX v2606 integration (`0.2.0.dev5`), including visible UI control, recovery and artifact tools, native interference checks, viewport images, visual controls and sketch diagnostics. The opt-in profile exposes 94 tools. Start with [fork setup and scope](docs/fork-status.md). The original upstream README follows; its 16-tool default and NX2506 validation describe the upstream baseline. The full local suite passes with 393 tests and 81.49% whole-project branch coverage, above the unchanged 78% gate. See [validation and PR readiness](docs/fork-validation.md). +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev8` integration and 124 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 diff --git a/docs/dev8-validation.json b/docs/dev8-validation.json new file mode 100644 index 0000000..1907025 --- /dev/null +++ b/docs/dev8-validation.json @@ -0,0 +1,57 @@ +{ + "version": "0.2.0.dev8", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 124, + "native_evidence_scope": "Serialized NX backend calls in disposable fixture parts; public endpoint deployment acceptance recorded separately after deployment.", + "native_passed_scenarios": [ + "assembly_constraint_fixed", + "assembly_distance_edit", + "associative_body_copy_absolute_edit", + "component_arrays", + "component_pattern_edits", + "extrusion_offsets_symmetric_arbitrary", + "extrusion_through_all_up_to_face", + "legacy_blend", + "legacy_chamfer", + "legacy_hole", + "legacy_sweep", + "native_boolean_volumes", + "native_drawing_pdf", + "native_edge_hole_sweep_volumes", + "native_mate_geometry", + "native_mirror_verified", + "native_nested_mass", + "physical_material_and_mass", + "project_copy", + "render_lighting", + "shell_open_box", + "sketch_angle", + "sketch_extend", + "sketch_primitives_solid_profiles", + "sketch_symmetry", + "sketch_tangent", + "sketch_trim", + "solid_loft" + ], + "original_loaded_parts_restored": 38, + "local_validation": { + "pytest_passed": 551, + "pytest_skipped": 1, + "branch_coverage_percent": 79.78, + "unchanged_coverage_gate_percent": 78, + "mypy": "passed" + }, + "visual_review": { + "native_png": "800x600 PNG inspected", + "drawing_pdf": "A3 PDF, base and projected views, computed10mm dimension inspected" + }, + "known_scopes": [ + "Line-pair sketch symmetry; line/circle tangent native fixture.", + "Solid loft native fixture; sheet configuration contract-tested.", + "Simple cylindrical holes, no drill-tip or thread authoring.", + "Single-body drawing base views; linear dimensions only.", + "Native preset2/custom background tested; no blanket rendering certification." + ], + "deployment": "pending" +} diff --git a/docs/engineering-tools.md b/docs/engineering-tools.md new file mode 100644 index 0000000..eb59d15 --- /dev/null +++ b/docs/engineering-tools.md @@ -0,0 +1,43 @@ +# Engineering tools on NX v2606 + +The dev8 opt-in profile exposes 124 tools. Mutations run serially on the NX thread and use durable operation IDs and NX undo marks. The capability manifest records the specific native fixtures tested; a tested tool is not a claim that every parameter combination or NX version is supported. + +## 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. + +## Reproducible validation + +Run `examples/validate_engineering_tools.py` against the deployed public MCP endpoint using `NX_MCP_URL` and `NX_VALIDATION_OUTPUT`. It creates an isolated fixture directory, requires a saved session, verifies analytic geometry, nested material mass, pattern placements, persistence/retry and inline images, and restores the original loaded parts. `NX_VALIDATION_GROUP` selects a focused rerun. Native evidence is kept separate from local mocked API-contract tests. diff --git a/docs/fork-status.md b/docs/fork-status.md index 9dfdde2..1a6f644 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,8 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev7`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev8`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. + +See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. ## Included changes @@ -12,7 +14,7 @@ This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserv - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev7 opt-in integration profile exposes 106 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev8 opt-in integration profile exposes 124 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index 2c9a195..b7f5df7 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 106 + assert len(tools) == 124 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 12dee03..91068c4 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 106 + assert len(tools) == 124 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py new file mode 100644 index 0000000..8ebc36c --- /dev/null +++ b/examples/validate_engineering_tools.py @@ -0,0 +1,413 @@ +"""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 restricts a rerun to one named group. +""" + +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): + if os.environ.get("NX_VALIDATION_GROUP") not in {None, name}: + 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) == 124 + + 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") + target = await face(f["body"]["id"], [0, 0, 1]) + s = await profile(2, 2) + 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(): + f = await box("recovery") + checkpoint = await call("nx_checkpoint", label="engineering recovery") + top = await face(f["body"]["id"], [0, 0, 1]) + failed = await client.call_tool( + "nx_shell", + { + "body": f["body"]["id"], + "thickness": 100, + "remove_faces": [top], + "operation_id": "bad-shell-" + uuid.uuid4().hex, + }, + ) + assert failed.isError, "Impossible shell unexpectedly succeeded" + close(await volume(), 1000) + await call( + "nx_transform_bodies", + bodies=[f["body"]["id"]], + 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) + 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_project_folders.py b/examples/validate_project_folders.py index 86ca886..1bf9236 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,7 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 106 + assert len((await c.list_tools()).tools) == 124 info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 31d67ab..3a27391 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 106, len(names) + assert len(names) == 124, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 929e36c..7602d6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev7" +version = "0.2.0.dev8" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 87b1f18..6d91946 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.dev7" +__version__ = "0.2.0.dev8" diff --git a/src/nx_mcp/advanced_authoring.py b/src/nx_mcp/advanced_authoring.py index 462fac7..7aa6621 100644 --- a/src/nx_mcp/advanced_authoring.py +++ b/src/nx_mcp/advanced_authoring.py @@ -143,7 +143,9 @@ def _component_pattern_record(self, pattern): result = { "object": self._reference(pattern, "component_pattern", part, "Component pattern"), "native_type": "NXOpen.Assemblies.ComponentPattern", - "pattern_type": "linear" if linear else str(service.PatternType), + "pattern_type": "linear" + if linear + else enum_name(service.PatternType, service.PatternEnum), "associative": bool(builder.Associative), } if linear: @@ -153,6 +155,19 @@ def _component_pattern_record(self, pattern): 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(): @@ -241,34 +256,85 @@ def _native_component_pattern(self, component, direction, spacing, count): ) return result - def _edit_component_pattern(self, pattern, spacing=None, count=None): - import NXOpen.GeometricUtilities - - if spacing is None and count is None: - raise NXToolError("NX_INVALID_ARGUMENT", "Supply spacing and/or count") + 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: - if ( - not b.Associative - or b.PatternService.PatternType - != NXOpen.GeometricUtilities.PatternDefinition.PatternEnum.Linear - ): + service = b.PatternService + if not b.Associative or len(obj.GetComponentsToPattern()) != 1: raise NXToolError( - "NX_UNSUPPORTED_EDIT", "Only associative linear component patterns are editable" + "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" ) - d = b.PatternService.RectangularDefinition - if count is not None: - d.XSpacing.NCopies.RightHandSide = str(count) - if spacing is not None: - d.XSpacing.PitchDistance.RightHandSide = str(float(spacing)) b.Commit() finally: b.Destroy() self._update_model() result = self._component_pattern_record(obj) - if count is not None and result["total_instances"] != count: + if result["total_instances"] != expected: raise NXToolError( "NX_PATTERN_VERIFICATION_FAILED", "Native member count differs; rolling back" ) diff --git a/src/nx_mcp/authoring_server.py b/src/nx_mcp/authoring_server.py index 6be9bd4..97e36e6 100644 --- a/src/nx_mcp/authoring_server.py +++ b/src/nx_mcp/authoring_server.py @@ -170,8 +170,15 @@ def nx_native_component_pattern(component: str, direction: list[float], spacing: """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): - """Edit pitch and/or total count (including seed, 2–100) of a native associative linear component pattern by ID. Read back native parameters and member poses. Unsupported native pattern types are rejected before editing.""" +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(): @@ -212,3 +219,234 @@ def nx_feature_parameters(feature: str): 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 +): + """Create and open a native landscape metric drawing sheet with first-angle projection, positive model-to-sheet scale, and unique name. Returns typed sheet ID and exact dimensions in mm.""" + + +def nx_add_base_view( + drawing: str, + body: str, + view: Literal["top", "front", "back", "right", "left", "bottom", "isometric"], + position: list[float] | None = None, +): + """Add a native base view to a drawing sheet. Requires a single-body part so the specified body is the exact view scope. position=[x,y] uses sheet mm, 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. 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 mm. 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, +): + """Create a native associative linear drawing dimension from owned 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 mm, default [100,80]. Returns actual computed size in model units and a typed dimension ID.""" diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 9740559..1595623 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-advanced-authoring-r1", + "revision": "2606-engineering-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -9,9 +9,9 @@ "scope": "Explicit checkpoint rollback; stale references rejected afterward" }, "nx_export_drawing_pdf": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native PDF plot export with A3 page size, two views and 10mm dimension; file parsed and visually reviewed." }, "nx_finish_sketch": { "status": "tested", @@ -19,14 +19,14 @@ "scope": "Principal/custom sketch completion and subsequent extrusion" }, "nx_chamfer": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -39,9 +39,9 @@ "scope": "Used by loaded-part opening; work/display activation; modified flags preserved" }, "nx_add_projection_view": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -59,9 +59,9 @@ "scope": "No correctness or failure claim; preserve as experimental." }, "nx_hole": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -124,9 +124,9 @@ "scope": "No correctness or failure claim; preserve as experimental." }, "nx_add_dimension": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -134,9 +134,9 @@ "scope": "Body/body, face/face, nested component/body occurrences; closest points and units" }, "nx_mate_component": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -179,9 +179,9 @@ "scope": "No correctness or failure claim; preserve as experimental." }, "nx_add_base_view": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -209,9 +209,9 @@ "scope": "No correctness or failure claim; preserve as experimental." }, "nx_boolean": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Overlapping1000mm3 cubes: unite1500, subtract500, intersect500mm3 verified analytically." }, "nx_list_topology": { "status": "tested", @@ -225,8 +225,8 @@ }, "nx_extrude": { "status": "tested", - "evidence_type": "real_NX_v2606", - "scope": "Principal/custom normals; disconnected two-body extrusion; positive depth" + "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": "experimental", @@ -254,9 +254,9 @@ "scope": "No correctness or failure claim; preserve as experimental." }, "nx_sweep": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", @@ -269,9 +269,9 @@ "scope": "No correctness or failure claim; preserve as experimental." }, "nx_blend": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", @@ -309,9 +309,9 @@ "scope": "Two-level transforms and STEP round-trip pose equality" }, "nx_create_drawing": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", @@ -490,13 +490,13 @@ }, "nx_edit_component_pattern": { "status": "tested", - "evidence_type": "real_NX_v2606_scoped_and_local_boundary_tests", - "scope": "Native linear pitch/count edited to 4 total at 20 mm; poses read back; association persists after save/reopen." + "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_and_local_boundary_tests", - "scope": "Native GetAllComponentPatterns enumeration, expression and member read-back; non-assembly guard." + "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", @@ -522,6 +522,96 @@ "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." } }, "limitations": [ diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py new file mode 100644 index 0000000..9b8d11b --- /dev/null +++ b/src/nx_mcp/engineering.py @@ -0,0 +1,1629 @@ +"""Native engineering builders; all calls run in the existing NX transaction dispatcher.""" + +from __future__ import annotations + +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): + 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 + b.Length, b.Height = map(float, 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() + return { + "object": self._reference(sheet, "drawing_sheet", self._work_part(), "Drawing sheet"), + "sheet_name": sheet.Name, + "size": size, + "dimensions_mm": list(dimensions[size]), + "scale": scale, + "projection": "first_angle", + "units": "mm", + } + + 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 mm") + sheet.Open() + b = part.DraftingViews.CreateBaseViewBuilder(None) + try: + b.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view]) + b.Placement.Placement.SetValue(None, None, self.nxopen.Point3d(*point, 0.0)) + result = b.Commit() + finally: + b.Destroy() + 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, + "position_mm": 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") + 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) + 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.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", + "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] + point = self.nxopen.Point3d(center.X + dx * spacing, center.Y + dy * spacing, 0.0) + 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() + return { + "object": self._reference(result, "drawing_view", self._work_part(), "Projected view"), + "view_name": result.Name, + "base_view": base_view, + "direction": direction, + "spacing_mm": spacing, + } + + def _add_dimension(self, view, object1, object2=None, dim_type="aligned", origin=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._engineering_owned(object1, "edge") + b = self._engineering_owned(object2, "edge") 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 mm") + builder = self._work_part().Dimensions.CreateLinearDimensionBuilder(None) + 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.nxopen.Point3d(*point, 0.0) + 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, + "origin_mm": point, + "units": self._units(), + "association": "edge start/end" if object2 is None else "edge start points", + } diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index b3f0cec..7d03aee 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -14,6 +14,7 @@ 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.engineering import EngineeringMixin from nx_mcp.inspection import InspectionMixin from nx_mcp.nx_bridge import NXOpenExecutor from nx_mcp.recovery import OperationStore, timestamp @@ -47,6 +48,7 @@ } # 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", @@ -57,6 +59,7 @@ "nx_save_as", "nx_export_step", "nx_screenshot", + "nx_render_view", "nx_export_drawing_pdf", "nx_undo", "nx_checkpoint", @@ -113,6 +116,7 @@ def add(a, b): class HardenedExecutor( + EngineeringMixin, AdvancedAuthoringMixin, AuthoringMixin, ReviewToolsMixin, @@ -133,6 +137,34 @@ def __init__(self, *args, **kwargs): self._handlers.update( { "nx_resolve_geometry": self._resolve_geometry, + "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, @@ -691,10 +723,10 @@ def _sketch_info(self, sketch_id): "curve_count": len(geometry), } - def _extrude(self, sketch_id, distance, reverse=False): + def _simple_extrude(self, sketch_id, distance, reverse=False): if not math.isfinite(distance): raise NXToolError("NX_INVALID_ARGUMENT", "distance must be finite") - result = super()._extrude(sketch_id, distance, reverse) + result = NXOpenExecutor._extrude(self, sketch_id, distance, reverse) feature = self.objects.resolve(result["feature"]["id"]) bodies = list(feature.GetBodies()) result.update( @@ -1388,7 +1420,7 @@ def _capabilities(self): }, ) if self.session.IsBatch: - for name in ("nx_screenshot", "nx_ui_control"): + for name in ("nx_screenshot", "nx_render_view", "nx_ui_control"): manifest["tools"][name].update( status="unavailable", scope="Requires the interactive NX host" ) @@ -1405,6 +1437,10 @@ def _finish_sketch(self, sketch_id): def _snapshot(self, part): groups = [ + ("assembly_constraint", self._assembly_constraints(part)), + ("drawing_sheet", getattr(part, "DrawingSheets", [])), + ("drawing_view", getattr(part, "DraftingViews", [])), + ("dimension", getattr(part, "Dimensions", [])), ( "component_pattern", self._component_patterns(part), diff --git a/src/nx_mcp/inspection.py b/src/nx_mcp/inspection.py index 997adb9..de2568d 100644 --- a/src/nx_mcp/inspection.py +++ b/src/nx_mcp/inspection.py @@ -37,6 +37,10 @@ def _view_info(self): 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), ] diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 3e547e1..4360d27 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -357,6 +357,8 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_cancel_operation", } PATHS = { + "nx_render_view": "path", + "nx_copy_project": "path", "nx_component_action": "part_path", "nx_save_presentation": "path", "nx_restore_presentation": "path", @@ -454,7 +456,7 @@ async def proxy(**kwargs): ) result = await bridge.call(method, params) response = envelope(result, error=result.get("status") == "error") - if method == "nx_screenshot" and not response.isError: + if method in {"nx_screenshot", "nx_render_view"} and not response.isError: file = workspace.ensure_inside(result["path"]) if file.stat().st_size <= 8 * 1024 * 1024: data = file.read_bytes() diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index 7b2d5eb..633f583 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -18,6 +18,10 @@ "constraint", "expression", "component_pattern", + "assembly_constraint", + "drawing_sheet", + "drawing_view", + "dimension", ] diff --git a/tests/test_advanced_authoring.py b/tests/test_advanced_authoring.py index 6dcab8a..90153db 100644 --- a/tests/test_advanced_authoring.py +++ b/tests/test_advanced_authoring.py @@ -192,7 +192,9 @@ def patterned(author): 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")), ), @@ -227,7 +229,7 @@ def test_pattern_failure_count_and_unsupported_edits(patterned): 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="Only associative"): + 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"): diff --git a/tests/test_artifact_recovery.py b/tests/test_artifact_recovery.py index 0b0c76f..aa5be20 100644 --- a/tests/test_artifact_recovery.py +++ b/tests/test_artifact_recovery.py @@ -159,7 +159,8 @@ async def test_mcp_paths_are_validated_before_bridge_dispatch(tmp_path): @pytest.mark.asyncio @pytest.mark.parametrize("kind", ["valid", "changed", "large"]) -async def test_inline_capture_delivery_checks_committed_artifact(tmp_path, kind): +@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) @@ -171,7 +172,7 @@ async def test_inline_capture_delivery_checks_committed_artifact(tmp_path, kind) bridge = AsyncMock() bridge.call.return_value = result server = create_server(bridge, Workspace(tmp_path), enable_experimental=True) - response = await server.call_tool("nx_screenshot", {"path": "capture.png"}) + 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 diff --git a/tests/test_engineering.py b/tests/test_engineering.py new file mode 100644 index 0000000..3e05345 --- /dev/null +++ b/tests/test_engineering.py @@ -0,0 +1,853 @@ +"""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 + sheet = Object("Sheet1") + r.part.DrawingSheets = [sheet] + b = NS( + ActionOption=NS(Native=1), + SizeOption=NS(FullScale=1), + UnitsOption=NS(Metric=1), + OutputTextOption=NS(Text=1), + SourceBuilder=NS(SetSheets=Mock()), + Destroy=Mock(), + ) + b.Commit = lambda: Path(b.Filename).write_bytes(b"%PDF-1.7\nfixture") + r.part.PlotManager = NS(CreatePrintPdfbuilder=lambda: b) + 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]) + 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 + r.part.DrawingSheets = [Object("sheet")] + b = NS( + ActionOption=NS(Native=1), + SizeOption=NS(FullScale=1), + UnitsOption=NS(Metric=1), + OutputTextOption=NS(Text=1), + SourceBuilder=NS(SetSheets=Mock()), + Destroy=Mock(), + ) + b.Commit = lambda: Path(b.Filename).write_bytes(b"not PDF") + r.part.PlotManager = NS(CreatePrintPdfbuilder=lambda: b) + 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_visual_tools.py b/tests/test_visual_tools.py index 84dd244..ffd080d 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 106 + assert len(tools) == 124 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From bfca807042d8fae49cd1a355afbb0a0eef729911 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 22:22:02 +0200 Subject: [PATCH 19/69] Record deployed dev8 acceptance and extend reproducible native regressions --- docs/dev8-validation.json | 70 ++++++++- docs/engineering-tools.md | 2 +- docs/fork-status.md | 2 +- examples/validate_engineering_tools.py | 190 +++++++++++++++++++++++-- 4 files changed, 247 insertions(+), 17 deletions(-) diff --git a/docs/dev8-validation.json b/docs/dev8-validation.json index 1907025..cbe3499 100644 --- a/docs/dev8-validation.json +++ b/docs/dev8-validation.json @@ -3,7 +3,7 @@ "nx_version": "v2606", "bridge_protocol": 1, "tool_count": 124, - "native_evidence_scope": "Serialized NX backend calls in disposable fixture parts; public endpoint deployment acceptance recorded separately after deployment.", + "native_evidence_scope": "Serialized NX 2606 backend fixtures plus deployed public MCP acceptance; native geometry evidence is separate from mocked contract coverage.", "native_passed_scenarios": [ "assembly_constraint_fixed", "assembly_distance_edit", @@ -44,7 +44,7 @@ }, "visual_review": { "native_png": "800x600 PNG inspected", - "drawing_pdf": "A3 PDF, base and projected views, computed10mm dimension inspected" + "drawing_pdf": "A3 PDF, base and projected views, computed 10 mm dimension inspected after public MCP download" }, "known_scopes": [ "Line-pair sketch symmetry; line/circle tangent native fixture.", @@ -53,5 +53,69 @@ "Single-body drawing base views; linear dimensions only.", "Native preset2/custom background tested; no blanket rendering certification." ], - "deployment": "pending" + "deployment": { + "status": "deployed_and_verified", + "runtime_commit": "d381426d31a25398daaea5910519a2bce24b4d38", + "release_archive_sha256": "dbd2db057900a87d63403987b00c4523d621ef6968c07f6ae6e095fe8312edd9", + "release_build": "https://github.com/xuio/NX_MCP/actions/runs/33988896807", + "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/33988873985", + "runtime_ci_status": "success", + "interactive_cold_start": "passed", + "main_thread_dispatch": "verified", + "stdio_tools": 124, + "http_tools": 124, + "runtime_source_files_checksum_verified": 38, + "original_saved_parts_restored": 38, + "original_component_paths_and_transforms_verified": 116, + "inline_native_png_checksum": "passed", + "native_pdf_download_checksum": "passed", + "offline_bundle_refreshed": true, + "runtime_backup_retained": true, + "journal_execution_enabled": false + }, + "public_endpoint_validation": { + "passed": 13, + "total": 13, + "groups": [ + "assembly_constraint_geometry", + "associative_motion_persistence_retry", + "component_arrays_and_edits", + "extrusion_limits", + "failed_mutation_and_checkpoint_rollback", + "native_drafting_pdf", + "native_render_inline_artifact", + "physical_material_mass_inertia", + "project_copy_dependencies", + "repaired_native_modeling", + "shell_loft_draft", + "sketch_primitives", + "sketch_relations_and_local_edits" + ], + "runs": [ + { + "run": "public", + "passed": 8, + "total": 10, + "restored_parts": 38 + }, + { + "run": "public-followup", + "passed": 4, + "total": 5, + "restored_parts": 38 + }, + { + "run": "public-recovery", + "passed": 1, + "total": 1, + "restored_parts": 38 + } + ], + "fixture_corrections": [ + "Reacquire face IDs after sketch mutations.", + "Use a self-intersecting extrusion for native failure testing; NX accepts the oversized shell fixture.", + "Reacquire surviving body IDs after native rollback." + ], + "runtime_changes_after_release": "none" + } } diff --git a/docs/engineering-tools.md b/docs/engineering-tools.md index eb59d15..2563cd9 100644 --- a/docs/engineering-tools.md +++ b/docs/engineering-tools.md @@ -40,4 +40,4 @@ These are authoring tools, not a complete manufacturing drawing package: title b ## Reproducible validation -Run `examples/validate_engineering_tools.py` against the deployed public MCP endpoint using `NX_MCP_URL` and `NX_VALIDATION_OUTPUT`. It creates an isolated fixture directory, requires a saved session, verifies analytic geometry, nested material mass, pattern placements, persistence/retry and inline images, and restores the original loaded parts. `NX_VALIDATION_GROUP` selects a focused rerun. Native evidence is kept separate from local mocked API-contract tests. +Run `examples/validate_engineering_tools.py` against the deployed public MCP endpoint using `NX_MCP_URL` and `NX_VALIDATION_OUTPUT`. It creates an isolated fixture directory, requires a saved session, verifies analytic geometry, nested material mass, pattern placements, persistence/retry and inline images, and restores the original loaded parts. `NX_VALIDATION_GROUP` selects one or more comma-separated groups for a focused rerun. The suite also covers repaired blends, chamfers, holes, sweeps and mirrors, local sketch solver edits, and native drafting/PDF download. See [dev8 acceptance](dev8-validation.json) for the deployed results. Native evidence is kept separate from local mocked API-contract tests. diff --git a/docs/fork-status.md b/docs/fork-status.md index 1a6f644..6ccc5ca 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -44,6 +44,6 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current folder-support runtime CI and native evidence are recorded in [dev7 acceptance](dev7-validation.json); [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current engineering runtime CI and native evidence are recorded in [dev8 acceptance](dev8-validation.json); [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index 8ebc36c..737b576 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -1,7 +1,7 @@ """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 restricts a rerun to one named group. +artifacts. NX_VALIDATION_GROUP optionally selects comma-separated named groups. """ import asyncio @@ -45,7 +45,8 @@ def close(actual, expected): assert math.isclose(actual, expected, rel_tol=1e-7, abs_tol=1e-10), (actual, expected) async def group(name, fn): - if os.environ.get("NX_VALIDATION_GROUP") not in {None, name}: + selected = os.environ.get("NX_VALIDATION_GROUP") + if selected and name not in selected.split(","): return try: value = await fn() @@ -135,8 +136,8 @@ async def limits(): ) close(await volume(), 960) f = await box("until") - target = await face(f["body"]["id"], [0, 0, 1]) 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} @@ -344,23 +345,37 @@ async def primitives(): await group("sketch_primitives", primitives) async def recovery(): - f = await box("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") - top = await face(f["body"]["id"], [0, 0, 1]) failed = await client.call_tool( - "nx_shell", + "nx_extrude", { - "body": f["body"]["id"], - "thickness": 100, - "remove_faces": [top], - "operation_id": "bad-shell-" + uuid.uuid4().hex, + "sketch_id": sketch, + "distance": 5, + "operation_id": "bad-section-" + uuid.uuid4().hex, }, ) - assert failed.isError, "Impossible shell unexpectedly succeeded" + 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=[f["body"]["id"]], + bodies=[body], translation=[20, 0, 0], rotation_matrix=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], copy=True, @@ -385,6 +400,157 @@ async def rendering(): ) 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"]: From bc54d38ecde44bd3a36ca89e1830959a83d47961 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 23:14:59 +0200 Subject: [PATCH 20/69] Add native exploded assembly views and associative drawing support --- README.md | 2 +- docs/advanced-roadmap.md | 26 ++ docs/exploded-views.md | 71 ++++ docs/fork-status.md | 6 +- examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 2 +- examples/validate_exploded_views.py | 249 +++++++++++ examples/validate_project_folders.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/authoring_server.py | 60 ++- src/nx_mcp/capability_manifest.json | 31 +- src/nx_mcp/exploded_views.py | 463 ++++++++++++++++++++ src/nx_mcp/hardened.py | 36 +- src/nx_mcp/integration_server.py | 12 +- src/nx_mcp/runtime.py | 2 + tests/test_exploded_views.py | 567 +++++++++++++++++++++++++ tests/test_visual_tools.py | 2 +- 20 files changed, 1516 insertions(+), 25 deletions(-) create mode 100644 docs/advanced-roadmap.md create mode 100644 docs/exploded-views.md create mode 100644 examples/validate_exploded_views.py create mode 100644 src/nx_mcp/exploded_views.py create mode 100644 tests/test_exploded_views.py diff --git a/README.md b/README.md index 7af208c..4f97df1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev8` integration and 124 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev9` integration and 130 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 diff --git a/docs/advanced-roadmap.md b/docs/advanced-roadmap.md new file mode 100644 index 0000000..2c7babf --- /dev/null +++ b/docs/advanced-roadmap.md @@ -0,0 +1,26 @@ +# Advanced authoring roadmap + +These are recommended additions, not claims of implemented or tested APIs. + +1. **Freeform curves and surfaces:** editable 3D splines, through-curve and mesh + surfaces, bridge surfaces, trim/extend, sew, thicken and offset. Include + continuity (G0/G1/G2), curvature and gap diagnostics so the agent can verify + surface quality. NX v2606 builder presence was inspected; each operation still + needs native license/API and geometry tests. Build on existing loft and sweep. +2. **Service and assembly documentation:** explosion trace lines, BOMs and + associative balloons, then assembly sequences and animation. +3. **Imported-part direct editing:** move/offset/replace/delete-heal faces with + geometric selection and before/after validity checks. +4. **Sheet metal:** bends, flanges, reliefs, bend allowance and flat patterns. +5. **Manufacturing detail:** threaded holes and cosmetic threads, GD&T/PMI, + datum schemes and drawing sections/details. +6. **Design validation:** wall thickness, draft analysis, curvature/zebra + inspection and tolerance-aware clearance reports. + +Realize Shape subdivision is a later freeform stage. It needs separate NXOpen +and license verification; exposing a named builder alone is insufficient. +Keep commands intent-oriented, with typed selections, preview/commit boundaries, +checkpoints, units, actual result counts and native verification evidence. + +Siemens references: [freeform surface workflow](https://blogs.sw.siemens.com/nx-design/freeform-modeling-walk-through/) +and [Realize Shape](https://blogs.sw.siemens.com/designcenter/nx-tips-and-tricks-realize-shape/). diff --git a/docs/exploded-views.md b/docs/exploded-views.md new file mode 100644 index 0000000..76d8bb9 --- /dev/null +++ b/docs/exploded-views.md @@ -0,0 +1,71 @@ +# Native exploded views (NX v2606) + +The dev9 integration exposes 130 tools, including six named explosion tools. +Explosions are presentation transforms: they do not reposition the assembled +components, change mates or alter prototype geometry. + +| Tool | Contract | +| --- | --- | +| `nx_create_explosion(name)` | Create a named native explosion in the active assembly. | +| `nx_list_explosions()` | List explosion IDs and associated model/drawing views. | +| `nx_explosion_info(explosion, offset, limit)` | Read occurrence paths, assembled and exploded poses, suppression and view references. | +| `nx_edit_explosion(explosion, placements, reset_components)` | Assign absolute poses or reset selected occurrence offsets. | +| `nx_show_explosion(explosion, drawing_view, model_view)` | Display an explosion; omit explosion to restore assembled presentation. | +| `nx_delete_explosion(explosion)` | Delete an unused explosion; detach all referencing views first. | + +All references are typed opaque IDs. An explosion belongs to the work part; +mutations require matching work/display parts and a finished sketch. Reacquire +references after close/reopen, rollback or manual-control handoff. + +## 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. diff --git a/docs/fork-status.md b/docs/fork-status.md index 6ccc5ca..0ed3498 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev8`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev9`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. @@ -14,7 +14,7 @@ See [engineering tools and scoped validation](engineering-tools.md) for the late - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev8 opt-in integration profile exposes 124 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev9 opt-in integration profile exposes 130 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -47,3 +47,5 @@ The source matches the deployed runtime. The fork includes local tests and a con A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current engineering runtime CI and native evidence are recorded in [dev8 acceptance](dev8-validation.json); [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). + +See [native exploded views](exploded-views.md) for dev9 presentation and drawing contracts, and the [advanced roadmap](advanced-roadmap.md) for proposed freeform and manufacturing work. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index b7f5df7..f9462fd 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 124 + assert len(tools) == 130 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 91068c4..7740910 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 124 + assert len(tools) == 130 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index 737b576..5c389d8 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -108,7 +108,7 @@ async def assembly(name): 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) == 124 + assert len((await client.list_tools()).tools) == 130 async def limits(): await new("offset") 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_project_folders.py b/examples/validate_project_folders.py index 1bf9236..f0e0142 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,7 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 124 + assert len((await c.list_tools()).tools) == 130 info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 3a27391..f9ad4af 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 124, len(names) + assert len(names) == 130, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 7602d6a..877664f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev8" +version = "0.2.0.dev9" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 6d91946..92559b8 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.dev8" +__version__ = "0.2.0.dev9" diff --git a/src/nx_mcp/authoring_server.py b/src/nx_mcp/authoring_server.py index 97e36e6..4870be8 100644 --- a/src/nx_mcp/authoring_server.py +++ b/src/nx_mcp/authoring_server.py @@ -5,6 +5,8 @@ from typing import Any, Literal READ_ONLY = { + "nx_list_explosions", + "nx_explosion_info", "nx_find_geometry", "nx_list_expressions", "nx_model_health", @@ -425,11 +427,13 @@ def nx_create_drawing( def nx_add_base_view( drawing: str, - body: str, - view: Literal["top", "front", "back", "right", "left", "bottom", "isometric"], + 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. Requires a single-body part so the specified body is the exact view scope. position=[x,y] uses sheet mm, default [100,100]. Return typed view reference; open the target sheet.""" + """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 mm, default [100,100]. Return typed view reference; open the target sheet.""" def nx_export_drawing_pdf(path: str): @@ -450,3 +454,53 @@ def nx_add_dimension( origin: list[float] | None = None, ): """Create a native associative linear drawing dimension from owned 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 mm, default [100,80]. 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/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 1595623..c3da500 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-engineering-r1", + "revision": "2606-explosions-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -612,6 +612,35 @@ "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": "experimental", + "scope": "Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending." } }, "limitations": [ diff --git a/src/nx_mcp/exploded_views.py b/src/nx_mcp/exploded_views.py new file mode 100644 index 0000000..5944d5c --- /dev/null +++ b/src/nx_mcp/exploded_views.py @@ -0,0 +1,463 @@ +"""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): + """Display native sheets for CGM-preserving saves, then restore the view.""" + sheets = list(getattr(part, "DrawingSheets", [])) + if not sheets or not part.SaveOptions.DrawingCgmData: + 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() + 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", + ) + 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 _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 + uf.SetViewExplosion(view.Tag, ex.Tag if ex else 0) + 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 mm" + ) + 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: + builder.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view]) + builder.Placement.Placement.SetValue(None, None, self.nxopen.Point3d(*point, 0.0)) + result = builder.Commit() + finally: + builder.Destroy() + 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, + "position_mm": 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/hardened.py b/src/nx_mcp/hardened.py index 7d03aee..c9e940b 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -15,6 +15,7 @@ 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.engineering import EngineeringMixin +from nx_mcp.exploded_views import ExplodedViewsMixin from nx_mcp.inspection import InspectionMixin from nx_mcp.nx_bridge import NXOpenExecutor from nx_mcp.recovery import OperationStore, timestamp @@ -116,6 +117,7 @@ def add(a, b): class HardenedExecutor( + ExplodedViewsMixin, EngineeringMixin, AdvancedAuthoringMixin, AuthoringMixin, @@ -137,6 +139,12 @@ def __init__(self, *args, **kwargs): self._handlers.update( { "nx_resolve_geometry": self._resolve_geometry, + "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, @@ -373,6 +381,10 @@ def handler(**p): 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", @@ -537,10 +549,12 @@ def _activate_part(self, part, work=True, display=True): def _save_part(self): part = self._work_part() - status = part.Save( - self.nxopen.BasePart.SaveComponents.TrueValue, - self.nxopen.BasePart.CloseAfterSave.FalseValue, - ) + self._save_component_drawing_previews(part) + with self._drawing_save_context(part): + status = part.Save( + self.nxopen.BasePart.SaveComponents.TrueValue, + self.nxopen.BasePart.CloseAfterSave.FalseValue, + ) if status and hasattr(status, "Dispose"): status.Dispose() state = self._checkpoint_state() @@ -559,7 +573,8 @@ def _save_as(self, path): if dest.exists(): raise NXToolError("NX_FILE_EXISTS", "Save-as does not overwrite existing files") dest.parent.mkdir(parents=True, exist_ok=True) - status = part.SaveAs(str(dest)) + with self._drawing_save_context(part): + status = part.SaveAs(str(dest)) if status and hasattr(status, "Dispose"): status.Dispose() return { @@ -573,10 +588,11 @@ def _close_part(self, save=True, part=None): pid = self._part_id(target) tag = int(target.Tag) if save: - status = target.Save( - self.nxopen.BasePart.SaveComponents.FalseValue, - self.nxopen.BasePart.CloseAfterSave.FalseValue, - ) + 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( @@ -1437,6 +1453,8 @@ def _finish_sketch(self, sketch_id): 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", [])), diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 4360d27..5207ca1 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -516,7 +516,13 @@ async def proxy(**kwargs): 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"}, + or name + in { + "nx_set_component_transform", + "nx_create_directory", + "nx_edit_explosion", + "nx_show_explosion", + }, ), ) tool = mcp._tool_manager.get_tool(name) @@ -527,6 +533,10 @@ async def proxy(**kwargs): 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): diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index 633f583..ff98dd7 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -22,6 +22,8 @@ "drawing_sheet", "drawing_view", "dimension", + "explosion", + "modeling_view", ] diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py new file mode 100644 index 0000000..05c3188 --- /dev/null +++ b/tests/test_exploded_views.py @@ -0,0 +1,567 @@ +"""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 + 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(RuntimeError, match="disk error"): + r.e._save_part() + 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.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() + ) + + def commit(): + view = Object("Base") + 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 diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py index ffd080d..137da4e 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 124 + assert len(tools) == 130 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 4f18ab8040b702144a7c89c6707eaf9180fc36b6 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 23:22:15 +0200 Subject: [PATCH 21/69] Record deployed NX 2606 explosion acceptance and recovery evidence --- docs/dev9-validation.json | 40 +++++++++++++++++++++++++++++++++++++++ docs/fork-status.md | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 docs/dev9-validation.json diff --git a/docs/dev9-validation.json b/docs/dev9-validation.json new file mode 100644 index 0000000..829212e --- /dev/null +++ b/docs/dev9-validation.json @@ -0,0 +1,40 @@ +{ + "version": "0.2.0.dev9", + "runtime_commit": "bc54d38ecde44bd3a36ca89e1830959a83d47961", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 130, + "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/33992483926", + "release_build": "https://github.com/xuio/NX_MCP/actions/runs/33992483710", + "release_sha256": "8f57ef6156688581154c0051f3b92b1a528e2c8863c435493fdd88005f12fcf4", + "native_passed_groups": [ + "absolute_nested_poses_reset_retry", + "views_pdf_save_as_reopen", + "preflight_rollback_view_guard_delete_stale" + ], + "original_loaded_parts_restored": 38, + "original_occurrences_verified": 116, + "local_validation": { + "pytest_passed": 610, + "pytest_skipped": 1, + "combined_statement_branch_coverage_percent": 80.51, + "unchanged_coverage_gate_percent": 78, + "mypy": "passed", + "pre_commit": "passed", + "ci": "all jobs passed" + }, + "visual_review": { + "native_png": "Native 2946x1575 viewport PNG delivered and checksum verified; three separated solid occurrences visible. NX returned a larger size than requested and reports a warning.", + "drawing_pdf": "A3 one-page PDF with assembled/exploded/projected views; downloaded, checksum verified, parsed and visually inspected. Fixture datum axes remain visible." + }, + "known_scopes": [ + "Native absolute parent/child placement, inherited reset, identical operation-ID retry and new-ID absolute replay.", + "Base/projected drawing association and update propagation; CGM-preserving save, Save As and save-on-close; reopen persistence.", + "Native checkpoint rollback, preflight rejection without pose changes, referenced-view deletion guard, view detach, deletion and stale-reference rejection.", + "Local tests inject mid-batch native failure; real transport interruption during a native explosion edit was not injected.", + "The runtime manifest conservatively labels deletion experimental because its deployed acceptance followed packaging; the native pass is recorded here.", + "Ordinary collision, clearance and mass tools measure assembled geometry. No trace lines, automatic layouts, animation, BOMs or balloons.", + "Freeform surfaces and other roadmap items are proposed, not implemented." + ], + "pull_request_opened": false +} diff --git a/docs/fork-status.md b/docs/fork-status.md index 0ed3498..0b8af86 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -44,7 +44,7 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current engineering runtime CI and native evidence are recorded in [dev8 acceptance](dev8-validation.json); [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current exploded-view runtime CI and native evidence are recorded in [dev9 acceptance](dev9-validation.json); [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). From f85dce388e4d656e8ac86d4efdb4473bfa8043da Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sat, 5 Sep 2026 23:25:19 +0200 Subject: [PATCH 22/69] Synchronize response-timeout retry test with server receipt --- tests/test_hardening.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/test_hardening.py b/tests/test_hardening.py index c3ce395..5d8ad9a 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -1,8 +1,9 @@ """Safety and MCP contract regressions; these do not substitute for real NX tests.""" +import asyncio import base64 import hashlib -import time +from threading import Event from types import SimpleNamespace import pytest @@ -98,25 +99,37 @@ def test_restarted_pending_receipt_becomes_unknown(executor): @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): - time.sleep(0.1) + 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: - p = {"value": 9, "operation_id": "lost-response-123"} - with pytest.raises(NXToolError): - await BridgeClient("127.0.0.1", server.port, token="test-token", timeout=0.02).call( - "nx_test_mutate", p - ) - result = await BridgeClient("127.0.0.1", server.port, token="test-token", timeout=2).call( + # 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() From 80eed43bc8339494717ab0b0daa37191bc17c1f4 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 01:49:03 +0200 Subject: [PATCH 23/69] Add native NX sheet-metal authoring, flat patterns and path sketches --- README.md | 2 +- docs/fork-status.md | 6 +- docs/sheet-metal-native-validation.json | 540 +++ docs/sheet-metal.md | 133 + examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 2 +- examples/validate_project_folders.py | 2 +- examples/validate_sheet_metal.py | 268 ++ examples/validate_visual_tools.py | 2 +- pyproject.toml | 4 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/capability_manifest.json | 52 +- src/nx_mcp/hardened.py | 26 +- src/nx_mcp/integration_server.py | 10 +- src/nx_mcp/runtime.py | 2 + src/nx_mcp/sheet_metal.py | 1120 +++++ src/nx_mcp/sheet_metal_catalog.json | 5434 +++++++++++++++++++++++ src/nx_mcp/sheet_metal_server.py | 141 + tests/fakes/__init__.py | 16 +- tests/test_sheet_metal.py | 731 +++ tests/test_visual_tools.py | 2 +- 22 files changed, 8477 insertions(+), 22 deletions(-) create mode 100644 docs/sheet-metal-native-validation.json create mode 100644 docs/sheet-metal.md create mode 100644 examples/validate_sheet_metal.py create mode 100644 src/nx_mcp/sheet_metal.py create mode 100644 src/nx_mcp/sheet_metal_catalog.json create mode 100644 src/nx_mcp/sheet_metal_server.py create mode 100644 tests/test_sheet_metal.py diff --git a/README.md b/README.md index 4f97df1..3dd3de3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev9` integration and 130 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev10` integration and 140 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 diff --git a/docs/fork-status.md b/docs/fork-status.md index 0b8af86..eddea56 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev9`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev10`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. @@ -14,7 +14,7 @@ See [engineering tools and scoped validation](engineering-tools.md) for the late - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev9 opt-in integration profile exposes 130 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev10 opt-in integration profile exposes 140 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -49,3 +49,5 @@ A series of focused pull requests is preferable to the full integration diff. Th Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). See [native exploded views](exploded-views.md) for dev9 presentation and drawing contracts, and the [advanced roadmap](advanced-roadmap.md) for proposed freeform and manufacturing work. + +See [native sheet metal](sheet-metal.md) for the dev10 operation catalog, verified scope, flat-pattern exports and measured PMI semantics. diff --git a/docs/sheet-metal-native-validation.json b/docs/sheet-metal-native-validation.json new file mode 100644 index 0000000..0d67075 --- /dev/null +++ b/docs/sheet-metal-native-validation.json @@ -0,0 +1,540 @@ +{ + "nx_version": "v2606", + "execution": "Serialized graphical NX main thread, isolated disposable parts", + "stage": "pre-release native executor validation; public MCP acceptance is separate", + "operations": { + "contour_flange": { + "native_type": "BCFLANGE", + "source_fixture": "suite1", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "thickness": 2, + "sweep_distance": 40 + } + }, + "bend": { + "native_type": "BEND", + "source_fixture": "suite1", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "target_face": "$input_2", + "bend_angle": 90 + } + }, + "hem": { + "native_type": "Hem Flange", + "source_fixture": "suite1", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "edge_chain": [ + "$input_1" + ], + "type": "OpenHemType", + "first_flange_length": 10, + "first_bend_radius": 2 + } + }, + "break_corner": { + "native_type": "Break Corner", + "source_fixture": "suite1", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "edges": [ + "$input_1" + ], + "type": "Fillet", + "value": 3 + } + }, + "resize_bend_angle": { + "native_type": "Resize Bend Angle", + "source_fixture": "suite1", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "bend_face": "$input_1", + "angle": 110, + "reference_face": "$input_2" + } + }, + "resize_bend_radius": { + "native_type": "Resize Bend Radius", + "source_fixture": "suite1", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "bend_faces": [ + "$input_1" + ], + "bend_radius": 3, + "reference_entity": "$input_2" + } + }, + "jog": { + "native_type": "JOG", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "target_face": "$input_2", + "height": 10, + "angle": 90 + } + }, + "normal_cutout": { + "native_type": "Normal Cutout", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "target_body": "$input_2", + "depth_type": "ThroughAll", + "depth_side": "Symmetric", + "depth": 5 + } + }, + "dimple": { + "native_type": "Dimple", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "depth": 3, + "taper_angle": 15, + "include_rounding": false + } + }, + "drawn_cutout": { + "native_type": "Drawn Cutout", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "cutout_depth": 5, + "side_angle": 60, + "include_rounding": false + } + }, + "advanced_flange": { + "native_type": "AdvancedFlange", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "edges": [ + "$input_1" + ], + "length": 20, + "angle": 90 + } + }, + "variational_flange": { + "native_type": "VariationalFlange", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "edges": [ + "$input_1" + ], + "length_law_type": "Linear", + "start_length": 10, + "end_length": 20, + "angle": 90, + "radius": 2, + "neutral_factor": 0.33 + } + }, + "resize_neutral_factor": { + "native_type": "Resize Neutral Factor", + "source_fixture": "suite2", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "bend_faces": [ + "$input_1" + ], + "neutral_factor": 0.4 + } + }, + "lofted_flange": { + "native_type": "BLFLANGE", + "source_fixture": "suite4", + "model_health": true, + "saved_and_closed": true, + "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 + } + }, + "bead": { + "native_type": "Bead", + "source_fixture": "suite4", + "model_health": true, + "saved_and_closed": true, + "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 + } + }, + "convert": { + "native_type": "Convert To Sheetmetal", + "source_fixture": "suite5", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "base_face": "$input_1", + "is_uniform_thickness": true + } + }, + "gusset": { + "native_type": "SB_Gusset", + "source_fixture": "suite5", + "model_health": true, + "saved_and_closed": true, + "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 + } + }, + "bridge_bend": { + "native_type": "FPC Bridge Transition", + "source_fixture": "suite5", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "start_edge": "$input_1", + "end_edge": "$input_2", + "type": "Zu", + "width_type": "FullBothEdges", + "length": 20, + "width": 80 + } + }, + "bend_taper": { + "native_type": "Bend Taper", + "source_fixture": "suite5", + "model_health": true, + "saved_and_closed": true, + "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" + } + }, + "flat_solid": { + "native_type": "SB_FLAT_SOLID", + "source_fixture": "suite5", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "stationary_face": "$input_1", + "x_axis_edge": "$input_2", + "associative": true + } + }, + "lightening_cutout": { + "native_type": "Lightening Cutout", + "source_fixture": "suite5", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "type": "Hole", + "hole_center": [ + [ + 50, + 40, + 0 + ] + ], + "diameter": 12, + "length": 4, + "angle": 45, + "die_radius": 2 + } + }, + "closed_corner": { + "native_type": "Closed Corner", + "source_fixture": "suite6", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "face_pairs": [ + [ + "$input_1", + "$input_2" + ] + ], + "gap": 0.5, + "overlap_type": "NotSet", + "treatment_type": "CircularCutout", + "diameter": 6 + } + }, + "joggle": { + "native_type": "SB_Joggle", + "source_fixture": "suite6", + "model_health": true, + "saved_and_closed": true, + "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 + } + } + }, + "solid_punch": { + "native_type": "SMSPUNCH", + "source_fixture": "suite6", + "model_health": true, + "saved_and_closed": true, + "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 + } + }, + "from_solid": { + "native_type": "SB Sheet Metal from Solid", + "source_fixture": "suite7", + "model_health": true, + "saved_and_closed": true, + "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 + } + }, + "louver": { + "native_type": "Louver", + "source_fixture": "suite9", + "model_health": true, + "saved_and_closed": true, + "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 + } + }, + "three_bend_corner": { + "native_type": "Three Bend Corner", + "source_fixture": "suite11", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "face_pairs": [ + [ + "$input_1", + "$input_2" + ] + ], + "corner_gap": 1, + "flange_clearance": 1, + "treatment_type": "Open", + "diameter": 4 + } + }, + "bulge_relief": { + "native_type": "SB_BulgeRelief", + "source_fixture": "suite11", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "bend_edges": [ + "$input_1" + ], + "depth": 3, + "width": 6, + "radius": 2, + "relief_type": "Circular" + } + }, + "edge_rip": { + "native_type": "Edge Rip", + "source_fixture": "suite14", + "model_health": true, + "saved_and_closed": true, + "parameters": { + "section": "$input_1", + "width": 0.5, + "symmetric": true, + "use_system_width": false, + "end_cap_shape": "Round" + } + }, + "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" + }, + "tab": { + "native_type": "Base Tab", + "source_fixture": "api24", + "scope": "100 x 80 mm rectangular tab, thickness 2 mm; successful save and close" + }, + "flange": { + "native_type": "SB_FLANGE", + "source_fixture": "api12/api20/api24", + "scope": "90-degree flange, radius and neutral factor read-back; length edited from 20 to 25 mm" + }, + "flat_pattern": { + "native_type": "FLAT_PATTERN", + "source_fixture": "api15/api18", + "scope": "Native developed bracket, DXF/GEO export; flat pattern drawing and visually inspected PDF" + } + }, + "notes": [ + "Native creation success is scoped to the listed fixture; not all parameter combinations or edit modes were exercised.", + "Edge rip was verified with a short planar-face sketch slit; selected-edge ripping remains unverified.", + "PMI creation, geometric association and explicit refresh use measured text snapshots, not automatic numeric text updates.", + "Flat-pattern DXF dimensions were independently checked from LINE entities; header bounds are not trusted.", + "Part-wide bounds and summed volume include derived flat-pattern bodies and hidden source solids. Use the folded body ID for physical-part measurements." + ], + "secondary_features": { + "source_fixture": "suite16", + "secondary_tab_volume_mm3": 32000, + "secondary_contour": "Along-path sketch plus attached contour flange; native health, save and close passed" + } +} diff --git a/docs/sheet-metal.md b/docs/sheet-metal.md new file mode 100644 index 0000000..be2f22f --- /dev/null +++ b/docs/sheet-metal.md @@ -0,0 +1,133 @@ +# Native sheet metal + +The dev10 opt-in integration adds ten tools (140 total). It exposes 34 native +sheet-metal feature families and 412 top-level builder fields, with nested bend, +relief, miter, flange, corner and multi-thickness settings. These are native NX +features, not tessellated substitutes or ordinary solids renamed as sheet metal. + +Each family has a successful **NX v2606 creation fixture**. This is broad authoring +coverage, not a claim that every NX sheet-metal workflow or parameter combination +is complete. See [scoped native evidence](sheet-metal-native-validation.json). +Other NX versions, most feature edit combinations, table-driven materials, and +custom bend tables remain unverified. Metaform and manufacturing nesting are not +exposed. Remove Bends was unavailable under the tested installation's feature toggle. + +## 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. + +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. + +## Reproducible acceptance + +Run `examples/validate_sheet_metal.py` against an isolated test session using +`NX_MCP_URL` and `NX_VALIDATION_OUTPUT`. It preserves the original saved session, +creates a bracket in a unique folder, checks analytical volume and bend data, +checks same-ID replay, downloads native artifacts with checksum verification, +checks developed DXF dimensions before/after a parameter edit, exercises +save/reopen/stale references, and verifies rejection and checkpoint rollback. + +The public acceptance checks a complete bracket workflow. The separate 34-family +fixture evidence reports the broader creation coverage. Neither mocked wrapper +tests nor a builder's mere presence counts as native geometry verification. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index f9462fd..13a7495 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 130 + assert len(tools) == 140 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 7740910..1af43e3 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 130 + assert len(tools) == 140 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index 5c389d8..f498053 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -108,7 +108,7 @@ async def assembly(name): 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) == 130 + assert len((await client.list_tools()).tools) == 140 async def limits(): await new("offset") diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py index f0e0142..a4734be 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,7 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 130 + assert len((await c.list_tools()).tools) == 140 info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py new file mode 100644 index 0000000..4ae6d55 --- /dev/null +++ b/examples/validate_sheet_metal.py @@ -0,0 +1,268 @@ +"""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) == 140 + 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["thickness"] == 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 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", view="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") + 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_visual_tools.py b/examples/validate_visual_tools.py index f9ad4af..602f201 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 130, len(names) + assert len(names) == 140, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 877664f..170c626 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev9" +version = "0.2.0.dev10" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" @@ -85,4 +85,4 @@ module = "NXOpen.*" ignore_missing_imports = true [tool.setuptools.package-data] -nx_mcp = ["capability_manifest.json"] +nx_mcp = ["capability_manifest.json", "sheet_metal_catalog.json"] diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 92559b8..6bfaae1 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.dev9" +__version__ = "0.2.0.dev10" diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index c3da500..29d571e 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-explosions-r1", + "revision": "2606-sheet-metal-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -641,6 +641,56 @@ "nx_delete_explosion": { "status": "experimental", "scope": "Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending." + }, + "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." + }, + "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." } }, "limitations": [ diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index c9e940b..785b1d6 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -10,6 +10,7 @@ import uuid from pathlib import Path +from nx_mcp import sheet_metal_server from nx_mcp.advanced_authoring import AdvancedAuthoringMixin from nx_mcp.authoring import AuthoringMixin from nx_mcp.authoring_server import NON_MODEL as AUTHORING_NON_MODEL @@ -21,6 +22,7 @@ from nx_mcp.recovery import OperationStore, timestamp from nx_mcp.review_tools import ReviewToolsMixin from nx_mcp.runtime import NXToolError +from nx_mcp.sheet_metal import SheetMetalMixin from nx_mcp.visual_tools import VisualToolsMixin READ_ONLY = { @@ -112,11 +114,12 @@ def add(a, b): 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) -NON_MODEL.update(AUTHORING_NON_MODEL) +READ_ONLY.update(AUTHORING_READ_ONLY | sheet_metal_server.READ_ONLY) +NON_MODEL.update(AUTHORING_NON_MODEL | sheet_metal_server.NON_MODEL) class HardenedExecutor( + SheetMetalMixin, ExplodedViewsMixin, EngineeringMixin, AdvancedAuthoringMixin, @@ -139,6 +142,16 @@ def __init__(self, *args, **kwargs): self._handlers.update( { "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, @@ -289,7 +302,12 @@ def handler(**p): part = self._work_part(required=False) part_id = self._part_id(part) if part else None if mutable: - fingerprint = self.store.fingerprint(method, params) + 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: @@ -1459,6 +1477,7 @@ def _snapshot(self, part): ("drawing_sheet", getattr(part, "DrawingSheets", [])), ("drawing_view", getattr(part, "DraftingViews", [])), ("dimension", getattr(part, "Dimensions", [])), + ("annotation", [*getattr(part, "Notes", []), *getattr(part, "Labels", [])]), ( "component_pattern", self._component_patterns(part), @@ -1467,6 +1486,7 @@ def _snapshot(self, part): ("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)]), diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 5207ca1..57ba1b6 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -12,7 +12,7 @@ from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations -from nx_mcp import authoring_server +from nx_mcp import authoring_server, sheet_metal_server from nx_mcp.recovery import OperationStore from nx_mcp.runtime import NXToolError from nx_mcp.workspace import WorkspaceViolation @@ -338,11 +338,11 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_workspace_list", "nx_download_file", } -READ_ONLY.update(authoring_server.READ_ONLY) +READ_ONLY.update(authoring_server.READ_ONLY | sheet_metal_server.READ_ONLY) DESCRIPTIONS.update( { name: obj.__doc__ or name - for name, obj in vars(authoring_server).items() + for name, obj in {**vars(authoring_server), **vars(sheet_metal_server)}.items() if name.startswith("nx_") and inspect.isfunction(obj) } ) @@ -357,6 +357,8 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_cancel_operation", } PATHS = { + "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", @@ -395,7 +397,7 @@ def configure(mcp, bridge, workspace): definitions.update( { name: obj - for name, obj in vars(authoring_server).items() + for name, obj in {**vars(authoring_server), **vars(sheet_metal_server)}.items() if name.startswith("nx_") and inspect.isfunction(obj) } ) diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index ff98dd7..7e29c48 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -9,6 +9,7 @@ "part", "sketch", "curve", + "point", "feature", "body", "component", @@ -22,6 +23,7 @@ "drawing_sheet", "drawing_view", "dimension", + "annotation", "explosion", "modeling_view", ] diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py new file mode 100644 index 0000000..2618f58 --- /dev/null +++ b/src/nx_mcp/sheet_metal.py @@ -0,0 +1,1120 @@ +"""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"}, + } + return { + "type": "string", + "description": "Typed work-part object ID; section takes a finished sketch ID.", + } + + +def fields_schema(fields, required=()): + return { + "type": "object", + "properties": {k: field_schema(v) 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": self._units() if self._work_part(required=False) else 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"], + "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_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": self._units() if self._work_part(required=False) else None, + "coordinate_frame": "work_part", + } + + 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 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 mm" + ) + 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.nxopen.Point3d(*point, 0.0)) + view = builder.Commit() + finally: + builder.Destroy() + 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, + "position_mm": point, + "units": "mm", + } + + def _sheet_metal_annotation(self, kind, body, position, faces=None, annotation=None): + 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 (measured snapshot)", + f"Thickness: {measured['thickness']:.3f} {units}", + ] + else: + measured = {"bends": []} + lines = ["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() + 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": "Measured snapshot; call this tool with the annotation ID to refresh after model edits", + "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..40636bb --- /dev/null +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -0,0 +1,5434 @@ +{ + "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 + }, + "length": { + "path": "Length", + "getter": false, + "kind": "expression" + }, + "length_option": { + "path": "LengthOption", + "getter": false, + "kind": "enum", + "enum_type": "FlangeBendPropertiesBuilderLengthOptions", + "values": [ + "Value", + "Keypoint" + ] + }, + "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" + ] + }, + "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" + } + } + } + } + } + }, + "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" + }, + "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 + } + }, + "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" + }, + "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" + }, + "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 + }, + "plane2": { + "path": "Plane2", + "getter": false, + "kind": "plane", + "assign": true + }, + "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" + ] + } + }, + "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" + }, + "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" + }, + "hide_original_curves": { + "path": "HideOriginalCurves", + "getter": false, + "kind": "boolean" + }, + "reference_entity": { + "path": "ReferenceEntity", + "getter": false, + "kind": "face_or_edge", + "assign": true + } + }, + "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" + }, + "rebend": { + "builder": "RebendBuilder", + "factory": "CreateRebendFeatureBuilder", + "fields": { + "face_collector": { + "path": "FaceCollector", + "getter": false, + "kind": "collector", + "assign": true, + "objects": "face" + }, + "reference_entity": { + "path": "ReferenceEntity", + "getter": false, + "kind": "face_or_edge", + "assign": true + } + }, + "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" + } +} diff --git a/src/nx_mcp/sheet_metal_server.py b/src/nx_mcp/sheet_metal_server.py new file mode 100644 index 0000000..d20d9e9 --- /dev/null +++ b/src/nx_mcp/sheet_metal_server.py @@ -0,0 +1,141 @@ +"""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 mm; 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, +): + """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/tests/fakes/__init__.py b/tests/fakes/__init__.py index 5327d45..946aa44 100644 --- a/tests/fakes/__init__.py +++ b/tests/fakes/__init__.py @@ -186,7 +186,15 @@ 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] + 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)) @@ -197,7 +205,9 @@ 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], groups, strict=True + [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: @@ -223,6 +233,8 @@ def __init__(self, session, path): self.Curves = 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( diff --git a/tests/test_sheet_metal.py b/tests/test_sheet_metal.py new file mode 100644 index 0000000..6d95c89 --- /dev/null +++ b/tests/test_sheet_metal.py @@ -0,0 +1,731 @@ +"""Sheet-metal contracts and failure recovery; these are not NX kernel tests.""" + +import inspect +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["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() diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py index 137da4e..21eeafa 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 130 + assert len(tools) == 140 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 2f0ff409ab684e7c897daa976ea0dfea96afea6a Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 02:07:14 +0200 Subject: [PATCH 24/69] Record deployed sheet-metal acceptance and complete the public workflow fixture --- docs/dev10-validation.json | 62 ++++++++++++++++++++++++++++++++ docs/fork-status.md | 2 +- examples/validate_sheet_metal.py | 57 +++++++++++++++++++++++++++-- 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 docs/dev10-validation.json diff --git a/docs/dev10-validation.json b/docs/dev10-validation.json new file mode 100644 index 0000000..18441c1 --- /dev/null +++ b/docs/dev10-validation.json @@ -0,0 +1,62 @@ +{ + "version": "0.2.0.dev10", + "runtime_commit": "80eed43bc8339494717ab0b0daa37191bc17c1f4", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 140, + "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/33999666551", + "release_build": "https://github.com/xuio/NX_MCP/actions/runs/33999684234", + "release_sha256": "78555841cad6821908a614dd5dca962d60a66123894e8c45cb09cf914d7261a6", + "native_creation_families_passed": 34, + "public_workflow_checks": [ + "tab_analytic_volume_flange_bend_info_retry_pmi", + "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", + "unsupported_edit_unchanged_checkpoint_rollback", + "path_sketch_secondary_contour_analytic_volume" + ], + "public_workflow_passed": true, + "original_session_restored": true, + "original_saved_parts": 38, + "original_occurrences_verified": 116, + "local_validation": { + "pytest_passed": 666, + "pytest_skipped": 1, + "combined_statement_branch_coverage_percent": 79.9, + "unchanged_coverage_gate_percent": 78, + "mypy": "passed", + "pre_commit": "passed", + "ci": "passed" + }, + "windows_validators": { + "stdio_tools": 140, + "http_tools": 140, + "native_inline_capture_checksum": "passed", + "serialized_ui_thread": "verified" + }, + "developed_dimensions_mm": { + "bracket.dxf": [ + 97.749115, + 100.0 + ], + "bracket-edited.dxf": [ + 100.0, + 102.749115 + ] + }, + "visual_review": { + "render": "Native 1600x1000 PNG: folded bracket and readable measured-snapshot PMI, unclipped.", + "drawing": "Native one-page A4 PDF: centered developed perimeter and bend/tangent lines, unclipped; no manufacturing-drawing completeness claim.", + "vm": "Controller restored in the visible graphical NX session; no blocking dialog." + }, + "scopes": [ + "The 34-family native fixture evidence is in sheet-metal-native-validation.json. It does not verify every option or edit combination.", + "Part defaults inherited correctly: native 3 mm radius read back as 2.9999999999999996. Acceptance uses numerical tolerance.", + "Native path sketch and secondary contour flange also passed through public MCP with independent 300 mm^3 volume.", + "The acceptance script was corrected after deployment to read expression-valued defaults, use geometry tolerance and pass the existing orientation argument. Runtime geometry code was unchanged.", + "Measured PMI snapshots require explicit refresh; automatic numeric text updates and drawing bend tables are not implemented.", + "Metaform and manufacturing nesting are not exposed; Remove Bends was unavailable on the tested installation.", + "Material/tool-table and custom bend-table workflows remain experimental.", + "Edge rip passed for an interior planar-face sketch slit; selected-edge ripping remains unverified." + ], + "pull_request_opened": false +} diff --git a/docs/fork-status.md b/docs/fork-status.md index eddea56..db8e41f 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -44,7 +44,7 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current exploded-view runtime CI and native evidence are recorded in [dev9 acceptance](dev9-validation.json); [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current sheet-metal runtime CI and native evidence are recorded in [dev10 acceptance](dev10-validation.json); [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py index 4ae6d55..0433421 100644 --- a/examples/validate_sheet_metal.py +++ b/examples/validate_sheet_metal.py @@ -98,7 +98,7 @@ async def volume(body): defaults = await call( "nx_set_sheet_metal_defaults", thickness=2, bend_radius=3, neutral_factor=0.33 ) - assert defaults["thickness"] == 2 + assert defaults["parameters"]["thickness"]["value"] == 2 sketch = (await call("nx_create_sketch"))["object"]["id"] await call( "nx_sketch_rectangle", @@ -131,7 +131,7 @@ async def volume(body): 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 bend["inner_radius"] == 3 + assert math.isclose(bend["angle_degrees"], 90) and math.isclose(bend["inner_radius"], 3) note = await call( "nx_sheet_metal_annotation", kind="bend", @@ -144,7 +144,7 @@ async def volume(body): receipt["checks"].append("tab_analytic_volume_flange_bend_info_retry_pmi") save() - await call("nx_set_view", view="isometric") + 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" @@ -240,6 +240,57 @@ async def volume(body): 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 From b2f1acc9709463ab4298dd61de9fb7152463fabf Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 03:39:08 +0200 Subject: [PATCH 25/69] Add native freeform, assembly documentation and manufacturing tools --- README.md | 4 +- docs/fork-status.md | 6 +- docs/freeform-manufacturing.md | 39 ++ examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 2 +- examples/validate_freeform_manufacturing.py | 398 +++++++++++++++++ examples/validate_project_folders.py | 2 +- examples/validate_sheet_metal.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/animation.py | 48 +++ src/nx_mcp/assembly_documentation.py | 446 +++++++++++++++++++ src/nx_mcp/assembly_documentation_server.py | 49 +++ src/nx_mcp/capability_manifest.json | 102 ++++- src/nx_mcp/exploded_views.py | 13 + src/nx_mcp/freeform.py | 444 +++++++++++++++++++ src/nx_mcp/freeform_server.py | 71 +++ src/nx_mcp/hardened.py | 36 +- src/nx_mcp/integration_server.py | 33 +- src/nx_mcp/manufacturing.py | 358 ++++++++++++++++ src/nx_mcp/manufacturing_server.py | 75 ++++ src/nx_mcp/runtime.py | 1 + src/nx_mcp/surface_math.py | 57 +++ tests/test_exploded_views.py | 1 + tests/test_freeform_manufacturing.py | 450 ++++++++++++++++++++ tests/test_visual_tools.py | 2 +- 28 files changed, 2628 insertions(+), 21 deletions(-) create mode 100644 docs/freeform-manufacturing.md create mode 100644 examples/validate_freeform_manufacturing.py create mode 100644 src/nx_mcp/animation.py create mode 100644 src/nx_mcp/assembly_documentation.py create mode 100644 src/nx_mcp/assembly_documentation_server.py create mode 100644 src/nx_mcp/freeform.py create mode 100644 src/nx_mcp/freeform_server.py create mode 100644 src/nx_mcp/manufacturing.py create mode 100644 src/nx_mcp/manufacturing_server.py create mode 100644 src/nx_mcp/surface_math.py create mode 100644 tests/test_freeform_manufacturing.py diff --git a/README.md b/README.md index 3dd3de3..54de570 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev10` integration and 140 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev11` integration and 160 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 @@ -142,3 +142,5 @@ release gates. Authoring and review tools add geometric selection, expression binding, model health, sketch editing, assembly maintenance, saved presentations, inspection reports, compact summaries, and reversible previews. See [supported operations and limits](docs/authoring-review.md). Advanced NX 2606 tools: [exact selection, associative component patterns and sketch dimensions](docs/advanced-authoring.md). Proposed upstream review slices are documented in the [review package](docs/upstream-review.md); no PR is opened by the release workflow. + +See [freeform, assembly documentation and manufacturing](docs/freeform-manufacturing.md) for the dev11 additions and scoped native verification. diff --git a/docs/fork-status.md b/docs/fork-status.md index db8e41f..6dbd1f8 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev10`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev11`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. @@ -14,7 +14,7 @@ See [engineering tools and scoped validation](engineering-tools.md) for the late - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev10 opt-in integration profile exposes 140 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev11 opt-in integration profile exposes 160 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -51,3 +51,5 @@ Explicit nested and absolute in-workspace file paths, directory creation, and Sa See [native exploded views](exploded-views.md) for dev9 presentation and drawing contracts, and the [advanced roadmap](advanced-roadmap.md) for proposed freeform and manufacturing work. See [native sheet metal](sheet-metal.md) for the dev10 operation catalog, verified scope, flat-pattern exports and measured PMI semantics. + +See [freeform, assembly documentation and manufacturing](freeform-manufacturing.md) for the dev11 additions and scoped native verification. diff --git a/docs/freeform-manufacturing.md b/docs/freeform-manufacturing.md new file mode 100644 index 0000000..9e1175a --- /dev/null +++ b/docs/freeform-manufacturing.md @@ -0,0 +1,39 @@ +# Freeform, documentation and manufacturing + +The dev11 integration adds 20 tools (160 total) for NX v2606. These are intent-oriented wrappers around native NX geometry, annotations and rendering. Model mutations run serially on the graphical NX thread with the existing operation-ID deduplication and rollback framework. Coordinates are in the work part unless the tool explicitly uses assembly or drawing-sheet coordinates. + +## 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`. + +## 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](sheet-metal.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. + +## Repeatable acceptance + +Run `examples/validate_freeform_manufacturing.py` with `NX_MCP_URL` and an optional `NX_VALIDATION_OUTPUT`. It uses disposable workspace parts, records operation receipts and downloaded artifact checksums, and restores the original saved session. The graphical bridge and experimental integration profile must be enabled. The ordinary test suite also covers contracts, validation, cleanup, numerical invariance and animation failure recovery; mocked tests are not native NX evidence. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index 13a7495..83b5214 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 140 + assert len(tools) == 160 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 1af43e3..f0a267d 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 140 + assert len(tools) == 160 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index f498053..6fcb182 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -108,7 +108,7 @@ async def assembly(name): 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) == 140 + assert len((await client.list_tools()).tools) == 160 async def limits(): await new("offset") diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py new file mode 100644 index 0000000..7288cbd --- /dev/null +++ b/examples/validate_freeform_manufacturing.py @@ -0,0 +1,398 @@ +"""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(name, **params): + response = await client.call_tool(name, params) + receipt["operations"].append( + { + "tool": name, + "error": response.isError, + "operation_id": response.structuredContent.get("operation_id"), + } + ) + save() + assert not response.isError, (name, response.structuredContent) + return response.structuredContent + + async def reject(name, **params): + response = await client.call_tool(name, params) + assert response.isError, (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) == 160 + 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=[face], + characteristic="Parallelism", + tolerance=0.05, + position=[25, 20, 5], + datums=[datum["annotation"]["id"]], + ) + assert fcf["geometry_associated"] + await call( + "nx_pmi_fcf", + faces=[face], + 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 = (await call("nx_list_components"))["components"] + 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"]] + await call( + "nx_add_component", + part_path=prefix + "/prototype.prt", + name="Cube3", + translation=[75, 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_project_folders.py b/examples/validate_project_folders.py index a4734be..891872b 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,7 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 140 + assert len((await c.list_tools()).tools) == 160 info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py index 0433421..95b136b 100644 --- a/examples/validate_sheet_metal.py +++ b/examples/validate_sheet_metal.py @@ -90,7 +90,7 @@ async def volume(body): 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) == 140 + assert len((await client.list_tools()).tools) == 160 catalog = await call("nx_sheet_metal_schema") assert len(catalog["operations"]) == 34 await call("nx_create_part", path=prefix + "/bracket.prt", units="mm") diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 602f201..cc2518d 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 140, len(names) + assert len(names) == 160, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 170c626..4f07d4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev10" +version = "0.2.0.dev11" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 6bfaae1..d8bf51e 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.dev10" +__version__ = "0.2.0.dev11" 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/assembly_documentation.py b/src/nx_mcp/assembly_documentation.py new file mode 100644 index 0000000..e1f050b --- /dev/null +++ b/src/nx_mcp/assembly_documentation.py @@ -0,0 +1,446 @@ +"""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"]: + values.extend(getattr(annotations, name, [])) + 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 + ] + ) + prefs = uf.Plist.AskPrefs(obj.Tag) + return { + "parts_list": self._reference(obj, "annotation", self._work_part(), "Parts list"), + "rows": rows, + "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/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 29d571e..bc4448d 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-sheet-metal-r1", + "revision": "2606-freeform-manufacturing-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -691,6 +691,106 @@ "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." } }, "limitations": [ diff --git a/src/nx_mcp/exploded_views.py b/src/nx_mcp/exploded_views.py index 5944d5c..4a4c7b5 100644 --- a/src/nx_mcp/exploded_views.py +++ b/src/nx_mcp/exploded_views.py @@ -316,6 +316,8 @@ def resolve(reference): "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 @@ -339,6 +341,14 @@ def resolve(reference): "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") @@ -360,7 +370,10 @@ def _show_explosion(self, explosion=None, drawing_view=None, model_view=None): 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: diff --git a/src/nx_mcp/freeform.py b/src/nx_mcp/freeform.py new file mode 100644 index 0000000..a6c83df --- /dev/null +++ b/src/nx_mcp/freeform.py @@ -0,0 +1,444 @@ +"""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 + return self._freeform_commit(b) + 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 index 785b1d6..90873eb 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -10,14 +10,22 @@ import uuid from pathlib import Path -from nx_mcp import sheet_metal_server +from nx_mcp import ( + assembly_documentation_server, + freeform_server, + manufacturing_server, + sheet_metal_server, +) from nx_mcp.advanced_authoring import AdvancedAuthoringMixin +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.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.manufacturing import ManufacturingMixin from nx_mcp.nx_bridge import NXOpenExecutor from nx_mcp.recovery import OperationStore, timestamp from nx_mcp.review_tools import ReviewToolsMixin @@ -114,11 +122,26 @@ def add(a, b): 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) -NON_MODEL.update(AUTHORING_NON_MODEL | sheet_metal_server.NON_MODEL) +READ_ONLY.update( + AUTHORING_READ_ONLY + | sheet_metal_server.READ_ONLY + | freeform_server.READ_ONLY + | manufacturing_server.READ_ONLY + | assembly_documentation_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 +) class HardenedExecutor( + FreeformMixin, + ManufacturingMixin, + AssemblyDocumentationMixin, SheetMetalMixin, ExplodedViewsMixin, EngineeringMixin, @@ -139,6 +162,10 @@ def __init__(self, *args, **kwargs): 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:]) self._handlers.update( { "nx_resolve_geometry": self._resolve_geometry, @@ -1477,7 +1504,8 @@ def _snapshot(self, part): ("drawing_sheet", getattr(part, "DrawingSheets", [])), ("drawing_view", getattr(part, "DraftingViews", [])), ("dimension", getattr(part, "Dimensions", [])), - ("annotation", [*getattr(part, "Notes", []), *getattr(part, "Labels", [])]), + ("annotation", self._documentation_annotations(part)), + ("traceline", getattr(part, "Tracelines", [])), ( "component_pattern", self._component_patterns(part), diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 57ba1b6..da94224 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -12,7 +12,13 @@ from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations -from nx_mcp import authoring_server, sheet_metal_server +from nx_mcp import ( + assembly_documentation_server, + authoring_server, + freeform_server, + manufacturing_server, + sheet_metal_server, +) from nx_mcp.recovery import OperationStore from nx_mcp.runtime import NXToolError from nx_mcp.workspace import WorkspaceViolation @@ -338,11 +344,23 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_workspace_list", "nx_download_file", } -READ_ONLY.update(authoring_server.READ_ONLY | sheet_metal_server.READ_ONLY) +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 +) DESCRIPTIONS.update( { name: obj.__doc__ or name - for name, obj in {**vars(authoring_server), **vars(sheet_metal_server)}.items() + for name, obj in { + **vars(authoring_server), + **vars(sheet_metal_server), + **vars(freeform_server), + **vars(manufacturing_server), + **vars(assembly_documentation_server), + }.items() if name.startswith("nx_") and inspect.isfunction(obj) } ) @@ -357,6 +375,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_cancel_operation", } PATHS = { + "nx_export_explosion_animation": "path", "nx_export_flat_pattern": "path", "nx_set_sheet_metal_defaults": "bend_table", "nx_render_view": "path", @@ -397,7 +416,13 @@ def configure(mcp, bridge, workspace): definitions.update( { name: obj - for name, obj in {**vars(authoring_server), **vars(sheet_metal_server)}.items() + for name, obj in { + **vars(authoring_server), + **vars(sheet_metal_server), + **vars(freeform_server), + **vars(manufacturing_server), + **vars(assembly_documentation_server), + }.items() if name.startswith("nx_") and inspect.isfunction(obj) } ) diff --git a/src/nx_mcp/manufacturing.py b/src/nx_mcp/manufacturing.py new file mode 100644 index 0000000..a52e569 --- /dev/null +++ b/src/nx_mcp/manufacturing.py @@ -0,0 +1,358 @@ +"""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): + 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() + 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) + datum_fields = [ + "PrimaryDatumReference", + "SecondaryDatumReference", + "TertiaryDatumReference", + ] + for index, name in enumerate(datum_fields): + getattr(frame, name).Letter = letters[index] if index < len(letters) else "" + result = self._commit_pmi(b, targets, point, existing) + result.update( + {"characteristic": characteristic, "tolerance": tolerance, "datum_letters": letters} + ) + 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..e7ec162 --- /dev/null +++ b/src/nx_mcp/manufacturing_server.py @@ -0,0 +1,75 @@ +"""Native manufacturing detail and bounded, explicitly sampled analysis contracts.""" + +from __future__ import annotations + +from typing import Literal + +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, +): + """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. Uses native default tolerance-zone/material modifiers; 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.""" diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py index 7e29c48..f5da7fb 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -24,6 +24,7 @@ "drawing_view", "dimension", "annotation", + "traceline", "explosion", "modeling_view", ] 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/tests/test_exploded_views.py b/tests/test_exploded_views.py index 05c3188..04bb437 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -28,6 +28,7 @@ def inverse(pose): @pytest.fixture def explosions(rig): r = rig + r.uf.Disp = NS(RegenerateDisplay=Mock()) root = Component("root") parent = Component("parent", parent=root) leaf = Component("leaf", parent=parent) diff --git a/tests/test_freeform_manufacturing.py b/tests/test_freeform_manufacturing.py new file mode 100644 index 0000000..9ccbf5e --- /dev/null +++ b/tests/test_freeform_manufacturing.py @@ -0,0 +1,450 @@ +"""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), + 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.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), FcfFrameStyle=NS(SingleFrame=1) + ) + ) + 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_visual_tools.py b/tests/test_visual_tools.py index 21eeafa..e8bc97b 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 140 + assert len(tools) == 160 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 1dfd2c821273dc30b0e9f32275148901f9ee096a Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 03:57:25 +0200 Subject: [PATCH 26/69] Restore drawing presentation around native PDF exports --- docs/advanced-roadmap.md | 4 ++- examples/validate_freeform_manufacturing.py | 27 +++++++++------ src/nx_mcp/authoring_server.py | 2 +- src/nx_mcp/engineering.py | 38 ++++++++++++--------- src/nx_mcp/exploded_views.py | 6 ++-- tests/test_engineering.py | 11 +++++- tests/test_exploded_views.py | 20 +++++++++++ 7 files changed, 75 insertions(+), 33 deletions(-) diff --git a/docs/advanced-roadmap.md b/docs/advanced-roadmap.md index 2c7babf..09edc3b 100644 --- a/docs/advanced-roadmap.md +++ b/docs/advanced-roadmap.md @@ -1,6 +1,8 @@ # Advanced authoring roadmap -These are recommended additions, not claims of implemented or tested APIs. +This was the pre-dev10 roadmap. Sheet-metal authoring and flat patterns are now described in [sheet metal](sheet-metal.md). Dev11 implements curves/meshes/bridges, trim/sew/thicken, direct face editing, BOMs/balloons/traces/animation, manual threads, native datum/FCF PMI and sampled surface/thickness/draft analysis; see [contracts and verification limits](freeform-manufacturing.md). + +The historical list below also contains extensions that remain unimplemented or unverified, including surface extension, zebra inspection and full standards-table manufacturing workflows. It must not be read as a current capability manifest. 1. **Freeform curves and surfaces:** editable 3D splines, through-curve and mesh surfaces, bridge surfaces, trim/extend, sew, thicken and offset. Include diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py index 7288cbd..9e0c012 100644 --- a/examples/validate_freeform_manufacturing.py +++ b/examples/validate_freeform_manufacturing.py @@ -35,22 +35,22 @@ def save(): ): await client.initialize() - async def call(name, **params): - response = await client.call_tool(name, params) + async def call(tool_name, **params): + response = await client.call_tool(tool_name, params) receipt["operations"].append( { - "tool": name, + "tool": tool_name, "error": response.isError, "operation_id": response.structuredContent.get("operation_id"), } ) save() - assert not response.isError, (name, response.structuredContent) + assert not response.isError, (tool_name, response.structuredContent) return response.structuredContent - async def reject(name, **params): - response = await client.call_tool(name, params) - assert response.isError, (name, 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): @@ -256,7 +256,7 @@ async def checked(name): datum = await call("nx_pmi_datum", faces=[face], letter="A", position=[25, 10, 5]) fcf = await call( "nx_pmi_fcf", - faces=[face], + faces=[await nearest(body, "face", [10, 5, 5])], characteristic="Parallelism", tolerance=0.05, position=[25, 20, 5], @@ -265,7 +265,7 @@ async def checked(name): assert fcf["geometry_associated"] await call( "nx_pmi_fcf", - faces=[face], + faces=[await nearest(body, "face", [10, 5, 5])], characteristic="Flatness", tolerance=0.1, position=[25, 20, 5], @@ -306,7 +306,9 @@ async def checked(name): name="Cube" + str(i), translation=[25 * i, 0, 0], ) - assembled = (await call("nx_list_components"))["components"] + 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", @@ -343,11 +345,14 @@ async def checked(name): 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=[75, 0, 0], + translation=[120, 0, 0], ) assert (await call("nx_update_parts_list", parts_list=bom["parts_list"]["id"]))[ "rows" diff --git a/src/nx_mcp/authoring_server.py b/src/nx_mcp/authoring_server.py index 4870be8..ad3d8f4 100644 --- a/src/nx_mcp/authoring_server.py +++ b/src/nx_mcp/authoring_server.py @@ -437,7 +437,7 @@ def nx_add_base_view( def nx_export_drawing_pdf(path: str): - """Export all work-part drawing sheets to a new workspace PDF using native NX plotting. Full sheet scale, metric dimensions and searchable text. Returns actual path, sheet names/count, size and checksum. Reject missing drawings and existing files.""" + """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( diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py index 9b8d11b..5c7bbd5 100644 --- a/src/nx_mcp/engineering.py +++ b/src/nx_mcp/engineering.py @@ -1529,23 +1529,27 @@ def _export_drawing_pdf(self, path): if not sheets: raise NXToolError("NX_NO_DRAWING", "Create a drawing sheet before PDF export") file.parent.mkdir(parents=True, exist_ok=True) - 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.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() + with self._drawing_save_context(self._work_part(), force_display=True): + # Opening each sheet refreshes its display/CGM presentation before plotting. + 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.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)), diff --git a/src/nx_mcp/exploded_views.py b/src/nx_mcp/exploded_views.py index 4a4c7b5..d28f990 100644 --- a/src/nx_mcp/exploded_views.py +++ b/src/nx_mcp/exploded_views.py @@ -11,10 +11,10 @@ class ExplodedViewsMixin: @contextmanager - def _drawing_save_context(self, part): + 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: + if not sheets or (not part.SaveOptions.DrawingCgmData and not force_display): yield return work, display = self.session.Parts.Work, self.session.Parts.Display @@ -33,6 +33,8 @@ def _drawing_save_context(self, part): 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( diff --git a/tests/test_engineering.py b/tests/test_engineering.py index 3e05345..fb30a27 100644 --- a/tests/test_engineering.py +++ b/tests/test_engineering.py @@ -534,7 +534,11 @@ def test_mass_tensor_uses_centroidal_values_and_product_signs(material): 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() r.part.DrawingSheets = [sheet] b = NS( ActionOption=NS(Native=1), @@ -561,7 +565,12 @@ def test_pdf_export_reports_actual_artifact_and_refuses_overwrite(eng): def test_invalid_pdf_output_is_removed(eng): r = eng - r.part.DrawingSheets = [Object("sheet")] + from contextlib import nullcontext + + r.e._drawing_save_context = lambda *_, **__: nullcontext() + sheet = Object("sheet") + sheet.Open = Mock() + r.part.DrawingSheets = [sheet] b = NS( ActionOption=NS(Native=1), SizeOption=NS(FullScale=1), diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py index 04bb437..b699e8c 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -566,3 +566,23 @@ def test_strict_pose_types(explosions, extra): 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"): + with 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 From b88c7100cac34127aad722c6ac2ea1ba3c836971 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 03:57:53 +0200 Subject: [PATCH 27/69] Keep drawing restoration regression lint-clean --- tests/test_exploded_views.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py index b699e8c..3b62883 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -573,10 +573,12 @@ def test_forced_drawing_display_restores_sheet_on_failure(drawing_save): r.part.SaveOptions.DrawingCgmData = False original = r.sheet r.part.DrawingSheets.CurrentDrawingSheet = original - with pytest.raises(RuntimeError, match="plot failed"): - with r.e._drawing_save_context(r.part, force_display=True): - r.part.DrawingSheets.CurrentDrawingSheet = Object("Other sheet") - raise RuntimeError("plot failed") + 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 From 9c0d45e483345b84fe3478a51c48f62c0da34cf5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 04:06:58 +0200 Subject: [PATCH 28/69] Record deployed dev11 native acceptance and verified scope --- docs/dev11-validation.json | 119 +++++++++++++++++++++++++++++++++ docs/fork-status.md | 2 +- docs/freeform-manufacturing.md | 2 +- 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 docs/dev11-validation.json diff --git a/docs/dev11-validation.json b/docs/dev11-validation.json new file mode 100644 index 0000000..6b09df5 --- /dev/null +++ b/docs/dev11-validation.json @@ -0,0 +1,119 @@ +{ + "version": "0.2.0.dev11", + "runtime_commit": "b88c7100cac34127aad722c6ac2ea1ba3c836971", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 160, + "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/34005240174", + "release_build": "https://github.com/xuio/NX_MCP/actions/runs/34005239661", + "release_sha256": "1e9dba523612a9c7fcf0a8d9c32f154aa64ea6d756e6850223aadac200668cb4", + "local_validation": { + "pytest_passed": 706, + "pytest_skipped": 1, + "combined_statement_branch_coverage_percent": 79.49, + "unchanged_coverage_gate_percent": 78, + "mypy": "passed", + "pre_commit": "passed", + "ci": "passed" + }, + "native_trace_persistence": { + "created_endpoints_mm": [ + [ + 20, + 5, + 5 + ], + [ + 40, + 5, + 5 + ] + ], + "reopened_endpoints_mm": [ + [ + 20, + 5, + 5 + ], + [ + 40, + 5, + 5 + ] + ], + "edited_endpoints_mm": [ + [ + 20, + 5, + 5 + ], + [ + 60, + 5, + 5 + ] + ], + "anchor_scheme": "UF.Tag persistent handles for occurrence and prototype edge" + }, + "native_additional_fixtures": [ + "G1 and G2 bridge builder creation", + "symbolic and detailed external threads", + "tangent plane/quadratic-surface continuity G0=true G1=true G2=false" + ], + "scopes": [ + "Native capabilities are scoped, not blanket certification of all licenses, inputs or options.", + "Sampled continuity, thickness and draft do not certify global extrema or manufacturability.", + "Trace endpoints refresh through MCP explosion edits/show/animation; manual NX edits require explicit refresh.", + "Thread dimensions are manual; no standards-table fit class is implied.", + "Native PMI supports the published datum/single-frame fields; not every GD&T modifier.", + "Imported-face wrappers were verified on controlled solids; arbitrary damaged vendor B-reps remain unverified." + ], + "public_workflow_checks": [ + "associative_3d_spline_edit_and_idempotent_retry", + "native_mesh_sew_thicken_and_matching_surface_continuity", + "native_sheet_trim", + "native_bridge_and_deliberate_gap_detection", + "tangent_but_curvature_discontinuous_surface_pair", + "move_offset_replace_analytic_volumes", + "delete_heal_wall_thickness_draft_and_native_pmi", + "native_symbolic_thread", + "native_detailed_thread", + "native_bom_quantity_update_balloons_trace_animation_and_pdf" + ], + "public_workflow_passed": true, + "original_session_restored": true, + "native_pdf_from_modeling": { + "single_sheet": "passed", + "two_sheets": "passed", + "prior_modeling_view_restored": "passed" + }, + "visual_review": { + "animation": "Three native PNG frames; full motion framed, exact first-to-second trace, no ghost geometry.", + "drawing": "Native A3 PDF: four separated prototype instances, quantity 4, grouped associative balloon and trace; no complete manufacturing-drawing claim." + }, + "public_pdf_reopen_animation_restoration": true, + "protected_sheet_metal_checks": [ + "tab_analytic_volume_flange_bend_info_retry_pmi", + "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", + "unsupported_edit_unchanged_checkpoint_rollback", + "path_sketch_secondary_contour_analytic_volume" + ], + "protected_sheet_metal_passed": true, + "original_saved_parts": 38, + "original_occurrences_verified": 116, + "windows_validators": { + "stdio_tools": 160, + "http_tools": 160, + "native_inline_capture_checksum": "passed", + "serialized_ui_thread": "verified", + "installed_runtime_matches_release": true + }, + "offline_bundle_verified_files_before_evidence_overlay": 197, + "public_acceptance_initial_runtime": "b2f1acc9709463ab4298dd61de9fb7152463fabf", + "final_runtime_regressions": [ + "native and public PDF export from modeling view", + "persistent trace refresh and animation after saved assembly reopen", + "four protected sheet-metal groups", + "Windows stdio/HTTP and final 38-part/116-occurrence preservation" + ] +} diff --git a/docs/fork-status.md b/docs/fork-status.md index 6dbd1f8..6f15c11 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -44,7 +44,7 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current sheet-metal runtime CI and native evidence are recorded in [dev10 acceptance](dev10-validation.json); [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev11 acceptance](dev11-validation.json); [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/docs/freeform-manufacturing.md b/docs/freeform-manufacturing.md index 9e1175a..c010435 100644 --- a/docs/freeform-manufacturing.md +++ b/docs/freeform-manufacturing.md @@ -23,7 +23,7 @@ Native fixtures exercised spline creation/editing, planar meshes, G0/G1/G2 bridg `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`. +`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 From d0c161f18e3265421095850d0a38812feacbfbc2 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 10:42:41 +0200 Subject: [PATCH 29/69] Add editable documentation and native manufacturing workflows --- README.md | 4 +- docs/documentation-manufacturing.md | 31 + docs/fork-status.md | 6 +- docs/freeform-manufacturing.md | 2 + examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- .../validate_documentation_manufacturing.py | 572 ++++++++++++++++++ examples/validate_engineering_tools.py | 2 +- examples/validate_freeform_manufacturing.py | 2 +- examples/validate_project_folders.py | 2 +- examples/validate_sheet_metal.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/annotation_updates.py | 139 +++++ src/nx_mcp/assembly_documentation.py | 29 +- src/nx_mcp/capability_manifest.json | 52 +- src/nx_mcp/documentation_editing.py | 230 +++++++ src/nx_mcp/documentation_editing_server.py | 65 ++ src/nx_mcp/hardened.py | 20 + src/nx_mcp/integration_server.py | 4 + src/nx_mcp/manufacturing.py | 74 ++- src/nx_mcp/manufacturing_server.py | 32 +- src/nx_mcp/sheet_metal.py | 23 +- src/nx_mcp/sheet_metal_server.py | 1 + src/nx_mcp/thread_standards.py | 144 +++++ tests/test_documentation_editing.py | 137 +++++ tests/test_freeform_manufacturing.py | 24 +- tests/test_visual_tools.py | 2 +- 29 files changed, 1583 insertions(+), 26 deletions(-) create mode 100644 docs/documentation-manufacturing.md create mode 100644 examples/validate_documentation_manufacturing.py create mode 100644 src/nx_mcp/annotation_updates.py create mode 100644 src/nx_mcp/documentation_editing.py create mode 100644 src/nx_mcp/documentation_editing_server.py create mode 100644 src/nx_mcp/thread_standards.py create mode 100644 tests/test_documentation_editing.py diff --git a/README.md b/README.md index 54de570..9693b40 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev11` integration and 160 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev12` integration and 170 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 @@ -144,3 +144,5 @@ Authoring and review tools add geometric selection, expression binding, model he Advanced NX 2606 tools: [exact selection, associative component patterns and sketch dimensions](docs/advanced-authoring.md). Proposed upstream review slices are documented in the [review package](docs/upstream-review.md); no PR is opened by the release workflow. See [freeform, assembly documentation and manufacturing](docs/freeform-manufacturing.md) for the dev11 additions and scoped native verification. + +See [editable documentation and manufacturing](docs/documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. diff --git a/docs/documentation-manufacturing.md b/docs/documentation-manufacturing.md new file mode 100644 index 0000000..fd13369 --- /dev/null +++ b/docs/documentation-manufacturing.md @@ -0,0 +1,31 @@ +# Editable documentation and manufacturing validation + +The dev12 integration adds ten tools, bringing the integration profile to 170. All modeling calls remain serial on the graphical NX thread. Operation IDs, explicit rollback and stale-reference rejection apply to the new model edits. + +## 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. Native bend-table updates use NX's own mechanism. Saved source handles are resolved within the owning part, and missing sources are rejected explicitly. + +## Validation scope + +Run `examples/validate_documentation_manufacturing.py` with `NX_MCP_URL` and optionally `NX_VALIDATION_OUTPUT`. It preserves the existing saved session and creates isolated fixtures: a rounded enclosure and STEP copy, curved surface joins, a thin plate, a drafted block, a sheet-metal bracket and a service assembly. It checks analytic dimensions/volumes, local editing and recovery, stale references after reopen, documentation updates and downloaded native artifacts. + +The acceptance script is executable test intent; a successful run and its receipt are required evidence. Local mocked tests check contracts and failure handling, not NX geometry. Sampled curvature, draft and wall-thickness results retain their explicitly sampled scope; no global manufacturing certification is claimed. diff --git a/docs/fork-status.md b/docs/fork-status.md index 6f15c11..d1cb20a 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev11`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev12`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. @@ -14,7 +14,7 @@ See [engineering tools and scoped validation](engineering-tools.md) for the late - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev11 opt-in integration profile exposes 160 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev12 opt-in integration profile exposes 170 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -53,3 +53,5 @@ See [native exploded views](exploded-views.md) for dev9 presentation and drawing See [native sheet metal](sheet-metal.md) for the dev10 operation catalog, verified scope, flat-pattern exports and measured PMI semantics. See [freeform, assembly documentation and manufacturing](freeform-manufacturing.md) for the dev11 additions and scoped native verification. + +See [editable documentation and manufacturing](documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. diff --git a/docs/freeform-manufacturing.md b/docs/freeform-manufacturing.md index c010435..0880e95 100644 --- a/docs/freeform-manufacturing.md +++ b/docs/freeform-manufacturing.md @@ -2,6 +2,8 @@ The dev11 integration adds 20 tools (160 total) for NX v2606. These are intent-oriented wrappers around native NX geometry, annotations and rendering. Model mutations run serially on the graphical NX thread with the existing operation-ID deduplication and rollback framework. Coordinates are in the work part unless the tool explicitly uses assembly or drawing-sheet coordinates. +See [dev12 editable documentation and manufacturing](documentation-manufacturing.md) for the subsequent ten tools and deeper acceptance fixtures. + ## Curves and surfaces - `nx_spline`: create/edit associative 3D Studio Splines from interpolation points or control poles, including degree and periodicity. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index 83b5214..71485ea 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 160 + assert len(tools) == 170 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index f0a267d..c34c969 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 160 + assert len(tools) == 170 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_documentation_manufacturing.py b/examples/validate_documentation_manufacturing.py new file mode 100644 index 0000000..aca869b --- /dev/null +++ b/examples/validate_documentation_manufacturing.py @@ -0,0 +1,572 @@ +"""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) + 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, 130], + ) + )["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 index 6fcb182..46c5f83 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -108,7 +108,7 @@ async def assembly(name): 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) == 160 + assert len((await client.list_tools()).tools) == 170 async def limits(): await new("offset") diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py index 9e0c012..7cd7b6c 100644 --- a/examples/validate_freeform_manufacturing.py +++ b/examples/validate_freeform_manufacturing.py @@ -137,7 +137,7 @@ async def checked(name): 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) == 160 + assert len((await client.list_tools()).tools) == 170 await new("spline") token = "spline-" + uuid.uuid4().hex params = { diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py index 891872b..710ca2d 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,7 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 160 + assert len((await c.list_tools()).tools) == 170 info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py index 95b136b..99371e2 100644 --- a/examples/validate_sheet_metal.py +++ b/examples/validate_sheet_metal.py @@ -90,7 +90,7 @@ async def volume(body): 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) == 160 + assert len((await client.list_tools()).tools) == 170 catalog = await call("nx_sheet_metal_schema") assert len(catalog["operations"]) == 34 await call("nx_create_part", path=prefix + "/bracket.prt", units="mm") diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index cc2518d..8a6e26e 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 160, len(names) + assert len(names) == 170, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 4f07d4c..9489f2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev11" +version = "0.2.0.dev12" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index d8bf51e..15c6993 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.dev11" +__version__ = "0.2.0.dev12" diff --git a/src/nx_mcp/annotation_updates.py b/src/nx_mcp/annotation_updates.py new file mode 100644 index 0000000..951627c --- /dev/null +++ b/src/nx_mcp/annotation_updates.py @@ -0,0 +1,139 @@ +"""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() + 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"]) + return { + "updated": updated, + "updated_count": len(updated), + "units": self._units(), + "semantics": "Managed PMI refresh after MCP mutations; invoke explicitly after manual NX edits. Native automatic bend tables update through NX.", + } diff --git a/src/nx_mcp/assembly_documentation.py b/src/nx_mcp/assembly_documentation.py index e1f050b..4d79d2a 100644 --- a/src/nx_mcp/assembly_documentation.py +++ b/src/nx_mcp/assembly_documentation.py @@ -12,7 +12,14 @@ 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"]: + for name in [ + "Datums", + "Fcfs", + "IdSymbols", + "PartsLists", + "BendTables", + "TableSections", + ]: values.extend(getattr(annotations, name, [])) return list({int(obj.Tag): obj for obj in values}.values()) @@ -39,10 +46,30 @@ def _parts_list_info(self, parts_list): 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, diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index bc4448d..5974db0 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-freeform-manufacturing-r1", + "revision": "2606-documentation-manufacturing-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -791,6 +791,56 @@ "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 bend PMI changed from 90 to 75 degrees using persistent source handles; transaction-hook acceptance pending." + }, + "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." + }, + "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." } }, "limitations": [ diff --git a/src/nx_mcp/documentation_editing.py b/src/nx_mcp/documentation_editing.py new file mode 100644 index 0000000..8946821 --- /dev/null +++ b/src/nx_mcp/documentation_editing.py @@ -0,0 +1,230 @@ +"""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, + "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": self._units(), + "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, "AnnotationOrigin"): + item["position"] = xyz(value.AnnotationOrigin) + if hasattr(value, "GetText"): + item["text"] = list(value.GetText()) + 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..c51a7cb --- /dev/null +++ b/src/nx_mcp/documentation_editing_server.py @@ -0,0 +1,65 @@ +"""Drawing inspection and precise native documentation edits.""" + +from typing import Literal + +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: list[float] | None = None, + name: str | None = None, + delete: bool = False, +): + """Move or rename a native annotation (including associative balloons), or delete it explicitly. Position uses the annotation's existing sheet/work-part frame. 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. Native automatic bend tables use NX's own update mechanism.""" diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 90873eb..aa9bacc 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -12,15 +12,18 @@ 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.engineering import EngineeringMixin from nx_mcp.exploded_views import ExplodedViewsMixin from nx_mcp.freeform import FreeformMixin @@ -31,6 +34,7 @@ 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 = { @@ -128,6 +132,7 @@ def add(a, b): | freeform_server.READ_ONLY | manufacturing_server.READ_ONLY | assembly_documentation_server.READ_ONLY + | documentation_editing_server.READ_ONLY ) NON_MODEL.update( AUTHORING_NON_MODEL @@ -135,10 +140,14 @@ def add(a, b): | freeform_server.NON_MODEL | manufacturing_server.NON_MODEL | assembly_documentation_server.NON_MODEL + | documentation_editing_server.NON_MODEL ) class HardenedExecutor( + DocumentationEditingMixin, + AnnotationUpdatesMixin, + ThreadStandardsMixin, FreeformMixin, ManufacturingMixin, AssemblyDocumentationMixin, @@ -166,6 +175,9 @@ def __init__(self, *args, **kwargs): 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_resolve_geometry": self._resolve_geometry, @@ -396,6 +408,14 @@ def handler(**p): ) 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"} + ): + 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")), diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index da94224..da86cfc 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -15,6 +15,7 @@ from nx_mcp import ( assembly_documentation_server, authoring_server, + documentation_editing_server, freeform_server, manufacturing_server, sheet_metal_server, @@ -350,6 +351,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of | freeform_server.READ_ONLY | manufacturing_server.READ_ONLY | assembly_documentation_server.READ_ONLY + | documentation_editing_server.READ_ONLY ) DESCRIPTIONS.update( { @@ -360,6 +362,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of **vars(freeform_server), **vars(manufacturing_server), **vars(assembly_documentation_server), + **vars(documentation_editing_server), }.items() if name.startswith("nx_") and inspect.isfunction(obj) } @@ -422,6 +425,7 @@ def configure(mcp, bridge, workspace): **vars(freeform_server), **vars(manufacturing_server), **vars(assembly_documentation_server), + **vars(documentation_editing_server), }.items() if name.startswith("nx_") and inspect.isfunction(obj) } diff --git a/src/nx_mcp/manufacturing.py b/src/nx_mcp/manufacturing.py index a52e569..5b0865e 100644 --- a/src/nx_mcp/manufacturing.py +++ b/src/nx_mcp/manufacturing.py @@ -114,7 +114,21 @@ def _pmi_datum(self, faces, letter, position, annotation=None): finally: b.Destroy() - def _pmi_fcf(self, faces, characteristic, tolerance, position, datums=None, annotation=None): + 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 @@ -155,6 +169,35 @@ def _pmi_fcf(self, faces, characteristic, tolerance, position, datums=None, anno 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: @@ -164,16 +207,41 @@ def _pmi_fcf(self, faces, characteristic, tolerance, position, datums=None, anno 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): - getattr(frame, name).Letter = letters[index] if index < len(letters) else "" + 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} + { + "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: diff --git a/src/nx_mcp/manufacturing_server.py b/src/nx_mcp/manufacturing_server.py index e7ec162..99cc38f 100644 --- a/src/nx_mcp/manufacturing_server.py +++ b/src/nx_mcp/manufacturing_server.py @@ -53,8 +53,14 @@ def nx_pmi_fcf( 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. Uses native default tolerance-zone/material modifiers; does not claim a complete standards compliance check. annotation edits an existing FCF ID.""" + """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( @@ -73,3 +79,27 @@ def nx_wall_thickness( 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") diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py index 2618f58..6bf407c 100644 --- a/src/nx_mcp/sheet_metal.py +++ b/src/nx_mcp/sheet_metal.py @@ -1024,7 +1024,9 @@ def _add_flat_pattern_view(self, drawing, flat_pattern, position=None): "units": "mm", } - def _sheet_metal_annotation(self, kind, body, position, faces=None, annotation=None): + 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"}: @@ -1053,12 +1055,16 @@ def _sheet_metal_annotation(self, kind, body, position, faces=None, annotation=N if kind == "body": measured = {"thickness": float(self._sm_manager().GetBodyThickness(target))} lines = [ - "Sheet metal (measured snapshot)", + "Sheet metal (managed measurement)" + if automatic + else "Sheet metal (measured snapshot)", f"Thickness: {measured['thickness']:.3f} {units}", ] else: measured = {"bends": []} - lines = ["Bend data (measured snapshot)"] + 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 = { @@ -1096,6 +1102,12 @@ def _sheet_metal_annotation(self, kind, body, position, faces=None, annotation=N "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 @@ -1111,7 +1123,10 @@ def _sheet_metal_annotation(self, kind, body, position, faces=None, annotation=N "body": self._reference(target, "body", self._work_part(), "Body"), "kind": kind, "measured_parameters": measured, - "text_semantics": "Measured snapshot; call this tool with the annotation ID to refresh after model edits", + "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", diff --git a/src/nx_mcp/sheet_metal_server.py b/src/nx_mcp/sheet_metal_server.py index d20d9e9..9111b9e 100644 --- a/src/nx_mcp/sheet_metal_server.py +++ b/src/nx_mcp/sheet_metal_server.py @@ -137,5 +137,6 @@ def nx_sheet_metal_annotation( 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/thread_standards.py b/src/nx_mcp/thread_standards.py new file mode 100644 index 0000000..dec3466 --- /dev/null +++ b/src/nx_mcp/thread_standards.py @@ -0,0 +1,144 @@ +"""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, + representation="detailed" if detailed else "symbolic", + catalog_callout=row.get("Callout"), + catalog_units=row.get("Unit"), + ) + return result + finally: + b.Destroy() diff --git a/tests/test_documentation_editing.py b/tests/test_documentation_editing.py new file mode 100644 index 0000000..92e2df7 --- /dev/null +++ b/tests/test_documentation_editing.py @@ -0,0 +1,137 @@ +"""Contracts, preflight and transaction regressions; native geometry tested separately.""" + +import inspect +from types import SimpleNamespace as NS +from unittest.mock import 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() diff --git a/tests/test_freeform_manufacturing.py b/tests/test_freeform_manufacturing.py index 9ccbf5e..cbb2d94 100644 --- a/tests/test_freeform_manufacturing.py +++ b/tests/test_freeform_manufacturing.py @@ -48,7 +48,7 @@ def ff(rig, monkeypatch): DeleteFaceBuilder=NS(SelectTypes=NS(Face=1)), TrimSheetBuilder=NS(KeepDiscardOption=NS(Keep=1, Discard=2)), ThreadBuilder=NS( - Input=NS(Manual=1), + Input=NS(Manual=1, ThreadTable=2), Type=NS(Detailed=2, Symbolic=1), LimitOption=NS(Value=1), Handedness=NS(LeftHand=1, RightHand=2), @@ -335,8 +335,26 @@ def test_pmi_association_and_existing_edit(ff, monkeypatch): ff.part.Annotations.CreatePmiFeatureControlFrameBuilder.return_value = fb a = NS( FeatureControlFrameBuilder=NS( - FcfCharacteristic=NS(Parallelism=1, Flatness=2), FcfFrameStyle=NS(SingleFrame=1) - ) + 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 diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py index e8bc97b..54b54c2 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 160 + assert len(tools) == 170 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From a901f4d6f561b3bec06731f9ae5d09721c959d71 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 10:45:13 +0200 Subject: [PATCH 30/69] Keep the service assembly view inside its drawing sheet --- examples/validate_documentation_manufacturing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/validate_documentation_manufacturing.py b/examples/validate_documentation_manufacturing.py index aca869b..2173d9e 100644 --- a/examples/validate_documentation_manufacturing.py +++ b/examples/validate_documentation_manufacturing.py @@ -514,7 +514,7 @@ async def curved_patch(poles): drawing=sheet, scope="assembly", explosion=explosion, - position=[190, 130], + position=[190, 160], ) )["object"]["id"] balloons = await call( From c3bdb52814f18d67b9df3ed7dd42e7a795fb9ba5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 10:58:26 +0200 Subject: [PATCH 31/69] Refresh native bend tables transactionally after model changes --- docs/documentation-manufacturing.md | 4 +- examples/validate_annotation_recovery.py | 119 ++++++++++++++++++ .../validate_documentation_manufacturing.py | 8 ++ src/nx_mcp/annotation_updates.py | 20 ++- src/nx_mcp/capability_manifest.json | 2 +- src/nx_mcp/documentation_editing_server.py | 2 +- src/nx_mcp/hardened.py | 3 +- tests/test_documentation_editing.py | 28 ++++- 8 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 examples/validate_annotation_recovery.py diff --git a/docs/documentation-manufacturing.md b/docs/documentation-manufacturing.md index fd13369..b2c4596 100644 --- a/docs/documentation-manufacturing.md +++ b/docs/documentation-manufacturing.md @@ -22,10 +22,12 @@ The dev12 integration adds ten tools, bringing the integration profile to 170. A `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. Native bend-table updates use NX's own mechanism. Saved source handles are resolved within the owning part, and missing sources are rejected explicitly. +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. ## Validation scope Run `examples/validate_documentation_manufacturing.py` with `NX_MCP_URL` and optionally `NX_VALIDATION_OUTPUT`. It preserves the existing saved session and creates isolated fixtures: a rounded enclosure and STEP copy, curved surface joins, a thin plate, a drafted block, a sheet-metal bracket and a service assembly. It checks analytic dimensions/volumes, local editing and recovery, stale references after reopen, documentation updates and downloaded native artifacts. The acceptance script is executable test intent; a successful run and its receipt are required evidence. Local mocked tests check contracts and failure handling, not NX geometry. Sampled curvature, draft and wall-thickness results retain their explicitly sampled scope; no global manufacturing certification is claimed. + +Then run `examples/validate_annotation_recovery.py` with the same environment and output directory. It verifies automatic table row changes before any explicit table edit, idempotent expression retries, disabling managed PMI, and checkpoint rollback of both geometry and annotations. The prior fixture parts must be closed and user parts saved. diff --git a/examples/validate_annotation_recovery.py b/examples/validate_annotation_recovery.py new file mode 100644 index 0000000..06c69ad --- /dev/null +++ b/examples/validate_annotation_recovery.py @@ -0,0 +1,119 @@ +"""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 not edit.get("refreshed_annotations") + 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_documentation_manufacturing.py b/examples/validate_documentation_manufacturing.py index 2173d9e..2455014 100644 --- a/examples/validate_documentation_manufacturing.py +++ b/examples/validate_documentation_manufacturing.py @@ -361,6 +361,14 @@ async def curved_patch(poles): 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"], diff --git a/src/nx_mcp/annotation_updates.py b/src/nx_mcp/annotation_updates.py index 951627c..727df0f 100644 --- a/src/nx_mcp/annotation_updates.py +++ b/src/nx_mcp/annotation_updates.py @@ -131,9 +131,27 @@ def _refresh_annotations(self): 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. Native automatic bend tables update through NX.", + "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/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 5974db0..ba2c9ff 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -825,7 +825,7 @@ "nx_refresh_annotations": { "status": "tested", "evidence_type": "real_NX_v2606_scoped", - "scope": "Native bend PMI changed from 90 to 75 degrees using persistent source handles; transaction-hook acceptance pending." + "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", diff --git a/src/nx_mcp/documentation_editing_server.py b/src/nx_mcp/documentation_editing_server.py index c51a7cb..1a43a86 100644 --- a/src/nx_mcp/documentation_editing_server.py +++ b/src/nx_mcp/documentation_editing_server.py @@ -62,4 +62,4 @@ def nx_bend_table( 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. Native automatic bend tables use NX's own update mechanism.""" + """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.""" diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index aa9bacc..1194cd6 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -411,7 +411,8 @@ def handler(**p): if ( mark is not None and part is not None - and method not in {"nx_refresh_annotations", "nx_sheet_metal_annotation"} + and method + not in {"nx_refresh_annotations", "nx_sheet_metal_annotation", "nx_bend_table"} ): refreshed = self._refresh_annotations() if refreshed["updated_count"]: diff --git a/tests/test_documentation_editing.py b/tests/test_documentation_editing.py index 92e2df7..4f3bea1 100644 --- a/tests/test_documentation_editing.py +++ b/tests/test_documentation_editing.py @@ -2,7 +2,7 @@ import inspect from types import SimpleNamespace as NS -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock import pytest @@ -135,3 +135,29 @@ def test_annotation_refresh_failure_rolls_back_the_model_edit(rig): 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() From db9fb07a0782cfff28d9628aa102b8d41a0ec6ca Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 11:14:45 +0200 Subject: [PATCH 32/69] Record dev12 native workflow and recovery acceptance --- docs/dev12-validation.json | 173 +++++++++++++++++++++++ docs/documentation-manufacturing.md | 6 + docs/fork-status.md | 2 +- examples/validate_annotation_recovery.py | 4 +- 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 docs/dev12-validation.json diff --git a/docs/dev12-validation.json b/docs/dev12-validation.json new file mode 100644 index 0000000..9f2a127 --- /dev/null +++ b/docs/dev12-validation.json @@ -0,0 +1,173 @@ +{ + "version": "0.2.0.dev12", + "runtime_commit": "c3bdb52814f18d67b9df3ed7dd42e7a795fb9ba5", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 170, + "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/34023328645", + "release_build": "https://github.com/xuio/NX_MCP/actions/runs/34023334813", + "release_sha256": "cf46f43065906c8eeceeb9be41a7ea161019e1db8859ceb2becf1b489e954b21", + "local_validation": { + "pytest_passed": 715, + "pytest_skipped": 1, + "combined_statement_branch_coverage_percent": 78.51, + "unchanged_coverage_gate_percent": 78, + "mypy": "passed", + "pre_commit": "passed", + "ci": "passed" + }, + "added_tools": [ + "nx_list_drawings", + "nx_activate_drawing", + "nx_list_annotations", + "nx_edit_annotation", + "nx_parts_list_column", + "nx_edit_explosion_trace", + "nx_thread_catalog", + "nx_standard_thread", + "nx_bend_table", + "nx_refresh_annotations" + ], + "public_workflow_checks": [ + "rounded_enclosure_analytic_shell_step_roundtrip_local_edit_rollback_reopen", + "curved_parabolic_G2_join_and_deliberate_tangent_discontinuity", + "thin_wall_measurement_and_outside_ray_origin_rejection", + "known_five_degree_draft_signed_transition_and_threshold", + "native_bend_table_managed_PMI_transactional_update_and_persistent_reopen", + "standard_table_thread_False_and_GDT_modifiers", + "standard_table_thread_True_and_GDT_modifiers", + "sheet_metal_assembly_explosion_trace_edit_BOM_columns_balloon_placement_drawings" + ], + "public_workflow_passed": true, + "annotation_recovery": { + "automatic_without_table_edit": true, + "operation_retry_deduplicated": true, + "table_rows": [ + [ + "1", + "85,00", + "3,00" + ] + ], + "disable_preserves_measured_snapshot": true, + "rollback_restored_annotation_and_source": true, + "passed": true, + "session_restored": true + }, + "native_thread_cases": [ + { + "internal": false, + "detailed": false, + "pitch_mm": 1.0, + "volume_before_mm3": 263.66158778851576, + "volume_after_mm3": 263.66158778851576 + }, + { + "internal": false, + "detailed": true, + "pitch_mm": 1.0, + "volume_before_mm3": 263.66158778851576, + "volume_after_mm3": 235.58194147836767 + }, + { + "internal": true, + "detailed": false, + "pitch_mm": 1.0, + "volume_before_mm3": 3803.650459150639, + "volume_after_mm3": 3803.650459150639 + }, + { + "internal": true, + "detailed": true, + "pitch_mm": 1.0, + "volume_before_mm3": 3803.650459150639, + "volume_after_mm3": 3769.746530347013 + } + ], + "numeric_evidence": { + "enclosure_shell_volume_mm3": 40369.1325000739, + "curved_join_checks": { + "G0": true, + "G1": true, + "G2": true + }, + "sampled_thin_wall_mm": 0.2, + "bend_table_before": [ + [ + "1", + "75,00", + "3,00" + ] + ], + "bend_table_after": [ + [ + "1", + "80,00", + "3,00" + ] + ] + }, + "protected_freeform_checks": [ + "associative_3d_spline_edit_and_idempotent_retry", + "native_mesh_sew_thicken_and_matching_surface_continuity", + "native_sheet_trim", + "native_bridge_and_deliberate_gap_detection", + "tangent_but_curvature_discontinuous_surface_pair", + "move_offset_replace_analytic_volumes", + "delete_heal_wall_thickness_draft_and_native_pmi", + "native_symbolic_thread", + "native_detailed_thread", + "native_bom_quantity_update_balloons_trace_animation_and_pdf" + ], + "protected_sheet_metal_checks": [ + "tab_analytic_volume_flange_bend_info_retry_pmi", + "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", + "unsupported_edit_unchanged_checkpoint_rollback", + "path_sketch_secondary_contour_analytic_volume" + ], + "deployment": { + "stdio_tools": 170, + "http_tools": 170, + "native_inline_screenshot_checksum": "passed", + "saved_parts": 38, + "unchanged_component_occurrences": 116, + "main_thread_dispatch": true + }, + "original_session_restored": true, + "visual_review": { + "service_pdf": "Complete exploded enclosure/bracket view, native trace, evaluated BOM and associative balloons within A3 sheet. Test fixture, not a complete manufacturing drawing.", + "bracket_pdf": "Native flat-pattern view and bend-table row at 80 degrees; no clipping.", + "assembly_png": "Full native exploded enclosure and bent bracket with managed trace." + }, + "scopes": [ + "Native tests use millimeter parts; imperial and mixed-unit drafting have not been native-tested.", + "Curvature, draft and wall thickness remain sampled diagnostics, not global manufacturing certification.", + "ThreadTable tests select installed Metric Coarse M6 x 1.0 metadata in place; no catalog file is copied and no unexposed fit class is inferred.", + "Native FCF creation accepts published modifiers; this is not full GD&T standards validation.", + "Managed PMI and automatic bend tables refresh transactionally after MCP model mutations; call nx_refresh_annotations after manual NX changes.", + "NX v2606 automatic bend-table flag alone left stale rows after reopen; native builder rebuild in the transaction fixes the reproduced case.", + "Invalid managed-source rollback has local regression coverage; no blanket guarantee for every damaged vendor B-rep." + ], + "artifacts": [ + { + "file": "enclosure.png", + "sha256": "67bf37b47feb480bb9a58054d74ba9f173037a768815b9e3d258eb5f202dd9f5", + "size": 20815 + }, + { + "file": "bracket.pdf", + "sha256": "5a46ac0ffb9835f6ae0bd0f05e59aa0e8294d6f37e92ec7e5a2575bfc37fc20a", + "size": 21298 + }, + { + "file": "assembly.png", + "sha256": "f795309e78685a289cec680e0624a8c4113f16e3653e7bb546c5da196ad634f9", + "size": 25132 + }, + { + "file": "service.pdf", + "sha256": "f7a2069f4cf4f5fb365196cd4f794cf5940e81811f7beb839ac63e512fddfd0d", + "size": 26618 + } + ] +} diff --git a/docs/documentation-manufacturing.md b/docs/documentation-manufacturing.md index b2c4596..c48eb39 100644 --- a/docs/documentation-manufacturing.md +++ b/docs/documentation-manufacturing.md @@ -31,3 +31,9 @@ Run `examples/validate_documentation_manufacturing.py` with `NX_MCP_URL` and opt The acceptance script is executable test intent; a successful run and its receipt are required evidence. Local mocked tests check contracts and failure handling, not NX geometry. Sampled curvature, draft and wall-thickness results retain their explicitly sampled scope; no global manufacturing certification is claimed. Then run `examples/validate_annotation_recovery.py` with the same environment and output directory. It verifies automatic table row changes before any explicit table edit, idempotent expression retries, disabling managed PMI, and checkpoint rollback of both geometry and annotations. The prior fixture parts must be closed and user parts saved. + +## Recorded dev12 acceptance + +[The final acceptance receipt](dev12-validation.json) records the deployed runtime commit, Windows package checksum, numeric fixtures, recovery checks and protected workflows. The drawing/annotation fixtures use millimeter workparts; imperial and mixed-unit drafting remain outside this native test scope. + +The receipt distinguishes the reproduced stale bend-table issue from the corrected transaction path. A native rebuild is required after model changes even when the table already has automatic updating enabled. The service PDF was visually checked after moving its view inside the sheet. diff --git a/docs/fork-status.md b/docs/fork-status.md index d1cb20a..6889388 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -44,7 +44,7 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev11 acceptance](dev11-validation.json); [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev12 acceptance](dev12-validation.json); [dev11 acceptance](dev11-validation.json) retains the earlier freeform/documentation results; [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/examples/validate_annotation_recovery.py b/examples/validate_annotation_recovery.py index 06c69ad..c07358e 100644 --- a/examples/validate_annotation_recovery.py +++ b/examples/validate_annotation_recovery.py @@ -79,7 +79,9 @@ async def call(n, **p): edit = await call( "nx_set_expression", expression=angles[0]["object"]["id"], formula="82" ) - assert not edit.get("refreshed_annotations") + 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 From 7556d93adeac3aa99f226e66ad4c825d9ce805c5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 14:13:10 +0200 Subject: [PATCH 33/69] Add native drawing editing, assembly refresh and release acceptance --- README.md | 4 +- docs/fork-status.md | 6 +- docs/release-engineering.md | 44 ++ examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 2 +- examples/validate_freeform_manufacturing.py | 2 +- examples/validate_project_folders.py | 2 +- examples/validate_release_engineering.py | 716 ++++++++++++++++++++ examples/validate_sheet_metal.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- scripts/validate_native_release.py | 198 ++++++ src/nx_mcp/__init__.py | 2 +- src/nx_mcp/annotation_updates.py | 1 + src/nx_mcp/assembly_documentation.py | 2 + src/nx_mcp/authoring_server.py | 14 +- src/nx_mcp/capability_manifest.json | 53 +- src/nx_mcp/documentation_editing.py | 9 +- src/nx_mcp/documentation_editing_server.py | 62 ++ src/nx_mcp/engineering.py | 54 +- src/nx_mcp/exploded_views.py | 7 +- src/nx_mcp/freeform.py | 26 +- src/nx_mcp/hardened.py | 3 + src/nx_mcp/inspection.py | 3 + src/nx_mcp/nx_bridge.py | 1 + src/nx_mcp/release_engineering.py | 566 ++++++++++++++++ src/nx_mcp/sheet_metal.py | 9 +- src/nx_mcp/sheet_metal_server.py | 2 +- src/nx_mcp/thread_standards.py | 4 + tests/fakes/__init__.py | 1 + tests/test_exploded_views.py | 3 + tests/test_freeform_manufacturing.py | 1 + tests/test_native_release_runner.py | 34 + tests/test_release_engineering.py | 299 ++++++++ tests/test_visual_tools.py | 2 +- 36 files changed, 2094 insertions(+), 48 deletions(-) create mode 100644 docs/release-engineering.md create mode 100644 examples/validate_release_engineering.py create mode 100644 scripts/validate_native_release.py create mode 100644 src/nx_mcp/release_engineering.py create mode 100644 tests/test_native_release_runner.py create mode 100644 tests/test_release_engineering.py diff --git a/README.md b/README.md index 9693b40..07b32bc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NX MCP Server -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev12` integration and 170 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev13` integration and 179 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 @@ -146,3 +146,5 @@ Advanced NX 2606 tools: [exact selection, associative component patterns and ske See [freeform, assembly documentation and manufacturing](docs/freeform-manufacturing.md) for the dev11 additions and scoped native verification. See [editable documentation and manufacturing](docs/documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. + +See [release engineering and native acceptance](docs/release-engineering.md) for dev13 drawing authoring, assembly refresh, retained-dimension repair, imported geometry references, mixed units and serial release validation. diff --git a/docs/fork-status.md b/docs/fork-status.md index 6889388..ad3ed88 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev12`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev13`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. @@ -14,7 +14,7 @@ See [engineering tools and scoped validation](engineering-tools.md) for the late - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev12 opt-in integration profile exposes 170 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev13 opt-in integration profile exposes 179 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -55,3 +55,5 @@ See [native sheet metal](sheet-metal.md) for the dev10 operation catalog, verifi See [freeform, assembly documentation and manufacturing](freeform-manufacturing.md) for the dev11 additions and scoped native verification. See [editable documentation and manufacturing](documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. + +See [release engineering and native acceptance](release-engineering.md) for dev13 drawing authoring, assembly refresh, retained-dimension repair, imported geometry references, mixed units and serial release validation. diff --git a/docs/release-engineering.md b/docs/release-engineering.md new file mode 100644 index 0000000..f41ad5c --- /dev/null +++ b/docs/release-engineering.md @@ -0,0 +1,44 @@ +# Release engineering and native acceptance + +Dev13 adds nine tools for editable drawing views, assembly refresh and persistent imported geometry, plus a serial native release acceptance runner. The integration profile exposes 179 tools. NX calls remain on the graphical NX thread; model changes use the existing operation IDs and undo transactions. + +## 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. + +## Repeatable release validation + +Use trusted source from the release being tested: + +```sh +NX_MCP_URL=http://NX-HOST:8765/mcp \ +NX_VENDOR_STEP=/path/to/authorized-connector.step \ +python scripts/validate_native_release.py --output /new/receipt-directory +``` + +The runner executes the release-engineering, documentation, annotation-recovery, freeform and sheet-metal suites serially. It stops at the first failure, retains logs and geometry/artifact receipts, hashes outputs, and verifies original open parts, work/display state and component paths/transforms. There is no automatic mutation retry. A fresh output directory prevents stale evidence from being mistaken for a new pass. Native tests require saved existing parts and agent UI mode. + +A public connector fixture is available from [KiCad's USB4085 model](https://gitlab.com/kicad/libraries/kicad-packages3D/-/blob/8fb0194639525261cd642ec40d62ee26e1f601de/Connector_USB.3dshapes/USB_C_Receptacle_GCT_USB4085.step), SHA256 `82235f7275d07f720e3c050f781397f4bef47fdc7e15dd507d68ccba861f1a35`. Fetch it separately under its upstream license; vendor CAD is not bundled in this repository. Proprietary NX catalogs are read in place and never copied into release artifacts. + +Native acceptance is separate from mock/transport CI. A CI pass alone does not certify a release against NX. Keep the native receipt, package hash and exact source commit together, and run acceptance after every deployment before recording that release as verified. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index 71485ea..05e338b 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 170 + assert len(tools) == 179 assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index c34c969..4d4aaca 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 170 + assert len(tools) == 179 assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index 46c5f83..40cc73b 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -108,7 +108,7 @@ async def assembly(name): 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) == 170 + assert len((await client.list_tools()).tools) == 179 async def limits(): await new("offset") diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py index 7cd7b6c..d34d680 100644 --- a/examples/validate_freeform_manufacturing.py +++ b/examples/validate_freeform_manufacturing.py @@ -137,7 +137,7 @@ async def checked(name): 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) == 170 + assert len((await client.list_tools()).tools) == 179 await new("spline") token = "spline-" + uuid.uuid4().hex params = { diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py index 710ca2d..51c5165 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,7 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 170 + assert len((await c.list_tools()).tools) == 179 info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_release_engineering.py b/examples/validate_release_engineering.py new file mode 100644 index 0000000..a99234e --- /dev/null +++ b/examples/validate_release_engineering.py @@ -0,0 +1,716 @@ +"""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"))["object"]["id"] + view = ( + await call( + "nx_add_base_view", drawing=sheet, body=body, view="top", position=[100, 180] + ) + )["object"]["id"] + edited = await call("nx_edit_drawing_view", view=view, scale=1.5, position=[105, 180]) + 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) + 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"))["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), 512 * 1024): + await call( + "nx_upload_file", + path=path, + data_base64=base64.b64encode(data[offset : offset + 512 * 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_sheet_metal.py b/examples/validate_sheet_metal.py index 99371e2..c9f9d58 100644 --- a/examples/validate_sheet_metal.py +++ b/examples/validate_sheet_metal.py @@ -90,7 +90,7 @@ async def volume(body): 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) == 170 + assert len((await client.list_tools()).tools) == 179 catalog = await call("nx_sheet_metal_schema") assert len(catalog["operations"]) == 34 await call("nx_create_part", path=prefix + "/bracket.prt", units="mm") diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 8a6e26e..8dc8c7f 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 170, len(names) + assert len(names) == 179, len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/pyproject.toml b/pyproject.toml index 9489f2f..f03c802 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev12" +version = "0.2.0.dev13" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py new file mode 100644 index 0000000..01e9ecc --- /dev/null +++ b/scripts/validate_native_release.py @@ -0,0 +1,198 @@ +"""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 = [ + ( + "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": str(p.relative_to(root)), + "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=179) + 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.resolve())}, + stdout=stream, + stderr=subprocess.STDOUT, + check=False, + ) + item = { + "script": script, + "exit_code": result.returncode, + "receipt": str((output / receipt_name).relative_to(args.output)), + } + 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/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 15c6993..72b12c5 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.dev12" +__version__ = "0.2.0.dev13" diff --git a/src/nx_mcp/annotation_updates.py b/src/nx_mcp/annotation_updates.py index 727df0f..352f569 100644 --- a/src/nx_mcp/annotation_updates.py +++ b/src/nx_mcp/annotation_updates.py @@ -82,6 +82,7 @@ def _refresh_annotations(self): 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( diff --git a/src/nx_mcp/assembly_documentation.py b/src/nx_mcp/assembly_documentation.py index 4d79d2a..fe1230d 100644 --- a/src/nx_mcp/assembly_documentation.py +++ b/src/nx_mcp/assembly_documentation.py @@ -19,8 +19,10 @@ def _documentation_annotations(part): "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): diff --git a/src/nx_mcp/authoring_server.py b/src/nx_mcp/authoring_server.py index ad3d8f4..acf2a9b 100644 --- a/src/nx_mcp/authoring_server.py +++ b/src/nx_mcp/authoring_server.py @@ -420,9 +420,12 @@ def nx_mirror_body(body: str, plane: Literal["XY", "XZ", "YZ"]): def nx_create_drawing( - name: str = "Sheet1", size: Literal["A0", "A1", "A2", "A3", "A4"] = "A3", scale: float = 1.0 + 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 metric drawing sheet with first-angle projection, positive model-to-sheet scale, and unique name. Returns typed sheet ID and exact dimensions 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( @@ -433,7 +436,7 @@ def nx_add_base_view( 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 mm, default [100,100]. Return typed view reference; open the target sheet.""" + """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): @@ -443,7 +446,7 @@ def nx_export_drawing_pdf(path: str): 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 mm. Returns a typed drawing-view reference.""" + """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( @@ -452,8 +455,9 @@ def nx_add_dimension( 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 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 mm, default [100,80]. Returns actual computed size in model units and a typed dimension ID.""" + """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): diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index ba2c9ff..4c23a31 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-documentation-manufacturing-r1", + "revision": "2606-release-engineering-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -71,7 +71,7 @@ "nx_measure_volume": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Part and nested assembly sum, returned in mm^3; no union/mass claim" + "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", @@ -655,7 +655,7 @@ "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." + "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", @@ -740,7 +740,7 @@ "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." + "scope": "Native manual symbolic and detailed internal/external thread creation with explicit start face and cylinder diameter. Standards-table fit classes not exposed. Detailed Metric Fine M6x0.75 and Inch UNC 1/4-20, right and left handed." }, "nx_pmi_datum": { "status": "tested", @@ -841,6 +841,51 @@ "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": "Absolute base-view placement and scale with native readback; circular detail boundary refresh." + }, + "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." } }, "limitations": [ diff --git a/src/nx_mcp/documentation_editing.py b/src/nx_mcp/documentation_editing.py index 8946821..9ea108f 100644 --- a/src/nx_mcp/documentation_editing.py +++ b/src/nx_mcp/documentation_editing.py @@ -16,6 +16,7 @@ def _list_drawings(self): "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} @@ -24,7 +25,7 @@ def _list_drawings(self): } for s in part.DrawingSheets ], - "units": self._units(), + "units": "per_sheet", "coordinate_frame": "drawing_sheet", "modeling_active": current is None, } @@ -57,10 +58,16 @@ def _list_annotations(self, offset=0, limit=100): 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": diff --git a/src/nx_mcp/documentation_editing_server.py b/src/nx_mcp/documentation_editing_server.py index 1a43a86..4bd5dbe 100644 --- a/src/nx_mcp/documentation_editing_server.py +++ b/src/nx_mcp/documentation_editing_server.py @@ -63,3 +63,65 @@ def nx_bend_table( 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.""" + + +def nx_edit_drawing_view( + view: str, position: list[float] | None = None, scale: float | 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.""" + + +def nx_add_section_drawing_view( + parent_view: str, + cut_object: str, + position: list[float], + step_direction: list[float], + arrow_direction: list[float], + 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 vectors must be 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 also creates a native NX title-block definition; revision creates 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.""" diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py index 5c7bbd5..3d084b7 100644 --- a/src/nx_mcp/engineering.py +++ b/src/nx_mcp/engineering.py @@ -1439,7 +1439,9 @@ def _drawing_object(self, ref, kind): ) return matches[0] - def _create_drawing(self, name="Sheet1", size="A3", scale=1.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), @@ -1459,8 +1461,10 @@ def _create_drawing(self, name="Sheet1", size="A3", scale=1.0): b = self._work_part().DraftingDrawingSheets.CreateDraftingDrawingSheetBuilder(None) try: b.Option = b.SheetOption.CustomSize - b.Units = b.SheetUnits.Metric - b.Length, b.Height = map(float, dimensions[size]) + 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 @@ -1474,9 +1478,10 @@ def _create_drawing(self, name="Sheet1", size="A3", scale=1.0): "sheet_name": sheet.Name, "size": size, "dimensions_mm": list(dimensions[size]), + "dimensions": [sheet.Length, sheet.Height], "scale": scale, "projection": "first_angle", - "units": "mm", + "units": units, } def _add_base_view(self, drawing, body, view, position=None): @@ -1501,22 +1506,25 @@ def _add_base_view(self, drawing, body, view, position=None): 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 mm") + raise NXToolError( + "NX_INVALID_ARGUMENT", "position must be two sheet coordinates in sheet units" + ) sheet.Open() b = part.DraftingViews.CreateBaseViewBuilder(None) try: b.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view]) - b.Placement.Placement.SetValue(None, None, self.nxopen.Point3d(*point, 0.0)) + b.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point)) result = b.Commit() finally: b.Destroy() + 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, - "position_mm": point, + **self._drawing_coordinates(sheet, point), } def _export_drawing_pdf(self, path): @@ -1570,7 +1578,9 @@ def _add_projection_view(self, base_view, direction, spacing=60.0): view = self._drawing_object(base_view, "drawing_view") center = view.GetDrawingReferencePoint() dx, dy = offsets[direction] - point = self.nxopen.Point3d(center.X + dx * spacing, center.Y + dy * spacing, 0.0) + 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 @@ -1584,29 +1594,40 @@ def _add_projection_view(self, base_view, direction, spacing=60.0): 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_mm": spacing, + "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): + 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._engineering_owned(object1, "edge") - b = self._engineering_owned(object2, "edge") if object2 else a + 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 mm") - builder = self._work_part().Dimensions.CreateLinearDimensionBuilder(None) + 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) @@ -1617,7 +1638,7 @@ def _add_dimension(self, view, object1, object2=None, dim_type="aligned", origin builder.Measurement.Method = getattr( builder.Measurement.MeasurementMethod, methods[dim_type] ) - builder.Origin.OriginPoint = self.nxopen.Point3d(*point, 0.0) + builder.Origin.OriginPoint = self._sheet_point3d(self._view_sheet(drawing_view), point) result = builder.Commit() finally: builder.Destroy() @@ -1627,7 +1648,8 @@ def _add_dimension(self, view, object1, object2=None, dim_type="aligned", origin "view": view, "dim_type": dim_type, "measured_value": result.ComputedSize, - "origin_mm": point, + **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/exploded_views.py b/src/nx_mcp/exploded_views.py index d28f990..4575cb6 100644 --- a/src/nx_mcp/exploded_views.py +++ b/src/nx_mcp/exploded_views.py @@ -430,7 +430,7 @@ def _add_base_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 mm" + "NX_INVALID_ARGUMENT", "position must contain two sheet coordinates in sheet units" ) sheet = self._drawing_object(drawing, "drawing_sheet") if sheet.OwningPart != part: @@ -441,10 +441,11 @@ def _add_base_view( builder = part.DraftingViews.CreateBaseViewBuilder(None) try: builder.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view]) - builder.Placement.Placement.SetValue(None, None, self.nxopen.Point3d(*point, 0.0)) + builder.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point)) result = builder.Commit() finally: builder.Destroy() + 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): @@ -456,7 +457,7 @@ def _add_base_view( "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"), "scope": "assembly", "orientation": view, - "position_mm": point, + **self._drawing_coordinates(sheet, point), "explosion": self._reference(ex, "explosion", part, "Explosion") if ex else None, } diff --git a/src/nx_mcp/freeform.py b/src/nx_mcp/freeform.py index a6c83df..728b2cb 100644 --- a/src/nx_mcp/freeform.py +++ b/src/nx_mcp/freeform.py @@ -245,7 +245,31 @@ def _edit_faces(self, faces, action, distance=None, direction=None, replacement= b.FaceCollector.ReplaceRules([rule], False) b.Heal = True b.AllowPartialDelete = False - return self._freeform_commit(b) + 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() diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 1194cd6..d70202a 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -31,6 +31,7 @@ from nx_mcp.manufacturing import ManufacturingMixin from nx_mcp.nx_bridge import NXOpenExecutor from nx_mcp.recovery import OperationStore, timestamp +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 @@ -145,6 +146,7 @@ def add(a, b): class HardenedExecutor( + ReleaseEngineeringMixin, DocumentationEditingMixin, AnnotationUpdatesMixin, ThreadStandardsMixin, @@ -1576,6 +1578,7 @@ def _measure_volume(self, body=None, scope="auto"): 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"), diff --git a/src/nx_mcp/inspection.py b/src/nx_mcp/inspection.py index de2568d..b7a913e 100644 --- a/src/nx_mcp/inspection.py +++ b/src/nx_mcp/inspection.py @@ -189,6 +189,9 @@ def _interference_pair(self, a, b): continue props = part.MeasureManager.NewMassProperties(units, 0.999, [body]) try: + props.InformationUnit = ( + self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter + ) volume += float(props.Volume) finally: props.Dispose() diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py index e59a15e..fdc5f34 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -722,6 +722,7 @@ def _measure_volume(self, body=None): 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"), diff --git a/src/nx_mcp/release_engineering.py b/src/nx_mcp/release_engineering.py new file mode 100644 index 0000000..d3e4ba3 --- /dev/null +++ b/src/nx_mcp/release_engineering.py @@ -0,0 +1,566 @@ +"""Native drawing authoring and persistent imported-geometry references.""" + +import math + +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)) + 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__, + "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): + import NXOpen.UF as U + + if position is None and scale is None: + raise NXToolError("NX_INVALID_ARGUMENT", "Supply position or scale") + 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) + 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), unit_normal(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" + if section is not None and ( + not section.HasUserAttribute(attr, self.nxopen.NXObject.AttributeType.String, -1) + or section.GetStringAttribute(attr) != kind + or section.GetStringAttribute("NX_MCP_TABLE_SHEET_V1") + != U.UFSession.GetUFSession().Tag.AskHandleFromTag(sheet.Tag) + ): + raise NXToolError( + "NX_OBJECT_OWNER_MISMATCH", "Edit a managed table of the same kind on this sheet" + ) + 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) + ) + 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) + ) + 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": "native title-block annotation origin" + if kind == "title_block" + else "native table-section annotation origin", + "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/sheet_metal.py b/src/nx_mcp/sheet_metal.py index 6bf407c..1fe2eba 100644 --- a/src/nx_mcp/sheet_metal.py +++ b/src/nx_mcp/sheet_metal.py @@ -999,7 +999,7 @@ def _add_flat_pattern_view(self, drawing, flat_pattern, position=None): 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 mm" + "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) @@ -1011,17 +1011,18 @@ def _add_flat_pattern_view(self, drawing, flat_pattern, position=None): builder = part.DraftingViews.CreateBaseViewBuilder(None) try: builder.SelectModelView.SelectedView = model_view - builder.Placement.Placement.SetValue(None, None, self.nxopen.Point3d(*point, 0.0)) + 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, - "position_mm": point, - "units": "mm", + **self._drawing_coordinates(sheet, point), + "units": self._sheet_units(sheet), } def _sheet_metal_annotation( diff --git a/src/nx_mcp/sheet_metal_server.py b/src/nx_mcp/sheet_metal_server.py index 9111b9e..dd3ac94 100644 --- a/src/nx_mcp/sheet_metal_server.py +++ b/src/nx_mcp/sheet_metal_server.py @@ -128,7 +128,7 @@ def nx_export_flat_pattern( 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 mm; 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.""" + """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( diff --git a/src/nx_mcp/thread_standards.py b/src/nx_mcp/thread_standards.py index dec3466..2ee1515 100644 --- a/src/nx_mcp/thread_standards.py +++ b/src/nx_mcp/thread_standards.py @@ -135,6 +135,10 @@ def _standard_thread( 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"), diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py index 946aa44..542f878 100644 --- a/tests/fakes/__init__.py +++ b/tests/fakes/__init__.py @@ -290,6 +290,7 @@ def rig(tmp_path, monkeypatch): 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), diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py index 3b62883..5e89ae4 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -28,6 +28,9 @@ def inverse(pose): @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()) root = Component("root") parent = Component("parent", parent=root) diff --git a/tests/test_freeform_manufacturing.py b/tests/test_freeform_manufacturing.py index cbb2d94..5b80293 100644 --- a/tests/test_freeform_manufacturing.py +++ b/tests/test_freeform_manufacturing.py @@ -76,6 +76,7 @@ def ff(rig, monkeypatch): 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 diff --git a/tests/test_native_release_runner.py b/tests/test_native_release_runner.py new file mode 100644 index 0000000..1527378 --- /dev/null +++ b/tests/test_native_release_runner.py @@ -0,0 +1,34 @@ +"""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()} + ] diff --git a/tests/test_release_engineering.py b/tests/test_release_engineering.py new file mode 100644 index 0000000..365b007 --- /dev/null +++ b/tests/test_release_engineering.py @@ -0,0 +1,299 @@ +"""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.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] + 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_visual_tools.py b/tests/test_visual_tools.py index 54b54c2..dee8d47 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 170 + assert len(tools) == 179 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 7942284e0402f54d3ca54a6d6481b58a92a27c9c Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 14:15:36 +0200 Subject: [PATCH 34/69] Normalize native receipt artifact paths across platforms --- scripts/validate_native_release.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py index 01e9ecc..5c2f237 100644 --- a/scripts/validate_native_release.py +++ b/scripts/validate_native_release.py @@ -38,7 +38,7 @@ def manifest_files(root): return [ { - "path": str(p.relative_to(root)), + "path": p.relative_to(root).as_posix(), "size": p.stat().st_size, "sha256": hashlib.sha256(p.read_bytes()).hexdigest(), } @@ -155,7 +155,7 @@ def main(): item = { "script": script, "exit_code": result.returncode, - "receipt": str((output / receipt_name).relative_to(args.output)), + "receipt": (output / receipt_name).relative_to(args.output).as_posix(), } report["suites"].append(item) if result.returncode: From 3d0e4799b076179779a01d6cae8f31034e6ff496 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 14:41:11 +0200 Subject: [PATCH 35/69] Record native dev13 acceptance and harden the validation runner --- README.md | 2 + docs/dev13-validation.json | 309 +++++++++++++++++++++++ docs/fork-status.md | 2 +- docs/release-engineering.md | 15 ++ examples/validate_release_engineering.py | 4 +- scripts/validate_native_release.py | 2 +- src/nx_mcp/capability_manifest.json | 4 +- 7 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 docs/dev13-validation.json diff --git a/README.md b/README.md index 07b32bc..7e081e5 100644 --- a/README.md +++ b/README.md @@ -148,3 +148,5 @@ See [freeform, assembly documentation and manufacturing](docs/freeform-manufactu See [editable documentation and manufacturing](docs/documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. See [release engineering and native acceptance](docs/release-engineering.md) for dev13 drawing authoring, assembly refresh, retained-dimension repair, imported geometry references, mixed units and serial release validation. + +The [dev13 native acceptance receipt](docs/dev13-validation.json) records installed runtime tests, preserved session state and artifact hashes. diff --git a/docs/dev13-validation.json b/docs/dev13-validation.json new file mode 100644 index 0000000..0cac541 --- /dev/null +++ b/docs/dev13-validation.json @@ -0,0 +1,309 @@ +{ + "version": "0.2.0.dev13", + "runtime_commit": "7942284e0402f54d3ca54a6d6481b58a92a27c9c", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 179, + "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/34032586103", + "release_build": "https://github.com/xuio/NX_MCP/actions/runs/34032585472", + "release_sha256": "196eef7af7dc85b7abc9f958fbcb33d79ae0d10174fe30ac5252d532e35ba9ed", + "local_validation": { + "pytest_passed": 745, + "dedicated_nx_test_deselected": 1, + "combined_statement_branch_coverage_percent": 78.68, + "unchanged_coverage_gate_percent": 78, + "mypy": "passed", + "pre_commit": "passed", + "ci_jobs_passed": 13 + }, + "added_tools": [ + "nx_drawing_view_info", + "nx_edit_drawing_view", + "nx_add_section_drawing_view", + "nx_add_detail_drawing_view", + "nx_drawing_table", + "nx_geometry_anchor", + "nx_resolve_geometry_anchor", + "nx_update_assembly_documentation", + "nx_list_dimensions" + ], + "native_release_passed": true, + "native_suites": [ + { + "script": "validate_release_engineering.py", + "exit_code": 0 + }, + { + "script": "validate_documentation_manufacturing.py", + "exit_code": 0 + }, + { + "script": "validate_annotation_recovery.py", + "exit_code": 0 + }, + { + "script": "validate_freeform_manufacturing.py", + "exit_code": 0 + }, + { + "script": "validate_sheet_metal.py", + "exit_code": 0 + } + ], + "release_engineering_checks": [ + "native_view_edits_section_detail_dimension_title_revision_pdf_and_anchor_reopen", + "inch_volume_and_metric_inch_sheets_in_inch_part", + "thread_Metric Fine_right", + "thread_Metric Fine_left", + "thread_Inch UNC_right", + "thread_Inch UNC_left", + "mixed_unit_component_bounds_volume_and_clearance", + "prototype_resize_replace_mates_clearance_explosion_trace_BOM_and_drawing_propagation", + "two_bend_partial_width_channel_square_round_reliefs_analytic_flat_pattern", + "adjacent_mitered_flanges_native_validity_and_flat_export", + "vendor_step_native_offset_repair_rollback_and_persistent_face_reopen" + ], + "protected_documentation_checks": [ + "rounded_enclosure_analytic_shell_step_roundtrip_local_edit_rollback_reopen", + "curved_parabolic_G2_join_and_deliberate_tangent_discontinuity", + "thin_wall_measurement_and_outside_ray_origin_rejection", + "known_five_degree_draft_signed_transition_and_threshold", + "native_bend_table_managed_PMI_transactional_update_and_persistent_reopen", + "standard_table_thread_False_and_GDT_modifiers", + "standard_table_thread_True_and_GDT_modifiers", + "sheet_metal_assembly_explosion_trace_edit_BOM_columns_balloon_placement_drawings" + ], + "protected_annotation_recovery": { + "automatic_without_table_edit": true, + "operation_retry_deduplicated": true, + "table_rows": [ + [ + "1", + "85,00", + "3,00" + ] + ], + "disable_preserves_measured_snapshot": true, + "rollback_restored_annotation_and_source": true, + "passed": true, + "session_restored": true + }, + "protected_freeform_checks": [ + "associative_3d_spline_edit_and_idempotent_retry", + "native_mesh_sew_thicken_and_matching_surface_continuity", + "native_sheet_trim", + "native_bridge_and_deliberate_gap_detection", + "tangent_but_curvature_discontinuous_surface_pair", + "move_offset_replace_analytic_volumes", + "delete_heal_wall_thickness_draft_and_native_pmi", + "native_symbolic_thread", + "native_detailed_thread", + "native_bom_quantity_update_balloons_trace_animation_and_pdf" + ], + "protected_sheet_metal_checks": [ + "tab_analytic_volume_flange_bend_info_retry_pmi", + "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", + "unsupported_edit_unchanged_checkpoint_rollback", + "path_sketch_secondary_contour_analytic_volume" + ], + "numeric_evidence": { + "inch_fixture_volume_mm3": 8193.532, + "mixed_assembly_clearance_mm": 13.65, + "assembly_resize_dimension_mm": 8.0, + "assembly_rebound_dimension_mm": 10.0, + "assembly_replacement_clearance_mm": 10.0, + "sheet_channel": { + "bend_count": 2, + "developed_dimensions": [ + 100.0, + 120.49823 + ], + "analytic_dimensions": [ + 100, + 120.49822911213865 + ], + "reliefs": [ + "Square", + "Round" + ] + } + }, + "public_vendor_fixture": { + "name": "KiCad USB4085", + "sha256": "82235f7275d07f720e3c050f781397f4bef47fdc7e15dd507d68ccba861f1a35", + "source": "https://gitlab.com/kicad/libraries/kicad-packages3D/-/blob/8fb0194639525261cd642ec40d62ee26e1f601de/Connector_USB.3dshapes/USB_C_Receptacle_GCT_USB4085.step", + "native_offset_rollback_and_reopen": "passed" + }, + "deployment": { + "stdio_tools": 179, + "http_tools": 179, + "native_inline_screenshot_checksum": "passed", + "saved_parts": 38, + "unchanged_component_occurrences": 116, + "main_thread_dispatch": true, + "original_session_restored": true + }, + "visual_review": { + "service_pdf": "Native section through an 8mm hole, 3:1 circular detail, 50mm dimension, editable revision/title tables and reopened title edit.", + "assembly_pdf": "Updated two-row BOM, recreated native balloons, 10mm reassociated dimension and managed explosion trace.", + "mixed_units_pdf": "Metric and inch drawing sheets in an inch part, with native aligned projected views.", + "native_renders": "Adjacent mitered sheet-metal flanges and public USB connector STEP geometry." + }, + "scope_limits": [ + "Validation covers bounded NX v2606 fixtures; it is not blanket certification of every feature or manufacturing standard.", + "Section view authoring creates a native simple section; complex stepped sections are outside this tool.", + "Detail center/radius are explicit model coordinates, not automatic material-point tracking across arbitrary shape edits.", + "Component replacement can retain old dimensions or balloons; diagnostics mark documentation incomplete and explicit reassociation/recreation is required.", + "Geometry anchors resolve exact surviving owned native entities; no geometric nearest-neighbor substitution or occurrence anchors.", + "Sheet-metal flat-pattern numeric validation covers the two-bend channel; adjacent mitered-corner validation covers native validity and export.", + "Thread cases add Metric Fine M6x0.75 and Inch UNC 1/4-20 in both hands; no complete standards/fit-class certification.", + "Existing wall-thickness/draft/curvature diagnostics remain bounded sampling." + ], + "artifacts": [ + { + "path": "documentation/assembly.png", + "size": 24696, + "sha256": "f4412b7fc3146347abf29f590d4411b9ed2a33def9da110fed2bc200b9b20929" + }, + { + "path": "documentation/bracket.pdf", + "size": 21300, + "sha256": "1b6bd77db2e0e2bc2749ed27b5ee5ef2f426e25d42c5a77f2dde78d2172585c4" + }, + { + "path": "documentation/documentation-manufacturing-validation.json", + "size": 125022, + "sha256": "6bb8532b15302230bf58f09a618ce5811d793d66320c20fe3b74a5501db3eb39" + }, + { + "path": "documentation/enclosure.png", + "size": 20722, + "sha256": "120496d2d28ee44cab016023414f7f43977488114cec1c7c5593141f42eaf7e1" + }, + { + "path": "documentation/refresh-check.json", + "size": 314, + "sha256": "396e9ffbafb6dd9b22323b4f59cfc10918fa0378b44e43779d46676b5e9c7cb3" + }, + { + "path": "documentation/service.pdf", + "size": 26316, + "sha256": "9fcea4fa90d1b54e8ca3faf61bec5c3f94ea5f6a503de4de9a14f4630d769c33" + }, + { + "path": "freeform/animation.html", + "size": 48925, + "sha256": "471fcbe5ead5242fea6d9fd49f594ea2ed0f06d6121047a2a3e01491620b7cd1" + }, + { + "path": "freeform/freeform-manufacturing-validation.json", + "size": 30191, + "sha256": "4f1d9747029712a9b9906f451340e24b80e90fb50044bcc7d37c8915c37edd46" + }, + { + "path": "freeform/service.pdf", + "size": 20541, + "sha256": "58f42d9fb724192cc231893b02dcbc712e39d56ab898b8a837bfe32653606bdc" + }, + { + "path": "release_engineering/assembly.pdf", + "size": 24100, + "sha256": "8c99e78592513b37bc66506de3c2194d7199783ed1aa7587119320bbe0a184e4" + }, + { + "path": "release_engineering/channel.dxf", + "size": 98241, + "sha256": "748ccd7ceee4b92711116df76d66df51973be4f2299ae32fd32b94036d07fc19" + }, + { + "path": "release_engineering/corner.dxf", + "size": 96156, + "sha256": "a2bbddb027897a8c4a62bfbb91858b3a52e75bd05bc52267c887fc6143365087" + }, + { + "path": "release_engineering/corner.png", + "size": 38583, + "sha256": "c7ba6d2959aafac44e1dfb2c9a44cc670ee1981e811351ae4753587c41d9cc25" + }, + { + "path": "release_engineering/mixed-units.pdf", + "size": 3594, + "sha256": "78d28f32004a2906d07df3a4cc681564c479937f9f369e50f462c6b10b3a979e" + }, + { + "path": "release_engineering/release-engineering-validation.json", + "size": 146140, + "sha256": "f9cc5e518171457e684387f140a9b75970db90bc87f711b984f21f7bd1140534" + }, + { + "path": "release_engineering/service-reopened.pdf", + "size": 42243, + "sha256": "ebe258d12db2d764063f2f88dbf167aeb125fda42216d7b6935eb8980de40ed1" + }, + { + "path": "release_engineering/service.pdf", + "size": 41575, + "sha256": "96a706e67acbad379bb34d900183415c4a52d03a5e7c19b668573219c1e4adb2" + }, + { + "path": "release_engineering/vendor.png", + "size": 64913, + "sha256": "2b6285de87b3ef6aedb3d1fac984cc21b4f710976f2f6f311b7bf57923d67ccd" + }, + { + "path": "sheet_metal/bracket-edited.dxf", + "size": 95203, + "sha256": "386f447fdc9ecb466b64aa7fd4f1ffa8870e4c8808924db9cc7180ab201da1fd" + }, + { + "path": "sheet_metal/bracket.dxf", + "size": 95199, + "sha256": "854d4bbc3668c7a6cd8594934bd9eb224aea517e71e251cffde9333776e33a3a" + }, + { + "path": "sheet_metal/bracket.geo", + "size": 905, + "sha256": "863211fcdca82295441761f4931596584a23d480691674675cd74873a0e8e451" + }, + { + "path": "sheet_metal/bracket.pdf", + "size": 2426, + "sha256": "bd20e6801851616d97c44eb76eee64f1e655f6909ea8bb53c1f7b5c7d194ca43" + }, + { + "path": "sheet_metal/bracket.png", + "size": 25762, + "sha256": "a7c5efaf6b359ec5f06abb9b852bd55a49c1f2bf9a794ab77e98fc3a6184a6f1" + }, + { + "path": "sheet_metal/sheet-metal-validation.json", + "size": 4242, + "sha256": "e6a0fb3254af5c388cb2e15e1c91cf641b1a7182cd79caf1e18c2eb8487e8da4" + }, + { + "path": "validate_annotation_recovery.log", + "size": 254, + "sha256": "b4be8e731a299040f027b619b1874565a6f3cab8f0e508c4fbed117a86728a23" + }, + { + "path": "validate_documentation_manufacturing.log", + "size": 547, + "sha256": "f33d9355e55cdd303e7b1ceb11a29ca15f6d675245876fdff660cc5940e755b8" + }, + { + "path": "validate_freeform_manufacturing.log", + "size": 465, + "sha256": "4e70cc3fd57847167b09e146d7fff23b05be9c7da74f28d1a8f81f78fee5e4e0" + }, + { + "path": "validate_release_engineering.log", + "size": 617, + "sha256": "5a60baa08b3cba389ef9d9f6ec0f325732505651d6ec7ac443c20e829d78bd12" + }, + { + "path": "validate_sheet_metal.log", + "size": 228, + "sha256": "e24612fe4a65a11e0afccae25e07c97d349db3dc5f1cc6cf1eb08b507170ea24" + } + ] +} diff --git a/docs/fork-status.md b/docs/fork-status.md index ad3ed88..3f607f3 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -44,7 +44,7 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev12 acceptance](dev12-validation.json); [dev11 acceptance](dev11-validation.json) retains the earlier freeform/documentation results; [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev13 acceptance](dev13-validation.json); [dev12 acceptance](dev12-validation.json) retains the preceding documentation results; [dev11 acceptance](dev11-validation.json) retains the earlier freeform/documentation results; [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/docs/release-engineering.md b/docs/release-engineering.md index f41ad5c..0ee4b9b 100644 --- a/docs/release-engineering.md +++ b/docs/release-engineering.md @@ -42,3 +42,18 @@ The runner executes the release-engineering, documentation, annotation-recovery, A public connector fixture is available from [KiCad's USB4085 model](https://gitlab.com/kicad/libraries/kicad-packages3D/-/blob/8fb0194639525261cd642ec40d62ee26e1f601de/Connector_USB.3dshapes/USB_C_Receptacle_GCT_USB4085.step), SHA256 `82235f7275d07f720e3c050f781397f4bef47fdc7e15dd507d68ccba861f1a35`. Fetch it separately under its upstream license; vendor CAD is not bundled in this repository. Proprietary NX catalogs are read in place and never copied into release artifacts. Native acceptance is separate from mock/transport CI. A CI pass alone does not certify a release against NX. Keep the native receipt, package hash and exact source commit together, and run acceptance after every deployment before recording that release as verified. + +## Verified dev13 deployment + +[Native acceptance](dev13-validation.json) records all five installed suites passing: +11 release-engineering groups, eight protected documentation groups, annotation +recovery, ten freeform groups and four sheet-metal groups. The original 38 saved +parts and 116 component paths/transforms were preserved. All 29 downloaded +artifacts matched their native hashes; PDF layouts were visually reviewed. + +The runtime package is pinned to `7942284e0402f54d3ca54a6d6481b58a92a27c9c`. +A separate validation overlay records the final evidence, corrected capability +scope and two test-runner fixes: shared-drive output uses `absolute()` without +unsupported final-path resolution, and STEP uploads obey the 256 KiB chunk limit. +The NX modeling implementation remains the packaged runtime. The overlay's +`validation-release.json` records its commit and individual file checksums. diff --git a/examples/validate_release_engineering.py b/examples/validate_release_engineering.py index a99234e..796063d 100644 --- a/examples/validate_release_engineering.py +++ b/examples/validate_release_engineering.py @@ -698,11 +698,11 @@ async def artifact(meta, name): async def upload(source, path): data = source.read_bytes() - for offset in range(0, len(data), 512 * 1024): + for offset in range(0, len(data), 256 * 1024): await call( "nx_upload_file", path=path, - data_base64=base64.b64encode(data[offset : offset + 512 * 1024]).decode(), + data_base64=base64.b64encode(data[offset : offset + 256 * 1024]).decode(), offset=offset, total_size=len(data), sha256=hashlib.sha256(data).hexdigest(), diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py index 5c2f237..65bc83c 100644 --- a/scripts/validate_native_release.py +++ b/scripts/validate_native_release.py @@ -147,7 +147,7 @@ def main(): with log.open("w") as stream: result = subprocess.run( [sys.executable, str(source / "examples" / script)], - env={**os.environ, "NX_VALIDATION_OUTPUT": str(output.resolve())}, + env={**os.environ, "NX_VALIDATION_OUTPUT": str(output.absolute())}, stdout=stream, stderr=subprocess.STDOUT, check=False, diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 4c23a31..4e3f243 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -740,7 +740,7 @@ "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. Detailed Metric Fine M6x0.75 and Inch UNC 1/4-20, right and left handed." + "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", @@ -835,7 +835,7 @@ "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." + "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", From 80ef68ecc0ac583bd868b86bdcecd126a01bb756 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 15:21:42 +0200 Subject: [PATCH 36/69] Consolidate dev14 and fix native lifecycle and recovery findings --- docs/dev14-release.md | 30 +++ examples/validate_hard_geometry.py | 271 ++++++++++++++++++++++++ examples/validate_transport_recovery.py | 131 ++++++++++++ pyproject.toml | 2 +- scripts/install_release.ps1 | 10 +- scripts/restore_release.ps1 | 4 +- scripts/validate_native_release.py | 1 + src/nx_mcp/__init__.py | 2 +- src/nx_mcp/hardened.py | 45 +++- tests/test_authoring_contracts.py | 19 +- tests/test_recovery_state.py | 23 ++ 11 files changed, 525 insertions(+), 13 deletions(-) create mode 100644 docs/dev14-release.md create mode 100644 examples/validate_hard_geometry.py create mode 100644 examples/validate_transport_recovery.py diff --git a/docs/dev14-release.md b/docs/dev14-release.md new file mode 100644 index 0000000..2092147 --- /dev/null +++ b/docs/dev14-release.md @@ -0,0 +1,30 @@ +# Consolidated dev14 release + +Dev14 packages the dev13 runtime, validation-runner fixes and capability metadata +together. No validation overlay is required. Installation removes obsolete overlay +metadata; rollback restores the prior runtime and its original metadata. + +Three failures reproduced during native acceptance are addressed: + +- Closing an assembly can unload unused prototypes despite `CloseWholeTree=False`. + The result now reports `closed_parts`, `closed_count` and `remaining_count`, and + invalidates references for every unloaded part. Re-list parts between closes. +- Native bridge receipt queries preserve the target operation ID, session and + mutation outcome. Query identity is separate. HTTP receipt queries already read + the durable store directly. +- The STEP translator can accept a truncated file and import partial geometry. + Missing exchange-file opening/closing markers now fail before translation. This + is a completeness check, not a complete STEP syntax or geometry validator. + +`examples/validate_hard_geometry.py` adds truncated vendor STEP, native hole-face +healing, removed anchors, rotated nested mixed-unit assemblies and three adjacent +mitered sheet-metal walls. It is included in the serial native release runner. + +`examples/validate_transport_recovery.py` runs on Windows beside the bridge. Set +`NX_BRIDGE_DESCRIPTOR`, `NX_WORKSPACE` and `NX_VALIDATION_OUTPUT`. It disconnects +before receiving a relative-move result, verifies the committed receipt, retries +the same ID, checks rollback and manual handoff, then restores the prior session. +Tokens remain local and are never written to test receipts. Keep the receipt to +verify it after a real bridge restart; an old receipt never restores old IDs. + +Native validation results must be recorded against the exact deployed package. diff --git a/examples/validate_hard_geometry.py b/examples/validate_hard_geometry.py new file mode 100644 index 0000000..278a290 --- /dev/null +++ b/examples/validate_hard_geometry.py @@ -0,0 +1,271 @@ +"""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"] + ] + 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) + 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_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/pyproject.toml b/pyproject.toml index f03c802..d289ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev13" +version = "0.2.0.dev14" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/install_release.ps1 b/scripts/install_release.ps1 index 84dd6a9..08ac5e4 100644 --- a/scripts/install_release.ps1 +++ b/scripts/install_release.ps1 @@ -19,7 +19,7 @@ $python = Join-Path $InstallRoot 'venv\Scripts\python.exe' 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')) { +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 } } @@ -38,6 +38,14 @@ try { & $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 diff --git a/scripts/restore_release.ps1 b/scripts/restore_release.ps1 index 719f403..04a5409 100644 --- a/scripts/restore_release.ps1 +++ b/scripts/restore_release.ps1 @@ -8,13 +8,13 @@ if (Test-Path -LiteralPath $BridgeDescriptor) { throw 'Stop the NX bridge before 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')) { +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 -eq 'release.json' -and (Test-Path $destination)) { + } elseif ($item -in @('release.json','install.json','validation-release.json') -and (Test-Path $destination)) { Remove-Item -LiteralPath $destination -Force } } diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py index 65bc83c..95141f2 100644 --- a/scripts/validate_native_release.py +++ b/scripts/validate_native_release.py @@ -19,6 +19,7 @@ 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", diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 72b12c5..f592af9 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.dev13" +__version__ = "0.2.0.dev14" diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index d70202a..e7e089d 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -424,6 +424,11 @@ def handler(**p): 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, @@ -432,6 +437,9 @@ def handler(**p): "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 @@ -653,8 +661,9 @@ def _save_as(self, path): def _close_part(self, save=True, part=None): target = self.objects.resolve(part, expected_kind="part") if part else self._work_part() - pid = self._part_id(target) - tag = int(target.Tag) + # 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( @@ -668,11 +677,24 @@ def _close_part(self, save=True, part=None): self.nxopen.BasePart.CloseModified.CloseModified, None, ) - self.objects.invalidate_part(pid) - self._part_generations.pop(tag, None) - self._history = [h for h in self._history if h["part_id"] != pid] - self._checkpoints = {k: v for k, v in self._checkpoints.items() if v["part_id"] != pid} - return {"message": "Closed specified part; component tree and other parts preserved"} + 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): return { @@ -1249,6 +1271,15 @@ def _import_geometry(self, path, flatten=False, target="work_part", output_path= 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 diff --git a/tests/test_authoring_contracts.py b/tests/test_authoring_contracts.py index f8d6cb2..9ab3edd 100644 --- a/tests/test_authoring_contracts.py +++ b/tests/test_authoring_contracts.py @@ -262,7 +262,9 @@ def test_batch_preflight_cancellation_and_progress(rig): def test_step_import_validates_conflicts_and_reports_no_output(rig, tmp_path): source = tmp_path / "vendor.step" - source.write_text("PRODUCT('test','test'); NEXT_ASSEMBLY_USAGE_OCCURRENCE") + 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" @@ -309,3 +311,18 @@ def save(path): 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_recovery_state.py b/tests/test_recovery_state.py index 8e0327c..ac41f99 100644 --- a/tests/test_recovery_state.py +++ b/tests/test_recovery_state.py @@ -205,3 +205,26 @@ def test_open_reuses_loaded_part_with_equivalent_path_spelling(rig, tmp_path): 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 From 362f80f3b8aa90d9ddfcb58d4fe3f619f3ee33b5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 15:25:17 +0200 Subject: [PATCH 37/69] Assert analytic nested interference independent of enumeration order --- examples/validate_hard_geometry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/examples/validate_hard_geometry.py b/examples/validate_hard_geometry.py index 278a290..25f3ea2 100644 --- a/examples/validate_hard_geometry.py +++ b/examples/validate_hard_geometry.py @@ -126,6 +126,7 @@ async def checked(name): 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, @@ -140,6 +141,10 @@ async def checked(name): 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") From 052a9d3c5ce50f0e600d30ce352f92b90a453b66 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 15:45:28 +0200 Subject: [PATCH 38/69] Improve agent discovery, artifact previews and response contracts --- docs/agent-ux.md | 49 +++++++++++ docs/dev14-release.md | 2 + docs/fork-status.md | 6 +- src/nx_mcp/capability_manifest.json | 22 +++-- src/nx_mcp/hardened.py | 16 +++- src/nx_mcp/integration_server.py | 122 ++++++++++++++++++++++++---- src/nx_mcp/interactive.py | 5 +- src/nx_mcp/sheet_metal.py | 29 +++++-- src/nx_mcp/sheet_metal_catalog.json | 6 +- tests/test_artifact_recovery.py | 41 ++++++++++ tests/test_recovery_state.py | 15 ++++ tests/test_sheet_metal.py | 2 +- tests/test_ui_recovery.py | 11 ++- 13 files changed, 283 insertions(+), 43 deletions(-) create mode 100644 docs/agent-ux.md diff --git a/docs/agent-ux.md b/docs/agent-ux.md new file mode 100644 index 0000000..7023bba --- /dev/null +++ b/docs/agent-ux.md @@ -0,0 +1,49 @@ +# Agent UX and recovery + +Three independent agents sampled live discovery, artifacts and recovery. They +used read-only MCP calls while serial native acceptance ran. This was a focused +usability evaluation, not testing every tool or every geometric option. + +## Efficient discovery and artifacts + +- Start with `nx_workspace_info`. All file arguments name files on the NX host. +- Use `nx_capabilities(tool="nx_revolve")` or `prefix="nx_sheet"` for focused + native evidence. Full capability discovery remains available without filters. +- Request `nx_sheet_metal_schema(operation="unbend")` before authoring. Top-level + `status` describes call success; `validation_status` describes native evidence. + Length conventions are explicit and independent of whichever part is active. +- Use `nx_workspace_list(path="project", prefix="review", limit=100)` and follow + `next_offset`. `count` is page size; `total_count` is the filtered total. +- Use `nx_download_file(path="project/view.png", delivery="image")` for an inline + PNG, with size, resolution and checksum in structured metadata. The PNG bytes + are MCP image content rather than a large base64 text field. Limit: 8 MiB. +- Use `delivery="metadata"` for one file's size and SHA-256. Default `base64` + delivery remains available for PDFs/CAD and large images. Follow + `bytes_returned`, `next_offset` and `eof`; chunk length is 1–262144 bytes. + +Discovery and inspection no longer request a model viewport refresh. Drawing +mutations do not invoke a model-view refresh while a drawing sheet is active. + +## Recovery + +Supply an operation ID before a mutation. After a transport failure, query that +ID. `committed` permits replay of the identical request without repeating its +mutation. A changed payload with the same ID is rejected. `unknown` never proves +failure and never authorizes a blind retry. Receipt identities and query identities +are separate. An earlier-session receipt cannot make old object references valid. + +Closing an assembly can cause NX to unload unused prototypes. Inspect returned +`closed_parts` and re-list the session between closes. Reacquire references after +close, rollback or manual handoff. Saving expires native checkpoints; durable +operation receipts survive a bridge restart, but native undo marks do not. + +## Response contracts and remaining scope + +Every integration tool advertises a common structured output schema for status, +warnings, units and optional operation identity/outcome. Tool-specific result +fields remain extensible; this is not a complete typed schema for every feature +builder. Existing structured/text compatibility is retained. Long native calls +still serialize discovery queries that need installed NX API detection. Generic +PDF delivery remains chunked; there is no inline PDF renderer in the MCP server. + +Refresh the client's tool catalog after deployment to discover new arguments. diff --git a/docs/dev14-release.md b/docs/dev14-release.md index 2092147..0aff3ba 100644 --- a/docs/dev14-release.md +++ b/docs/dev14-release.md @@ -28,3 +28,5 @@ Tokens remain local and are never written to test receipts. Keep the receipt to verify it after a real bridge restart; an old receipt never restores old IDs. Native validation results must be recorded against the exact deployed package. + +The subsequent [agent UX review](agent-ux.md) adds focused capability queries, paged artifact listing, inline existing-PNG retrieval and a common structured output schema. Read-only calls no longer refresh model views; sheet-metal evidence status and unit conventions are explicit. diff --git a/docs/fork-status.md b/docs/fork-status.md index 3f607f3..b98bce5 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,9 +1,11 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev13`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev14`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. +See [agent UX](agent-ux.md) for focused discovery, inline artifact retrieval and recovery guidance. + ## Included changes - NX v2606 API repairs, sketch bases, object references and multi-body results. @@ -14,7 +16,7 @@ See [engineering tools and scoped validation](engineering-tools.md) for the late - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev13 opt-in integration profile exposes 179 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev14 opt-in integration profile exposes 179 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 4e3f243..7c134c1 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-release-engineering-r1", + "revision": "2606-agent-ux-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -66,7 +66,7 @@ "nx_close_part": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Explicit saved source parts closed without whole-tree closure" + "scope": "Saved part closure; NX may unload unused prototypes. Closed-part reporting invalidates all unloaded part references." }, "nx_measure_volume": { "status": "tested", @@ -886,6 +886,16 @@ "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." } }, "limitations": [ @@ -901,13 +911,7 @@ "MCP desktop clients must refresh tool schemas after deployment" ], "unavailable": [ - "general_solid_validity_audit", "batch_model_viewport_image", - "full_sketch_curve_and_constraint_editing", - "loft_shell_draft_threads_engraving", - "general_body_transforms", - "assembly_constraint_editing", - "union_of_all_pairwise_interference_volumes", - "material_assignment" + "union_of_all_pairwise_interference_volumes" ] } diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index e7e089d..3d6864c 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -1495,7 +1495,7 @@ def _batch(self, operations): "message": "Batch committed on NX journal thread", } - def _capabilities(self): + def _capabilities(self, tool=None, prefix=None): import json manifest = json.loads(Path(__file__).with_name("capability_manifest.json").read_text()) @@ -1540,8 +1540,18 @@ def _capabilities(self): status="unavailable", scope="Requires the interactive NX host" ) if self.nx_version != "v2606": - for tool in manifest["tools"].values(): - tool.update(status="experimental", scope="This NX version has not been tested") + 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): diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index da86cfc..659370b 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -7,10 +7,12 @@ import inspect import json import os +import struct import uuid -from typing import Any, Literal +from typing import Annotated, Any, Literal from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations +from pydantic import BaseModel, ConfigDict, Field from nx_mcp import ( assembly_documentation_server, @@ -231,7 +233,7 @@ def nx_batch(operations: list[dict[str, Any]]): pass -def nx_capabilities(): +def nx_capabilities(tool: str | None = None, prefix: str | None = None): pass @@ -256,11 +258,21 @@ def nx_create_directory(path: str): pass -def nx_workspace_list(path: str = "."): +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: int = 0, length: int = 262144): +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 @@ -269,6 +281,9 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of DESCRIPTIONS = { + "nx_boolean": "Boolean supported solid bodies: unite, subtract or intersect. Native cube subtraction and volume checks are scoped in nx_capabilities(tool='nx_boolean'); not general certification.", + "nx_revolve": "Revolve a finished sketch about the specified axis and origin. 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.", @@ -297,7 +312,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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": "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": "Close only the specified loaded part (ID), or current work part; preserves its component tree and unrelated parts. save defaults true.", + "nx_close_part": "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.", @@ -310,11 +325,10 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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 faces and edges of a body as session-scoped opaque references. References become stale after rollback/close; topology edits can invalidate them.", "nx_rename_object": "Rename a referenced object and return its actual NX-normalized display name. Reacquire references afterward.", - "nx_workspace_list": "List files/directories within the configured workspace; sizes and SHA-256 checksums for files. Internal operation storage is excluded.", - "nx_download_file": "Retrieve a workspace artifact as base64 chunks up to 256 KiB, with full-file SHA-256, size and offset. Does not read outside workspace.", + "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": "NX-version-specific integration manifest. API presence, real-test evidence and unavailable capabilities are separate. Batch NX has no model viewport.", + "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 = { @@ -398,7 +412,20 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of } +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))], @@ -438,7 +465,7 @@ def configure(mcp, bridge, workspace): # Resolve postponed annotations in the original callable's module. from typing import get_type_hints - hints = get_type_hints(fn) + hints = get_type_hints(fn, include_extras=True) parameters = [ p.replace(annotation=hints.get(p.name, p.annotation)) for p in sig.parameters.values() ] @@ -487,8 +514,11 @@ async def proxy(**kwargs): ) result = await bridge.call(method, params) response = envelope(result, error=result.get("status") == "error") - if method in {"nx_screenshot", "nx_render_view"} and not response.isError: - file = workspace.ensure_inside(result["path"]) + 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"]: @@ -557,6 +587,8 @@ async def proxy(**kwargs): ), ) tool = mcp._tool_manager.get_tool(name) + tool.fn_metadata.output_model = IntegrationEnvelope + tool.fn_metadata.output_schema = 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() @@ -639,30 +671,86 @@ def metadata(file): } if method == "nx_workspace_list": + 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 sorted(path.iterdir()): - if f.name.casefold() == ".nx-mcp": - continue + 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"} ) - return {"status": "success", "path": str(path), "entries": items, "count": len(items)} + 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", "Invalid chunk offset/length") + 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": p["offset"] + len(data) >= meta["size"], + "eof": next_offset >= meta["size"], } if path.suffix.lower() not in { ".prt", diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index d9ea394..c9a1e04 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -334,7 +334,10 @@ def execute(self, method, params): result = self.executor.execute(method, params) self.completed += 1 part = self.session.Parts.Display - if part: + 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: diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py index 1fe2eba..013ab92 100644 --- a/src/nx_mcp/sheet_metal.py +++ b/src/nx_mcp/sheet_metal.py @@ -94,18 +94,29 @@ def field_schema(field): "minItems": 1, "maxItems": 1000, "uniqueItems": True, - "items": {"type": "string"}, + "items": { + "type": "string", + "description": "Typed work-part " + field.get("objects", "geometry") + " ID", + }, } return { "type": "string", - "description": "Typed work-part object ID; section takes a finished sketch ID.", + "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) for k, v in fields.items()}, + "properties": { + k: { + **field_schema(v), + **({"description": v["description"]} if "description" in v else {}), + } + for k, v in fields.items() + }, "required": list(required), "additionalProperties": False, } @@ -248,7 +259,7 @@ def _sheet_metal_schema(self, operation=None): } for op, spec in CATALOG.items() ], - "units": self._units() if self._work_part(required=False) else None, + "units": None, "unit_conventions": "Lengths use work-part units; expression angles are degrees; neutral factor is unitless", "unavailable": [ { @@ -267,15 +278,21 @@ def _sheet_metal_schema(self, operation=None): "parameters_schema": fields_schema(spec["fields"], spec["required"]), "edit_parameters_schema": fields_schema(spec["fields"]), "native_builder": spec["builder"], - "status": spec["native_status"], + "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_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": self._units() if self._work_part(required=False) else None, + "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.", + ], } def _sm_reference(self, reference, kind): diff --git a/src/nx_mcp/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json index 40636bb..06c26aa 100644 --- a/src/nx_mcp/sheet_metal_catalog.json +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -5381,7 +5381,8 @@ "getter": false, "kind": "collector", "assign": true, - "objects": "face" + "objects": "face", + "description": "Bend face IDs to unbend; inspect nx_sheet_metal_info.bends for the owning sheet-metal body." }, "hide_original_curves": { "path": "HideOriginalCurves", @@ -5392,7 +5393,8 @@ "path": "ReferenceEntity", "getter": false, "kind": "face_or_edge", - "assign": true + "assign": true, + "description": "Work-part face or edge used by NX as the stationary reference for unbending; select it on the same sheet-metal body." } }, "required": [ diff --git a/tests/test_artifact_recovery.py b/tests/test_artifact_recovery.py index aa5be20..1d7b482 100644 --- a/tests/test_artifact_recovery.py +++ b/tests/test_artifact_recovery.py @@ -213,3 +213,44 @@ async def test_folder_tools_are_exposed_and_absolute_part_paths_are_forwarded(tm 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() diff --git a/tests/test_recovery_state.py b/tests/test_recovery_state.py index ac41f99..3c22aa2 100644 --- a/tests/test_recovery_state.py +++ b/tests/test_recovery_state.py @@ -228,3 +228,18 @@ def test_operation_status_preserves_target_receipt_metadata(rig): 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_sheet_metal.py b/tests/test_sheet_metal.py index 6d95c89..450191e 100644 --- a/tests/test_sheet_metal.py +++ b/tests/test_sheet_metal.py @@ -64,7 +64,7 @@ def test_public_signatures_match_executor_and_schema(sm): schema = result["parameters_schema"] assert set(schema["required"]) <= set(schema["properties"]) assert not schema["additionalProperties"] - assert result["status"] == spec["native_status"] + assert result["validation_status"] == spec["native_status"] with pytest.raises(NXToolError, match="Unknown"): sm.e._sheet_metal_schema("__dict__") diff --git a/tests/test_ui_recovery.py b/tests/test_ui_recovery.py index 97bb2c9..c157793 100644 --- a/tests/test_ui_recovery.py +++ b/tests/test_ui_recovery.py @@ -13,7 +13,7 @@ from nx_mcp.bridge import BridgeDescriptor from nx_mcp.interactive import ControlPanel, InteractiveHost from nx_mcp.runtime import NXToolError -from tests.fakes import Body +from tests.fakes import Body, Collection pytestmark = pytest.mark.fake_nx @@ -106,7 +106,14 @@ def test_operation_failure_relocks_ui_and_refresh_failure_is_warning(host, rig): assert host.ui.AskLockStatus() == 1 rig.part.ModelingViews.WorkView.UpdateDisplay.side_effect = RuntimeError("refresh") result = host.execute("nx_list_bodies", {}) - assert any("View refresh" in w for w in result["warnings"]) + 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 From 0023441de6f486adc68935eabbd73e8af5481b09 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 15:55:32 +0200 Subject: [PATCH 39/69] Clarify boolean operands, revolve axis and missing-directory errors --- src/nx_mcp/integration_server.py | 16 ++++++++++++++-- tests/test_artifact_recovery.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 659370b..dfb752f 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -27,6 +27,13 @@ from nx_mcp.workspace import WorkspaceViolation +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]): pass @@ -281,8 +288,8 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of DESCRIPTIONS = { - "nx_boolean": "Boolean supported solid bodies: unite, subtract or intersect. Native cube subtraction and volume checks are scoped in nx_capabilities(tool='nx_boolean'); not general certification.", - "nx_revolve": "Revolve a finished sketch about the specified axis and origin. Angles are degrees, lengths in work-part units; boolean is none/unite/subtract/intersect. Inspect nx_capabilities(tool='nx_revolve') for tested 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; custom axis origins are not exposed. 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.", @@ -671,6 +678,11 @@ def metadata(file): } 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", diff --git a/tests/test_artifact_recovery.py b/tests/test_artifact_recovery.py index 1d7b482..fe0bdb4 100644 --- a/tests/test_artifact_recovery.py +++ b/tests/test_artifact_recovery.py @@ -254,3 +254,23 @@ async def test_inline_image_metadata_paging_and_output_contract(tmp_path): ]: 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"] From 5dcd455b1841b3237126d4ff1f3d8e179d5f16bf Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 16:12:48 +0200 Subject: [PATCH 40/69] docs: record consolidated dev14 native and agent UX acceptance --- docs/dev14-validation.json | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/dev14-validation.json diff --git a/docs/dev14-validation.json b/docs/dev14-validation.json new file mode 100644 index 0000000..c23ec26 --- /dev/null +++ b/docs/dev14-validation.json @@ -0,0 +1,58 @@ +{ + "version": "0.2.0.dev14", + "runtime_commit": "0023441de6f486adc68935eabbd73e8af5481b09", + "release_sha256": "3c52035c91efae376abdba9de91dbe3f4b29e59312d4447c9145793f3fb65fa4", + "nx_version": "v2606", + "bridge_protocol": 1, + "tool_count": 179, + "ci": "https://github.com/xuio/NX_MCP/actions/runs/34037571927", + "build": "https://github.com/xuio/NX_MCP/actions/runs/34037571466", + "automated_tests": 753, + "coverage_percent": 78.89, + "coverage_gate_percent": 78, + "native_suite_commit": "052a9d3c5ce50f0e600d30ce352f92b90a453b66", + "native_modules_unchanged_in_final_package": true, + "native_suites": [ + "hard_geometry", + "release_engineering", + "documentation_manufacturing", + "annotation_recovery", + "freeform_manufacturing", + "sheet_metal" + ], + "recovery_checks": [ + "transport_disconnect_committed_receipt", + "same_id_relative_move_not_repeated", + "checkpoint_rollback", + "manual_handoff_stale_references", + "committed_receipt_survives_real_bridge_restart" + ], + "ux_evaluations": 3, + "ux_improvements": [ + "focused_capabilities", + "consistent_capability_claims", + "no_readonly_view_refresh", + "inline_existing_png", + "targeted_file_metadata", + "paged_filtered_listing", + "chunk_bounds_and_continuation", + "common_structured_output_schema", + "explicit_boolean_operands_and_enums", + "explicit_revolve_origin", + "distinct_missing_directory_error" + ], + "limits": [ + "Tool-specific output fields remain extensible.", + "Advanced native geometry prerequisites are not fully expressible in JSON schemas.", + "STEP marker check detects incomplete framing, not arbitrary STEP syntax/geometry faults.", + "No general modeling or manufacturing certification." + ], + "final_transport_recovery_passed": true, + "final_restart_recovery_passed": true, + "windows_stdio_http_passed": true, + "native_suites_passed": true, + "final_native_smoke_passed": true, + "install_rollback_passed": true, + "original_saved_parts": 38, + "original_occurrences_preserved": 116 +} From 55eebced4f73350afdc2698487f4620e179f86ea Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 16:49:00 +0200 Subject: [PATCH 41/69] Improve agent contracts and add repeatable native release acceptance --- README.md | 2 + docs/capability-matrix.md | 220 ++++++++++++++++ docs/output-contracts.md | 34 +++ docs/releases.md | 59 +++++ docs/sheet-metal.md | 29 +++ pyproject.toml | 2 +- scripts/accept_release.py | 345 ++++++++++++++++++++++++++ scripts/generate_capability_matrix.py | 127 ++++++++++ scripts/validate_output_contracts.py | 74 ++++++ src/nx_mcp/__init__.py | 2 +- src/nx_mcp/integration_server.py | 7 +- src/nx_mcp/output_schemas.py | 270 ++++++++++++++++++++ src/nx_mcp/sheet_metal.py | 4 +- src/nx_mcp/sheet_metal_catalog.json | 110 ++++++-- tests/test_capability_matrix.py | 53 ++++ tests/test_output_schemas.py | 99 ++++++++ tests/test_release_acceptance.py | 180 ++++++++++++++ tests/test_sheet_metal.py | 39 +++ 18 files changed, 1634 insertions(+), 22 deletions(-) create mode 100644 docs/capability-matrix.md create mode 100644 docs/output-contracts.md create mode 100644 scripts/accept_release.py create mode 100644 scripts/generate_capability_matrix.py create mode 100644 scripts/validate_output_contracts.py create mode 100644 src/nx_mcp/output_schemas.py create mode 100644 tests/test_capability_matrix.py create mode 100644 tests/test_output_schemas.py create mode 100644 tests/test_release_acceptance.py diff --git a/README.md b/README.md index 7e081e5..19ec2ca 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # NX MCP Server +See the generated [capability evidence matrix](docs/capability-matrix.md) for manifest-scoped native testing, contract-only testing, experimental tools, and unavailable capabilities. + > **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev13` integration and 179 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. NX MCP is a local Model Context Protocol server for Siemens NX automation. The diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md new file mode 100644 index 0000000..d138889 --- /dev/null +++ b/docs/capability-matrix.md @@ -0,0 +1,220 @@ +# 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-agent-ux-r1**. NX: **v2606**. Bridge protocol: **1**. +Canonical manifest SHA-256: `97293eadb542a5e9303c930710ed037c2cf9cbcb7c6326b984f721caba8ea7d8`. + +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 | 156 | +| Contract/sidecar-tested | 5 | +| Experimental | 18 | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| nx_capabilities | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Saved part closure; NX may unload unused prototypes. Closed-part reporting invalidates all unloaded part 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/authoring-review.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_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 | Experimental | experimental | not recorded | Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending. | +| nx_delete_feature | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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_drawing_view | Native-tested | tested | real_NX_v2606_scoped | Absolute base-view placement and scale with native readback; circular detail boundary refresh. | +| 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/authoring-review.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 PDF plot export with A3 page size, two views and 10mm dimension; file parsed and visually reviewed. | +| 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_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/authoring-review.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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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/authoring-review.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/authoring-review.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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Two-level transforms and STEP round-trip pose equality | +| 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/authoring-review.md for supported operations and limits. | +| nx_list_features | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| nx_list_open_parts | Native-tested | tested | real_NX_v2606 | Loaded names, paths, IDs, work/display status and modified flags | +| nx_list_sections | Native-tested | tested | real_NX_v2606_public_MCP | Native plane enumeration; active view state and saved flags preserved | +| nx_list_sketches | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| nx_list_topology | Native-tested | tested | real_NX_v2606 | Face and edge enumeration; face references used in actual distance query | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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/authoring-review.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/authoring-review.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 | Already-loaded paths reused without close/recreation | +| nx_operation_status | Contract/sidecar-tested | tested | local_contract_test | Durable committed/failed/unknown receipt tests; no crash reconstruction claimed | +| nx_package_assembly | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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/authoring-review.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/authoring-review.md for supported operations and limits. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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/authoring-review.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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| nx_save_part | Native-tested | tested | real_NX_v2606 | Save with documented native mark expiration | +| 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/authoring-review.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/authoring-review.md for supported operations and limits. | +| nx_set_component_transform | Native-tested | tested | real_NX_v2606 | Absolute immediate-child placement; repeated identical pose | +| 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/authoring-review.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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | + +## 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 diff --git a/docs/output-contracts.md b/docs/output-contracts.md new file mode 100644 index 0000000..983f9ee --- /dev/null +++ b/docs/output-contracts.md @@ -0,0 +1,34 @@ +# Integration output contracts + +All integration tools advertise an object `outputSchema`. The envelope requires +`status`, `warnings`, and nullable human-readable `units`. Errors additionally +require `code`, `message`, and `retryable`; error details may identify the operation +and mutation outcome. An error does not satisfy a success payload by returning +empty geometry. Success and error requirements are separate conditional branches. + +Tool-specific success contracts cover bounds, distance, volume, topology, +components, extrusion/revolve/pattern results, pairwise interference and clearance, +operation receipts, checkpoints/rollback, workspace listings, downloads and the +main image/CAD/document exports. Inspect the live `tools/list` output for the +exact fields. Other tools retain extensible success payloads; this is not a claim +that all 179 payloads are fully typed. + +Geometry references keep opaque IDs separate from names and identify owner parts. +Vectors have three coordinates; matrices contain three rows. Measurement fields +state their coordinate frame. `volume_mm3` always uses cubic millimeters and sums +included bodies; it does not represent geometric union. Collision pair +classification distinguishes `clear`, `contact`, `penetration`, and +`below_clearance`. Bounding envelopes are explicitly exact or conservative. + +Download success has one of three shapes: metadata, inline PNG metadata with MCP +image content, or a base64 chunk with `bytes_returned`, `next_offset`, and `eof`. +An absent receipt is `state: unknown`, never proof that a mutation failed. A +committed receipt can carry `reverted_by` after subsequent undo; historical commit +is not proof that the geometry is still present. Reacquire references after +rollback, close, or manual handoff. + +These schemas permit additive metadata. They are advertised for client validation; +the server does not turn an already committed NX mutation into a retryable failure +by running an additional payload-validation step afterward. Native acceptance and +fresh MCP workflow tests check actual response conformance. After transport or +client validation failure, query the original operation ID before retrying. diff --git a/docs/releases.md b/docs/releases.md index a52c953..0ca179d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -30,3 +30,62 @@ For an explicit rollback, stop that bridge/sidecar first and use the backup's sc ``` The rollback scripts restore runtime files, not CAD geometry or unsaved edits. Preserve CAD separately before deployment. Keep backups on the NX host; virtual environments or machine receipts can contain local paths and should not be published in the fork. + +## One-command installed-release acceptance + +After installing the reviewed package and restarting the existing launcher safely, +run this **on the Windows NX host**, with the installed Python 3.12 environment. +Keep the trusted ZIP and obtain its SHA-256/full commit from the reviewed build +receipt. Have a saved original part open, all loaded parts saved, and NX already in +agent mode. Keep exclusive use of this NX session during acceptance. Set the +authorized STEP fixture and a loopback MCP endpoint: + +```powershell +$env:NX_MCP_URL = 'http://127.0.0.1:8765/mcp' +$env:NX_VENDOR_STEP = 'C:\NX-MCP\fixtures\authorized-vendor.step' +C:\NX-MCP\venv\Scripts\python.exe C:\NX-MCP\source\scripts\accept_release.py ` + --release-zip C:\NX-MCP\releases\nx-mcp--windows-py312.zip ` + --sha256 --expected-commit ` + --install-root C:\NX-MCP --output C:\NX-MCP\acceptance\ +``` + +The command records atomic phase receipts in `acceptance.json`: + +1. Verify the ZIP hash, exact package manifest coverage, release commit/metadata, + every installed source file, and importable `nx_mcp` files against the packaged + wheel. Reject a source overlay or extra executable/configuration files. +2. Check Python 3.12, all pinned dependency versions and `pip check`. +3. Inspect the live NX version/tool count and original saved session. Defaults are + NX `v2606` and 179 tools; explicit expected-value options support later releases. +4. Run the existing six native release suites serially, retain their logs and + receipts, and stop at the first failure. The native runner checks its own + preservation evidence. +5. Independently compare open parts, work/display and saved flags, component + source paths and transforms with the preflight snapshot, including on failure. + +`--verify-only` ends with `state:verified` and does not run native suites. The same +command with `--resume` and without `--verify-only` rechecks the package/dependencies +and current saved session before starting native acceptance. Resume requires the +same ZIP/hash, commit, installation, interpreter, fixture hash, endpoint and runtime +expectations. A completed acceptance returns its historical receipt without running +again. An interrupted or failed native phase **cannot be resumed automatically**: +inspect its durable operation/suite receipts and reconcile NX first, then use a new +output directory for an explicitly chosen new acceptance run. Missing evidence is +never permission to replay mutations. + +An installation-wide `acceptance.lock` prevents two acceptance commands from running +together. A killed process leaves that lock; verify its PID and recorded receipt, +and reconcile the session before manually removing it. Other agents, users and +tools do not honor this lock, so exclusive NX access remains an operator prerequisite. + +Acceptance does not install, restart NX, save user work or force session restoration. +The native suites perform model tests in isolated test parts and restore saved user +state; errors remain visible for investigation. Deployment remains the separate +installer workflow above because it requires a stopped bridge and deliberate CAD +preservation. Installed-file hashes do **not** attest bytes already loaded by the +sidecar/NX processes; safe post-install launcher restart is still required, and the +receipt explicitly records `loaded_process_bytes_attested:false`. Dependency versions +are checked, not a byte-for-byte attestation of every third-party dependency. Native +acceptance covers `validate_native_release.py`'s selected suites; transport-disconnect +and real-restart tests remain separate exclusive-session checks. Output-schema +conformance is handled by the dedicated schema validator, not this command. diff --git a/docs/sheet-metal.md b/docs/sheet-metal.md index be2f22f..90401f3 100644 --- a/docs/sheet-metal.md +++ b/docs/sheet-metal.md @@ -54,6 +54,35 @@ 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. + 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 diff --git a/pyproject.toml b/pyproject.toml index d289ee8..abd7f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev14" +version = "0.2.0.dev15" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/accept_release.py b/scripts/accept_release.py new file mode 100644 index 0000000..77e5e87 --- /dev/null +++ b/scripts/accept_release.py @@ -0,0 +1,345 @@ +"""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 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=179) + 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 = args.output.resolve() + args.install_root = args.install_root.resolve() + args.release_zip = args.release_zip.resolve() + fixture = Path(os.environ.get("NX_VENDOR_STEP", "")).resolve() + 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 = Path(spec.origin).parent.resolve() + # 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/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/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 f592af9..71eb44b 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.dev14" +__version__ = "0.2.0.dev15" diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index dfb752f..4a3a37a 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -22,6 +22,7 @@ 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 @@ -249,9 +250,9 @@ def nx_rename_object(object_id: str, name: str): def nx_revolve( + sketch_name: Annotated[str, Field(min_length=1)], angle: float = 360, axis: Literal["X", "Y", "Z", "-X", "-Y", "-Z"] = "Z", - sketch_name: str | None = None, boolean: Literal["none", "unite", "subtract", "intersect"] = "none", ): pass @@ -595,7 +596,9 @@ async def proxy(**kwargs): ) tool = mcp._tool_manager.get_tool(name) tool.fn_metadata.output_model = IntegrationEnvelope - tool.fn_metadata.output_schema = IntegrationEnvelope.model_json_schema() + 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() diff --git a/src/nx_mcp/output_schemas.py b/src/nx_mcp/output_schemas.py new file mode 100644 index 0000000..4b0bec3 --- /dev/null +++ b/src/nx_mcp/output_schemas.py @@ -0,0 +1,270 @@ +"""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"]) + + +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( + { + "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"}}}, + "then": deepcopy(PAYLOADS[name]), + } + ) + return schema diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py index 013ab92..f4a1cef 100644 --- a/src/nx_mcp/sheet_metal.py +++ b/src/nx_mcp/sheet_metal.py @@ -283,6 +283,7 @@ def _sheet_metal_schema(self, operation=None): "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, @@ -292,7 +293,8 @@ def _sheet_metal_schema(self, operation=None): "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): diff --git a/src/nx_mcp/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json index 06c26aa..ee9b61f 100644 --- a/src/nx_mcp/sheet_metal_catalog.json +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -274,7 +274,8 @@ "path": "Keypoint", "getter": false, "kind": "point", - "assign": true + "assign": true, + "description": "Work-part coordinate point for length_option=Keypoint; that mode is not verified by the numeric-length fixture." }, "length": { "path": "Length", @@ -289,7 +290,8 @@ "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", @@ -350,7 +352,8 @@ "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", @@ -454,7 +457,8 @@ "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." } } } @@ -465,7 +469,30 @@ "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" + "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", @@ -4591,7 +4618,8 @@ "getter": false, "kind": "collector", "assign": false, - "objects": "face" + "objects": "face", + "description": "Native face collector. Selection role and combination with ToReference are not established by the recorded numeric-length fixture." }, "flat_pattern_compensation_at_end": { "path": "FlatPatternCompensationAtEnd", @@ -4606,7 +4634,8 @@ "infer_length": { "path": "InferLength", "getter": false, - "kind": "boolean" + "kind": "boolean", + "description": "Native inferred-length toggle; the numeric-length example does not exercise true or establish its required references." }, "inset": { "path": "Inset", @@ -4641,13 +4670,15 @@ "path": "Plane1", "getter": false, "kind": "plane", - "assign": true + "assign": true, + "description": "Native Plane1 input in work-part coordinates; valid combinations with type/infer_length are not established by the recorded fixture." }, "plane2": { "path": "Plane2", "getter": false, "kind": "plane", - "assign": true + "assign": true, + "description": "Native Plane2 input in work-part coordinates; do not infer that both planes are required for every mode." }, "reverse_direction": { "path": "ReverseDirection", @@ -4672,7 +4703,8 @@ "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": [ @@ -4688,7 +4720,17 @@ "length": 20, "angle": 90 }, - "edit_status": "experimental" + "edit_status": "experimental", + "example_evidence": { + "source": "docs/sheet-metal-native-validation.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": [ + "The recorded creation fixture selects the tab boundary edge at y=0, z=0 and supplies length=20, angle=90 in a millimeter part; use this simple geometry to establish a working feature first.", + "The example leaves type, infer_length, faces, plane1 and plane2 at native defaults. It does not verify ToReference or infer_length=true.", + "If choosing ToReference or inferred length, establish the native reference geometry requirements first; the schema exposes the builder fields but does not establish which face/plane combinations form a valid flange. Do not assume plane1 and plane2 are universally required." + ] }, "variational_flange": { "builder": "VariationalFlangeBuilder", @@ -5382,7 +5424,7 @@ "kind": "collector", "assign": true, "objects": "face", - "description": "Bend face IDs to unbend; inspect nx_sheet_metal_info.bends for the owning sheet-metal body." + "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", @@ -5394,7 +5436,7 @@ "getter": false, "kind": "face_or_edge", "assign": true, - "description": "Work-part face or edge used by NX as the stationary reference for unbending; select it on the same sheet-metal body." + "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": [ @@ -5404,7 +5446,23 @@ "native_status": "tested", "tested_on": "v2606", "validation_scope": "Native face collector initialized; actual bend flattened; body consistency and subsequent rebend verified", - "edit_status": "experimental" + "edit_status": "experimental", + "example_parameters": { + "face_collector": [ + "$input_1" + ], + "reference_entity": "$input_2" + }, + "example_evidence": { + "source": "docs/sheet-metal-native-validation.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", @@ -5415,13 +5473,15 @@ "getter": false, "kind": "collector", "assign": true, - "objects": "face" + "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 + "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": [ @@ -5431,6 +5491,22 @@ "native_status": "tested", "tested_on": "v2606", "validation_scope": "Flattened bend reformed about the largest stationary web face; native geometry healthy", - "edit_status": "experimental" + "edit_status": "experimental", + "example_parameters": { + "face_collector": [ + "$input_1" + ], + "reference_entity": "$input_2" + }, + "example_evidence": { + "source": "docs/sheet-metal-native-validation.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/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_output_schemas.py b/tests/test_output_schemas.py new file mode 100644 index 0000000..cb31ac9 --- /dev/null +++ b/tests/test_output_schemas.py @@ -0,0 +1,99 @@ +"""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"] + bad = await client.call_tool("nx_revolve", {}) + assert bad.isError and bad.structuredContent["code"] == "NX_INVALID_ARGUMENT" diff --git a/tests/test_release_acceptance.py b/tests/test_release_acceptance.py new file mode 100644 index 0000000..67b6bf2 --- /dev/null +++ b/tests/test_release_acceptance.py @@ -0,0 +1,180 @@ +"""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" diff --git a/tests/test_sheet_metal.py b/tests/test_sheet_metal.py index 450191e..25ae641 100644 --- a/tests/test_sheet_metal.py +++ b/tests/test_sheet_metal.py @@ -1,6 +1,7 @@ """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 @@ -729,3 +730,41 @@ def test_secondary_tab_rejects_inconsistent_thickness_before_builder(sm): ) 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"] From f44ffb39fa734188af5cc3e05053e783a4db9036 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 16:57:17 +0200 Subject: [PATCH 42/69] Handle Windows shared-drive paths in release acceptance --- scripts/accept_release.py | 21 +++++++++---- tests/test_release_acceptance.py | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/scripts/accept_release.py b/scripts/accept_release.py index 77e5e87..ae46980 100644 --- a/scripts/accept_release.py +++ b/scripts/accept_release.py @@ -30,6 +30,17 @@ 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") @@ -232,10 +243,10 @@ def main(): parser.error( "Run on the NX Windows host with its installed Python 3.12 virtual environment" ) - args.output = args.output.resolve() - args.install_root = args.install_root.resolve() - args.release_zip = args.release_zip.resolve() - fixture = Path(os.environ.get("NX_VENDOR_STEP", "")).resolve() + 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 = { @@ -279,7 +290,7 @@ def main(): def execute(args, receipt, report): def package(): spec = importlib.util.find_spec("nx_mcp") - runtime = Path(spec.origin).parent.resolve() + 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") diff --git a/tests/test_release_acceptance.py b/tests/test_release_acceptance.py index 67b6bf2..80f2dec 100644 --- a/tests/test_release_acceptance.py +++ b/tests/test_release_acceptance.py @@ -178,3 +178,54 @@ def fail_native(*args): 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() From 077f222832369f96b64885c1eabed79e8ba6c60b Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 17:14:44 +0200 Subject: [PATCH 43/69] Apply native agent workflow feedback to lifecycle and selection guidance --- docs/agent-workflows.md | 63 +++++++++++++++++++++++++++++ docs/sheet-metal.md | 7 ++++ src/nx_mcp/hardened.py | 8 +++- src/nx_mcp/integration_server.py | 1 + src/nx_mcp/sheet_metal_catalog.json | 3 +- tests/test_output_schemas.py | 2 + tests/test_recovery_state.py | 5 ++- 7 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 docs/agent-workflows.md diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md new file mode 100644 index 0000000..9127447 --- /dev/null +++ b/docs/agent-workflows.md @@ -0,0 +1,63 @@ +# Agent workflow evaluations + +These are scoped native workflow observations, not general correctness or manufacturing certification. + +## Exploded assembly drawing and BOM + +A public-MCP workflow on NX v2606 created a rectangular solid prototype, added three occurrences, assigned absolute exploded positions, and produced an A3 drawing with an associative native BOM and grouped callout. The BOM aggregated the repeated prototype to quantity 3. The drawing view referenced the named explosion, and assembled occurrence positions remained unchanged. The exported PDF was downloaded, checksum-verified, rendered and visually inspected. All 27 calls succeeded without schema errors, retries or corrective calls; this count includes initial/final session inventories and fixture cleanup. + +An efficient sequence is: + +1. Record the current session with `nx_list_open_parts`. Create a uniquely named disposable prototype using `nx_create_part`, `nx_create_sketch`, `nx_sketch_rectangle`, `nx_finish_sketch`, `nx_extrude` and `nx_save_part`. +2. Create the assembly and add occurrences with `nx_add_component`. Retain returned IDs instead of listing them again. +3. Use `nx_create_explosion` and `nx_edit_explosion` for absolute exploded poses. The edit response includes pose readback and whether assembled placements were preserved; a routine workflow can omit a duplicate `nx_explosion_info` query. +4. Create the sheet with `nx_create_drawing`, then call `nx_add_base_view` with `scope="assembly"` and the explosion ID. Direct attachment avoids a separate `nx_show_explosion` or viewport-fit call. +5. Use `nx_create_parts_list` and `nx_parts_list_balloons`. Inspect the returned evaluated rows; no duplicate BOM read is required. Native grouping may produce one balloon for several identical occurrences. +6. Check sheet containment with `nx_drawing_view_info` and assembled placements with `nx_list_components`. Save, export with `nx_export_drawing_pdf`, and retrieve with `nx_download_file`. Verify bytes/checksum and inspect the rendered PDF. Restore the prior work/display part, close only the disposable parts, and verify the session inventory. + +**Presentation limitation:** the tested occurrences used the native `Entire Part` reference set, which includes datum geometry. Coordinate/datum arrows appeared beside the blocks in the PDF. No explosion trace lines were requested or created; those arrows must not be interpreted as disassembly instructions. The MCP surface currently exposes no reference-set editing control, so this workflow does not prescribe a geometry-only reference-set switch. Inspect the exported PDF before treating it as a manufacturing document; successful BOM aggregation and sheet containment do not establish presentation readiness. + +## Imported-part face editing + +An independent fresh-MCP workflow created a synthetic 20 × 15 × 10 mm solid, +exported it to STEP, imported it into a new part and moved its unique upward-facing +planar top face by 2 mm. Native volume changed from 3,000 to 3,600 mm³ and dimensions +from 20 × 15 × 10 to 20 × 15 × 12 mm. Save/close/reopen preserved both measurements; +new object IDs were acquired after reopen. This validates a simple planar face move, +not arbitrary vendor-model healing or topology changes. + +The workflow used 34 tool calls plus one fresh catalog read, with no errors or +corrective calls. The original saved session and assembly placements were restored. +For a routine workflow, retain the edited body and health information returned by +`nx_edit_faces`; separate `nx_list_bodies` and `nx_model_health` calls duplicated +those results in this evaluation. An explicit seed save immediately before STEP +export was also redundant because export saves the work part. Account for that +side effect when choosing the disposable part boundary. + +Select the face with `nx_find_geometry` using planar type, normal and location, +require an unambiguous match, and use its typed ID in `nx_edit_faces`. Measure native +bounds and volume before/after; reacquire references after reopen. Keep the source +and edited part separate and close only the disposable fixtures during cleanup. + +## Four-wall sheet-metal fixture + +An independent agent created a 100 × 80 × 2 mm web with four shortened 90-degree +walls, leaving deliberate corner gaps. Native health and thickness checks passed. +Unbend and rebend committed successfully; final formed volume returned to the +original value. Intermediate flattened bounds were not independently asserted. +The exported DXF extents, 115.498230 × 135.498230 mm, matched an independent +bend-allowance calculation within 0.000001 mm; the PNG visibly showed four walls. +This is an open tray fixture, not a sealed or production-qualified enclosure. + +The evaluation recorded 49 workflow/recovery calls and four preparation schema +calls. An invalid flat-pattern orientation edge was rejected and its rollback +receipt checked before correction. After forming, use `nx_sheet_metal_info` and +geometry selection to reacquire a current straight boundary/tangent edge of the +stationary web for `x_axis_edge`; the original tab outer-edge location is no longer +reliable. The corrected selection produced a native flat pattern. Two local +response-parsing mistakes were evaluator errors, not NX failures. + +Retain typed references under their actual response fields (`part.id`, for +example), distinguish selected feature bends from duplicate historical information, +and reacquire geometry after rollback. Save/export the disposable fixture, restore +the prior work/display part, close the fixture and compare the original inventory. diff --git a/docs/sheet-metal.md b/docs/sheet-metal.md index 90401f3..94f6072 100644 --- a/docs/sheet-metal.md +++ b/docs/sheet-metal.md @@ -83,6 +83,13 @@ Replace every `$input_N` with a freshly selected typed reference. 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 diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 3d6864c..94bf463 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -595,7 +595,11 @@ def _open_part(self, path, work=True, display=True): result = self._activate_part( self._reference(loaded, "part", loaded, "Part")["id"], work, display ) - result.update(already_loaded=already_loaded, path=str(source)) + 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): @@ -620,7 +624,7 @@ def _activate_part(self, part, work=True, display=True): "part": self._reference(target, "part", target, "Part"), "work": self.session.Parts.Work == target, "display": self.session.Parts.Display == target, - "message": "Activated loaded part", + "message": "Activated loaded part" if work or display else "Resolved loaded part", } def _save_part(self): diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 4a3a37a..18e293c 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -289,6 +289,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of 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; custom axis origins are not exposed. 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.", diff --git a/src/nx_mcp/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json index ee9b61f..02502e8 100644 --- a/src/nx_mcp/sheet_metal_catalog.json +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -4475,7 +4475,8 @@ "path": "XAxisEdge", "getter": false, "kind": "select_edge", - "assign": false + "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": [ diff --git a/tests/test_output_schemas.py b/tests/test_output_schemas.py index cb31ac9..d85e02e 100644 --- a/tests/test_output_schemas.py +++ b/tests/test_output_schemas.py @@ -95,5 +95,7 @@ async def test_fresh_mcp_client_accepts_artifacts_and_structured_errors(tmp_path 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" diff --git a/tests/test_recovery_state.py b/tests/test_recovery_state.py index 3c22aa2..88857d6 100644 --- a/tests/test_recovery_state.py +++ b/tests/test_recovery_state.py @@ -134,6 +134,7 @@ def test_session_lifecycle_and_generation_reject_closed_references(rig, tmp_path 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) @@ -145,7 +146,9 @@ def test_session_lifecycle_and_generation_reject_closed_references(rig, tmp_path assert rig.ref(body) != old path = tmp_path / "imported.prt" path.write_text("fixture") - assert not rig.e._open_part(str(path), work=False, display=False)["already_loaded"] + 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")) From 1b8a181ff814eff5e9217d993ca50074a0df6012 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 17:37:43 +0200 Subject: [PATCH 44/69] Record final dev15 native acceptance and independent workflow evidence --- docs/agent-workflows.md | 20 +++++++++++++ docs/dev15-validation.json | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 docs/dev15-validation.json diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 9127447..dca287f 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -61,3 +61,23 @@ Retain typed references under their actual response fields (`part.id`, for example), distinguish selected feature bends from duplicate historical information, and reacquire geometry after rollback. Save/export the disposable fixture, restore the prior work/display part, close the fixture and compare the original inventory. + +## Principal-axis revolve recipe + +The final dev15 runtime independently verified this millimeter fixture: + +1. Create an XY sketch and a rectangle with local corners `[1,0]` and `[3,5]`. +2. Finish the sketch and retain its typed ID. +3. Call `nx_revolve` with `sketch_name` equal to that ID, `axis="Y"`, `angle=360`, + and `boolean="none"`. The axis passes through the part origin; there is no + arbitrary-origin argument. `sketch_name` is required in the published schema. +4. Measure the resulting annular cylinder. Expected volume is + `π × (3² − 1²) × 5 = 125.66370614359172 mm³`; native measurement was + `125.66370614359175 mm³`. Exact bounds were `[-3,0,-3]` to `[3,5,3]`. +5. Save, close and reopen the disposable part, then measure again. The volume + persisted. A new load reports `already_loaded=false` and `Opened part`; + another open of the same loaded file reports `already_loaded=true` and + `Reused loaded part`. Inspect returned work/display flags for activation state. + +The test restored the original saved session and captured a native PNG. It covers +this finished XY profile and principal Y axis, not arbitrary custom-axis geometry. diff --git a/docs/dev15-validation.json b/docs/dev15-validation.json new file mode 100644 index 0000000..dfcf70c --- /dev/null +++ b/docs/dev15-validation.json @@ -0,0 +1,61 @@ +{ + "version": "0.2.0.dev15", + "runtime_commit": "077f222832369f96b64885c1eabed79e8ba6c60b", + "package_sha256": "cdd0547358be63acaa48567eb74d1fa90ff7412c4831bf555324ab12f548874a", + "nx_version": "v2606", + "bridge_protocol": 1, + "tools": 179, + "typed_success_payloads": 22, + "automated_tests_passed": 806, + "native_only_test_skipped": 1, + "coverage_percent": 79.0, + "coverage_gate_percent": 78, + "build": "https://github.com/xuio/NX_MCP/actions/runs/34041696528", + "ci": "https://github.com/xuio/NX_MCP/actions/runs/34041696775", + "acceptance_command": "scripts/accept_release.py", + "verify_only_then_resume_native_passed": true, + "native_suites_passed": 6, + "schema_live_checks_passed": true, + "analytic_revolve_and_reopen_passed": true, + "windows_stdio_http_passed": true, + "original_saved_parts": 38, + "original_component_records_preserved": 116, + "agent_workflows": { + "exploded_drawing_bom": { + "calls": 27, + "tool_errors": 0, + "evaluated_runtime": "55eebce" + }, + "imported_face_edit": { + "calls": 34, + "tool_errors": 0, + "redundant_calls": 3, + "evaluated_runtime": "f44ffb3" + }, + "sheet_metal_tray": { + "workflow_calls": 49, + "preparation_calls": 4, + "native_selection_errors": 1, + "confirmed_rollback_and_corrected_selection": true, + "evaluated_runtime": "f44ffb3" + } + }, + "fixes": [ + "tool_specific_geometry_measurement_artifact_recovery_schemas", + "typed_error_branch", + "required_revolve_sketch", + "sheet_metal_recipes_and_flat_orientation_guidance", + "STEP_autosave_checkpoint_description", + "accurate_open_reuse_message", + "shared_drive_acceptance_paths", + "repeatable_acceptance_receipts_lock_and_safe_resume", + "generated_evidence_matrix" + ], + "limits": [ + "Other tool-specific success payloads remain extensible.", + "Advanced flange reference/plane mode combinations still require further native evidence.", + "Entire Part drawings can include datum arrows; reference-set editing is not exposed.", + "Native acceptance covers selected fixtures, not general manufacturing certification.", + "Installed hashes do not attest bytes already loaded by a process; final deployment included a controlled restart." + ] +} From 2b56802fe5254ca5450831e4d95ecfce7256fcfd Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 18:02:30 +0200 Subject: [PATCH 45/69] Add reference geometry controls and compact typed NX inspections --- docs/capability-matrix.md | 12 +- pyproject.toml | 2 +- scripts/accept_release.py | 2 +- scripts/validate_native_release.py | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/capability_manifest.json | 26 ++- src/nx_mcp/hardened.py | 144 +++++++++++++---- src/nx_mcp/integration_server.py | 55 ++++++- src/nx_mcp/inventory.py | 30 ++++ src/nx_mcp/output_schemas.py | 174 +++++++++++++++++++++ src/nx_mcp/reference_geometry.py | 174 +++++++++++++++++++++ src/nx_mcp/runtime.py | 2 + src/nx_mcp/sheet_metal.py | 10 ++ src/nx_mcp/sheet_metal_catalog.json | 14 +- src/nx_mcp/visual_tools.py | 13 +- tests/test_inventory_reference_geometry.py | 61 ++++++++ tests/test_visual_tools.py | 2 +- 17 files changed, 675 insertions(+), 50 deletions(-) create mode 100644 src/nx_mcp/inventory.py create mode 100644 src/nx_mcp/reference_geometry.py create mode 100644 tests/test_inventory_reference_geometry.py diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index d138889..1e7074a 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -3,8 +3,8 @@ 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-agent-ux-r1**. NX: **v2606**. Bridge protocol: **1**. -Canonical manifest SHA-256: `97293eadb542a5e9303c930710ed037c2cf9cbcb7c6326b984f721caba8ea7d8`. +Manifest revision: **2606-agent-ux-r2**. NX: **v2606**. Bridge protocol: **1**. +Canonical manifest SHA-256: `07abdaf599090aa2c3fff5ea06cceb0da18ba54e45e8553d8d0d28206fca3424`. 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. @@ -12,7 +12,7 @@ These labels report manifest evidence, not certification or independent verifica | --- | ---: | | Native-tested | 156 | | Contract/sidecar-tested | 5 | -| Experimental | 18 | +| Experimental | 24 | | Unavailable | 0 | ## Tools @@ -53,6 +53,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | | 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 | Experimental | experimental | not recorded | Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending. | @@ -84,6 +85,7 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| nx_flat_pattern_orientation_edges | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | | 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 | @@ -97,12 +99,14 @@ These labels report manifest evidence, not certification or independent verifica | nx_list_bodies | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | 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 | Two-level transforms and STEP round-trip pose equality | +| nx_list_datums | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | | 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/authoring-review.md for supported operations and limits. | | nx_list_features | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | nx_list_open_parts | Native-tested | tested | real_NX_v2606 | Loaded names, paths, IDs, work/display status and modified flags | +| nx_list_reference_sets | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | | nx_list_sections | Native-tested | tested | real_NX_v2606_public_MCP | Native plane enumeration; active view state and saved flags preserved | | nx_list_sketches | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | nx_list_topology | Native-tested | tested | real_NX_v2606 | Face and edge enumeration; face references used in actual distance query | @@ -147,7 +151,9 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| nx_set_component_reference_set | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | | nx_set_component_transform | Native-tested | tested | real_NX_v2606 | Absolute immediate-child placement; repeated identical pose | +| nx_set_datum_visibility | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | | 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/authoring-review.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. | diff --git a/pyproject.toml b/pyproject.toml index abd7f0a..5634d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev15" +version = "0.2.0.dev16" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/accept_release.py b/scripts/accept_release.py index ae46980..6755b3d 100644 --- a/scripts/accept_release.py +++ b/scripts/accept_release.py @@ -224,7 +224,7 @@ def main(): 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=179) + parser.add_argument("--expected-tool-count", type=int, default=185) parser.add_argument("--expected-nx-version", default="v2606") parser.add_argument("--verify-only", action="store_true") parser.add_argument("--resume", action="store_true") diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py index 95141f2..bb30cce 100644 --- a/scripts/validate_native_release.py +++ b/scripts/validate_native_release.py @@ -116,7 +116,7 @@ def stable(value): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--expected-tool-count", type=int, default=179) + parser.add_argument("--expected-tool-count", type=int, default=185) args = parser.parse_args() if args.output.exists(): raise RuntimeError( diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 71eb44b..5c81b06 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.dev15" +__version__ = "0.2.0.dev16" diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 7c134c1..a4ba608 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-agent-ux-r1", + "revision": "2606-agent-ux-r2", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -896,6 +896,30 @@ "status": "tested", "evidence_type": "local_contract_tests", "scope": "Workspace-scoped directory creation and idempotent existing-directory reporting." + }, + "nx_flat_pattern_orientation_edges": { + "status": "experimental", + "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + }, + "nx_list_reference_sets": { + "status": "experimental", + "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + }, + "nx_create_reference_set": { + "status": "experimental", + "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + }, + "nx_set_component_reference_set": { + "status": "experimental", + "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + }, + "nx_list_datums": { + "status": "experimental", + "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + }, + "nx_set_datum_visibility": { + "status": "experimental", + "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." } }, "limitations": [ diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 94bf463..899f675 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -28,9 +28,11 @@ 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.manufacturing import ManufacturingMixin from nx_mcp.nx_bridge import NXOpenExecutor 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 @@ -39,6 +41,9 @@ 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", @@ -147,6 +152,7 @@ def add(a, b): class HardenedExecutor( ReleaseEngineeringMixin, + ReferenceGeometryMixin, DocumentationEditingMixin, AnnotationUpdatesMixin, ThreadStandardsMixin, @@ -182,6 +188,12 @@ def __init__(self, *args, **kwargs): 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, @@ -465,6 +477,9 @@ def handler(**p): "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", @@ -700,20 +715,40 @@ def _close_part(self, save=True, part=None): "warnings": ["Re-list open parts before closing another assembly dependency."], } - def _list_open_parts(self): - return { - "parts": [ + 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": self._reference(p, "part", p, "Part"), + "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), } - for p in self.session.Parts - ] - } + ) + selected, metadata = page(rows, offset, limit) + return {"parts": selected, **metadata} def _sketch_frame(self, sketch): m = sketch.Orientation.Element @@ -1012,42 +1047,91 @@ def walk(parent, path): return list(walk(root, [])) if root else [] - def _list_components(self): + def _list_components( + self, + compact=False, + include_transforms=True, + name_contains=None, + suppressed=None, + offset=0, + limit=None, + ): + 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 self._walk_components(part): - p, m = c.GetPosition() + for c, path in selected: ref = self._reference(c, "component", part, "Component") ref["occurrence_path"] = path - result.append( - { - "object": ref, - "name": c.Name, - "part_path": c.Prototype.FullPath, - "depth": len(path) - 1, - "translation": xyz(p), - "rotation_matrix": rows(m), - "coordinate_frame": "assembly", - "rotation": [m.Xx, m.Xy, m.Xz, m.Yx, m.Yy, m.Yz, m.Zx, m.Zy, m.Zz], - "suppressed": bool(c.IsSuppressed), - "reference_set": c.ReferenceSet, - } - ) + row = { + "object": compact_reference(ref) if compact else ref, + "name": c.Name, + "part_path": c.Prototype.FullPath, + "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, - "count": len(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): + def _list_topology(self, body, face=None, include_adjacency=False, compact=False): b = self._resolve(body, {"body"}) part = self._work_part() - return { - "body": self._reference(b, "body", part, "Body"), - "faces": [self._reference(f, "face", part, "Face") for f in b.GetFaces()], - "edges": [self._reference(e, "edge", part, "Edge") for e in b.GetEdges()], + 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: diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 18e293c..f29670a 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -28,6 +28,52 @@ 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 is case-insensitive with either slash style. 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 loaded component occurrences. 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)], @@ -210,7 +256,9 @@ def nx_cancel_operation(operation_id: str): pass -def nx_list_topology(body: str): +def nx_list_topology( + body: str, face: str | None = None, include_adjacency: bool = False, compact: bool = False +): pass @@ -332,7 +380,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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 faces and edges of a body as session-scoped opaque references. References become stale after rollback/close; topology edits can invalidate them.", + "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.", @@ -341,6 +389,9 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of } READ_ONLY = { + "nx_flat_pattern_orientation_edges", + "nx_list_reference_sets", + "nx_list_datums", "nx_display_info", "nx_list_sections", "nx_sketch_diagnostics", 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/output_schemas.py b/src/nx_mcp/output_schemas.py index 4b0bec3..2327699 100644 --- a/src/nx_mcp/output_schemas.py +++ b/src/nx_mcp/output_schemas.py @@ -234,6 +234,180 @@ def obj(properties: dict, required: list[str] | None = None) -> dict: 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}, []) +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"]}), + "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["required"] = [ + x + for x in component_fields["required"] + if x not in {"translation", "rotation_matrix", "coordinate_frame"} +] +PAYLOADS["nx_list_components"]["properties"].update(PAGE) + + def output_schema(name: str, common: dict) -> dict: """Keep an object root for MCP; discriminate errors before success payloads.""" schema = deepcopy(common) 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/runtime.py b/src/nx_mcp/runtime.py index f5da7fb..d2fb766 100644 --- a/src/nx_mcp/runtime.py +++ b/src/nx_mcp/runtime.py @@ -6,6 +6,8 @@ from typing import Any, Literal ObjectKind = Literal[ + "reference_set", + "datum", "part", "sketch", "curve", diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py index f4a1cef..9875e46 100644 --- a/src/nx_mcp/sheet_metal.py +++ b/src/nx_mcp/sheet_metal.py @@ -638,6 +638,16 @@ def _sheet_metal_feature(self, operation, parameters, feature=None): 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() diff --git a/src/nx_mcp/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json index 02502e8..c4b2a60 100644 --- a/src/nx_mcp/sheet_metal_catalog.json +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -4620,7 +4620,7 @@ "kind": "collector", "assign": false, "objects": "face", - "description": "Native face collector. Selection role and combination with ToReference are not established by the recorded numeric-length fixture." + "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", @@ -4636,7 +4636,7 @@ "path": "InferLength", "getter": false, "kind": "boolean", - "description": "Native inferred-length toggle; the numeric-length example does not exercise true or establish its required references." + "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", @@ -4672,14 +4672,14 @@ "getter": false, "kind": "plane", "assign": true, - "description": "Native Plane1 input in work-part coordinates; valid combinations with type/infer_length are not established by the recorded fixture." + "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": "Native Plane2 input in work-part coordinates; do not infer that both planes are required for every mode." + "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", @@ -4728,9 +4728,9 @@ "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": [ - "The recorded creation fixture selects the tab boundary edge at y=0, z=0 and supplies length=20, angle=90 in a millimeter part; use this simple geometry to establish a working feature first.", - "The example leaves type, infer_length, faces, plane1 and plane2 at native defaults. It does not verify ToReference or infer_length=true.", - "If choosing ToReference or inferred length, establish the native reference geometry requirements first; the schema exposes the builder fields but does not establish which face/plane combinations form a valid flange. Do not assume plane1 and plane2 are universally required." + "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": { diff --git a/src/nx_mcp/visual_tools.py b/src/nx_mcp/visual_tools.py index 0276a00..1571953 100644 --- a/src/nx_mcp/visual_tools.py +++ b/src/nx_mcp/visual_tools.py @@ -40,7 +40,7 @@ def _display_targets(self, objects, expand=False): values = [] for ref in objects: obj = self._resolve( - ref, {"body", "face", "edge", "curve", "sketch", "component", "feature"} + ref, {"body", "face", "edge", "curve", "sketch", "component", "feature", "datum"} ) if isinstance(obj, self.nxopen.Features.Feature) or ( expand and hasattr(obj, "FindOccurrence") @@ -57,7 +57,16 @@ def _display_targets(self, objects, expand=False): def _display_ref(self, obj): kind = ( - "component" + "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) 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_visual_tools.py b/tests/test_visual_tools.py index dee8d47..6595033 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 179 + assert len(tools) == 185 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From e666c8e53fb29f6b16184e97493b8ee56bfc003a Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 18:17:14 +0200 Subject: [PATCH 46/69] Publish verified NX inspection guidance and native UX acceptance fixtures --- docs/agent-workflows.md | 38 +++ docs/capability-matrix.md | 22 +- scripts/validate_agent_ux.py | 382 ++++++++++++++++++++++++++++ src/nx_mcp/capability_manifest.json | 34 ++- src/nx_mcp/integration_server.py | 8 +- src/nx_mcp/output_schemas.py | 116 ++++++++- tests/test_output_schemas.py | 22 ++ 7 files changed, 594 insertions(+), 28 deletions(-) create mode 100644 scripts/validate_agent_ux.py diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index dca287f..82fa23d 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -81,3 +81,41 @@ The final dev15 runtime independently verified this millimeter fixture: The test restored the original saved session and captured a native PNG. It covers this finished XY profile and principal Y axis, not arbitrary custom-axis geometry. + +## Drawing reference geometry and compact inspection (dev16) + +For a clean assembly drawing, create a custom reference set in each prototype with +`nx_create_reference_set(name="SOLIDS", objects=[body_id, ...])`, save the prototype, +and assign it to direct occurrences with `nx_set_component_reference_set` in their +owning assembly. The assignment leaves component poses unchanged. Nested children +require activating their owning assembly. Hide the assembly's own datum geometry +with `nx_set_datum_visibility(visible=false)` before creating the drawing view. +Prototype reference sets alone do not hide assembly-owned coordinate systems. +`nx_list_datums` inspects the affected owned objects; `nx_restore_display` restores +the returned snapshot in reverse order. Existing drafting views may require an +explicit view update; `nx_edit_drawing_view` with its current position updates it. + +Use `nx_list_topology(body=..., face=..., include_adjacency=true)` for current face +boundaries and bidirectional edge adjacency. For flat patterns, +`nx_flat_pattern_orientation_edges(upward_face=...)` returns straight boundary +candidates with endpoints. Choose the desired axis from those endpoints and pass +its opaque ID as `x_axis_edge`; reacquire after geometry edits or rollback. This is +geometric eligibility, not a promise that every candidate will pass the native +Flat Pattern commit on every formed body. + +For inventory use `nx_list_open_parts(compact=true)` and +`nx_list_components(compact=true, include_transforms=false)`. Full responses remain +the default. Compact references retain identity and occurrence paths. Filters +precede pagination; `count` counts returned rows and `total_count` counts matching +rows. `path_prefix` uses absolute Windows host paths, with either slash style. +Request transforms when checking placement, rather than inferring them from an +inventory without pose fields. + +Advanced-flange end planes were exercised on a 100×80×2 mm tab, 20 mm flange and +90° angle. A +X plane at x=10 trims one end; another at x=90 trims the other. Their +volumes were 19242.97335529231 and 18829.309649148734 mm³, versus +19656.637061435922 mm³ without trimming. `infer_length=true` in ByValue mode +produced the same geometry as the numeric-length baseline and does not establish +inference behavior. ToReference requires reference faces on the same body; +tested web/formed-wall combinations still failed native geometric construction. +No successful ToReference recipe is claimed. Failures restored baseline volume. diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 1e7074a..6069ef4 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -4,15 +4,15 @@ Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by Run `python scripts/generate_capability_matrix.py` to regenerate, or add `--check` to detect drift. Manifest revision: **2606-agent-ux-r2**. NX: **v2606**. Bridge protocol: **1**. -Canonical manifest SHA-256: `07abdaf599090aa2c3fff5ea06cceb0da18ba54e45e8553d8d0d28206fca3424`. +Canonical manifest SHA-256: `63274526e0132077e6920f0c690d9004ab51295c3ad83ae4e04ebff85a019f1a`. 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 | 156 | +| Native-tested | 162 | | Contract/sidecar-tested | 5 | -| Experimental | 24 | +| Experimental | 18 | | Unavailable | 0 | ## Tools @@ -53,7 +53,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | +| 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 | Experimental | experimental | not recorded | Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending. | @@ -85,7 +85,7 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | -| nx_flat_pattern_orientation_edges | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | +| 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 | @@ -98,18 +98,18 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | 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 | Two-level transforms and STEP round-trip pose equality | -| nx_list_datums | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | +| nx_list_components | Native-tested | tested | real_NX_v2606 | Two-level transforms and STEP round-trip pose equality Compact inventory, no-pose projection and pagination checked against 116 occurrences. | +| 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/authoring-review.md for supported operations and limits. | | nx_list_features | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | nx_list_open_parts | Native-tested | tested | real_NX_v2606 | Loaded names, paths, IDs, work/display status and modified flags | -| nx_list_reference_sets | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | -| nx_list_topology | Native-tested | tested | real_NX_v2606 | Face and edge enumeration; face references used in actual distance query | +| 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. | @@ -151,9 +151,9 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | -| nx_set_component_reference_set | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | +| 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 | Experimental | experimental | not recorded | NX v2606 installed API signatures inspected; native fixture validation pending. | +| 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/authoring-review.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. | diff --git a/scripts/validate_agent_ux.py b/scripts/validate_agent_ux.py new file mode 100644 index 0000000..d470021 --- /dev/null +++ b/scripts/validate_agent_ux.py @@ -0,0 +1,382 @@ +"""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 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 = {} + + async def call(self, name, **params): + if "operation_id" in self.schemas[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": name, "params": params}) + "\n") + result = await self.c.call_tool(name, params) + with (self.out / "operations.jsonl").open("a") as f: + f.write( + json.dumps( + { + "state": "response", + "tool": name, + "result": result.structuredContent, + "error": result.isError, + } + ) + + "\n" + ) + if result.isError: + raise RuntimeError((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) + client.schemas = {t.name: t.inputSchema for t in (await session.list_tools()).tools} + await main(client) + + +async def main(c): + tools = {t.name: t for t in (await c.c.list_tools()).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/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index a4ba608..ba83113 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -216,7 +216,7 @@ "nx_list_topology": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Face and edge enumeration; face references used in actual distance query" + "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", @@ -306,7 +306,7 @@ "nx_list_components": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Two-level transforms and STEP round-trip pose equality" + "scope": "Two-level transforms and STEP round-trip pose equality Compact inventory, no-pose projection and pagination checked against 116 occurrences." }, "nx_create_drawing": { "status": "tested", @@ -898,28 +898,34 @@ "scope": "Workspace-scoped directory creation and idempotent existing-directory reporting." }, "nx_flat_pattern_orientation_edges": { - "status": "experimental", - "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + "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": "experimental", - "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native body-only custom set enumeration and exact member count." }, "nx_create_reference_set": { - "status": "experimental", - "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + "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": "experimental", - "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + "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": "experimental", - "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + "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": "experimental", - "scope": "NX v2606 installed API signatures inspected; native fixture validation pending." + "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." } }, "limitations": [ diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index f29670a..bf60aba 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -36,7 +36,7 @@ def nx_list_open_parts( offset: int = 0, limit: int | None = None, ): - """List loaded parts. Defaults preserve full inventory. compact retains opaque identity; path_prefix is case-insensitive with either slash style. active_only selects work/display parts. Filters precede paging; count is returned rows, total_count is matching rows. Read-only; never saves parts.""" + """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( @@ -622,7 +622,11 @@ async def proxy(**kwargs): if old: mcp.remove_tool(name) - description = DESCRIPTIONS.get(name, (old.description if old else 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 = ( diff --git a/src/nx_mcp/output_schemas.py b/src/nx_mcp/output_schemas.py index 2327699..491f628 100644 --- a/src/nx_mcp/output_schemas.py +++ b/src/nx_mcp/output_schemas.py @@ -242,7 +242,20 @@ def obj(properties: dict, required: list[str] | None = None) -> dict: "offset": COUNT, "next_offset": {"anyOf": [COUNT, NULL]}, } -EXPRESSION = obj({"object": REF}, []) +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}), @@ -408,6 +421,107 @@ def obj(properties: dict, required: list[str] | None = None) -> dict: 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"]) + + def output_schema(name: str, common: dict) -> dict: """Keep an object root for MCP; discriminate errors before success payloads.""" schema = deepcopy(common) diff --git a/tests/test_output_schemas.py b/tests/test_output_schemas.py index d85e02e..8f3c683 100644 --- a/tests/test_output_schemas.py +++ b/tests/test_output_schemas.py @@ -99,3 +99,25 @@ async def test_fresh_mcp_client_accepts_artifacts_and_structured_errors(tmp_path 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 + ) From e3d4c0ae6c28473ada66b938cfe359ae3b3a0e3a Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 18:25:50 +0200 Subject: [PATCH 47/69] Allow named NX arguments in the native UX test client --- scripts/validate_agent_ux.py | 12 ++++++------ tests/test_native_release_runner.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/scripts/validate_agent_ux.py b/scripts/validate_agent_ux.py index d470021..aba2382 100644 --- a/scripts/validate_agent_ux.py +++ b/scripts/validate_agent_ux.py @@ -22,18 +22,18 @@ def __init__(self, session, output): self.out.mkdir(parents=True, exist_ok=False) self.schemas = {} - async def call(self, name, **params): - if "operation_id" in self.schemas[name].get("properties", {}): + 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": name, "params": params}) + "\n") - result = await self.c.call_tool(name, params) + f.write(json.dumps({"state": "submitted", "tool": tool_name, "params": params}) + "\n") + result = await self.c.call_tool(tool_name, params) with (self.out / "operations.jsonl").open("a") as f: f.write( json.dumps( { "state": "response", - "tool": name, + "tool": tool_name, "result": result.structuredContent, "error": result.isError, } @@ -41,7 +41,7 @@ async def call(self, name, **params): + "\n" ) if result.isError: - raise RuntimeError((name, result.structuredContent)) + raise RuntimeError((tool_name, result.structuredContent)) return result.structuredContent async def artifact(self, meta, name): diff --git a/tests/test_native_release_runner.py b/tests/test_native_release_runner.py index 1527378..0462ce8 100644 --- a/tests/test_native_release_runner.py +++ b/tests/test_native_release_runner.py @@ -32,3 +32,24 @@ def test_receipt_manifest_hashes_nested_artifacts(tmp_path): 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"]} + ) From d68441ad8ae33263043ee877825ed8128171fde4 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 18:43:58 +0200 Subject: [PATCH 48/69] Pass the selected tool profile to native acceptance examples --- README.md | 2 +- examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 4 +++- examples/validate_freeform_manufacturing.py | 4 +++- examples/validate_project_folders.py | 4 +++- examples/validate_sheet_metal.py | 4 +++- examples/validate_visual_tools.py | 2 +- scripts/validate_native_release.py | 6 +++++- tests/test_native_release_runner.py | 17 +++++++++++++++++ 10 files changed, 38 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 19ec2ca..a2905cb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ See the generated [capability evidence matrix](docs/capability-matrix.md) for manifest-scoped native testing, contract-only testing, experimental tools, and unavailable capabilities. -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev13` integration and 179 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. +> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev16` integration and 185 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. 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 diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index 05e338b..ea85107 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 179 + assert len(tools) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "185")) assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 4d4aaca..19dd2a8 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): await client.initialize() tools = {t.name: t for t in (await client.list_tools()).tools} - assert len(tools) == 179 + assert len(tools) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "185")) assert tools["nx_model_health"].annotations.readOnlyHint assert not tools["nx_preview_change"].annotations.readOnlyHint diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index 40cc73b..8939b96 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -108,7 +108,9 @@ async def assembly(name): 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) == 179 + assert len((await client.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "185") + ) async def limits(): await new("offset") diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py index d34d680..445d14f 100644 --- a/examples/validate_freeform_manufacturing.py +++ b/examples/validate_freeform_manufacturing.py @@ -137,7 +137,9 @@ async def checked(name): 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) == 179 + assert len((await client.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "185") + ) await new("spline") token = "spline-" + uuid.uuid4().hex params = { diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py index 51c5165..596ef6f 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -41,7 +41,9 @@ async def rejected(name, **p): checks = [] prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: - assert len((await c.list_tools()).tools) == 179 + assert len((await c.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "185") + ) info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py index c9f9d58..fc93a89 100644 --- a/examples/validate_sheet_metal.py +++ b/examples/validate_sheet_metal.py @@ -90,7 +90,9 @@ async def volume(body): 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) == 179 + assert len((await client.list_tools()).tools) == int( + os.environ.get("NX_EXPECTED_TOOL_COUNT", "185") + ) catalog = await call("nx_sheet_metal_schema") assert len(catalog["operations"]) == 34 await call("nx_create_part", path=prefix + "/bracket.prt", units="mm") diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 8dc8c7f..5b5a8e1 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): async def schema(): names = {x.name for x in (await client.list_tools()).tools} - assert len(names) == 179, len(names) + assert len(names) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "185")), len(names) return await call("nx_status") await test("schemas_and_visible_ui", schema) diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py index bb30cce..a804aab 100644 --- a/scripts/validate_native_release.py +++ b/scripts/validate_native_release.py @@ -148,7 +148,11 @@ def main(): with log.open("w") as stream: result = subprocess.run( [sys.executable, str(source / "examples" / script)], - env={**os.environ, "NX_VALIDATION_OUTPUT": str(output.absolute())}, + 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, diff --git a/tests/test_native_release_runner.py b/tests/test_native_release_runner.py index 0462ce8..b8fab7a 100644 --- a/tests/test_native_release_runner.py +++ b/tests/test_native_release_runner.py @@ -53,3 +53,20 @@ async def test_agent_ux_client_forwards_tool_name_arguments(tmp_path): 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", "185")' in source + assert "== 179" not in source From c64d67edfa9b910629faf4ca71f79551b4972ac5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 18:55:06 +0200 Subject: [PATCH 49/69] Record dev16 deployment and reconciled native validation evidence --- docs/dev16-validation.json | 99 ++++++++++++++++++++++++++++++++++++++ docs/fork-status.md | 6 +-- docs/output-contracts.md | 4 +- docs/releases.md | 2 +- 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 docs/dev16-validation.json diff --git a/docs/dev16-validation.json b/docs/dev16-validation.json new file mode 100644 index 0000000..8d1d80b --- /dev/null +++ b/docs/dev16-validation.json @@ -0,0 +1,99 @@ +{ + "version": "0.2.0.dev16", + "deployed_commit": "d68441ad8ae33263043ee877825ed8128171fde4", + "zip_sha256": "3b22f2947b8c65cc73698d56a0581235d4d0867f5e9fa2a4e14ba2c979bca828", + "fork": "https://github.com/xuio/NX_MCP", + "build_run": 34046387111, + "ci_run": 34046387266, + "tools": 185, + "typed_success_payloads": 47, + "automated_tests_passed": 846, + "native_only_test_skipped_locally": 1, + "coverage_percent": 78.5, + "native_release_acceptance": "six_suites_passed_across_reconciled_runs", + "native_suite_count": 6, + "native_execution": "Four passing suites retained; failed freeform gate and unrun sheet-metal suite completed with corrected profile count. Original session restored after every run.", + "retained_harness_failure": "Freeform stopped before geometry because its obsolete tool-count assertion expected 179 instead of 185. The failed receipt is retained, not relabeled as passed.", + "native_agent_workflows": [ + "body-only reference sets assigned without pose changes; exploded drawing exported", + "flat pattern committed first attempt using discovered face-boundary edge", + "imported-face edit 3000 to 3600 mm3 persisted through reopen" + ], + "agent_workflows_runtime_provenance": "Agent workflows ran against e666c8e. All 58 NX runtime source files match the final deployed release byte-for-byte. Four native suites passed on e3d4c0a; two completed with corrected profile assertions against the same runtime.", + "session_preserved": { + "parts": 38, + "occurrences": 116 + }, + "compact_inventory_reduction_percent": { + "parts": 40.5, + "components_without_transforms": 53.9 + }, + "baseline_workflows": { + "exploded": { + "calls": 27, + "json_characters": 126993 + }, + "imported": { + "calls": 34, + "json_characters": 359158 + }, + "sheet_metal": { + "calls": 49, + "json_characters": 1049413 + } + }, + "current_workflows": { + "inventory": { + "calls": 5, + "json_characters": 186331 + }, + "exploded": { + "calls": 31, + "json_characters": 116220 + }, + "sheet_metal": { + "calls": 29, + "json_characters": 328673 + }, + "imported": { + "calls": 17, + "json_characters": 32799 + }, + "cleanup": { + "calls": 14, + "json_characters": 298349 + } + }, + "benchmark_method": "JSON characters of structured responses, counted once including artifact base64; not tokens. Baselines include individual setup/cleanup; current three workflows share 19 setup/cleanup calls and add new datum/reference-set/diagnostic checks. This is a recipe comparison, not an isolated backend performance A/B.", + "advanced_flange": { + "numeric_length": "passed", + "single_end_plane": "passed", + "paired_end_planes": "passed", + "by_value_infer_length_true": "committed, geometry identical to numeric baseline; inference not established", + "to_reference": "No successful fixture established; web/formed-wall combinations rejected with baseline volume restored." + }, + "dxf": { + "extents": [ + 115.49823, + 135.49823 + ], + "expected": [ + 115.49822911213865, + 135.49822911213863 + ], + "tolerance_mm": 0.001, + "passed": true + }, + "artifacts": { + "review.pdf": { + "sha256": "8904633ac78a5326b0c2243e4e2fa02f789321a911c04a534e8bf0e32b6896e1", + "size": 19520 + }, + "sheet.dxf": { + "sha256": "6a9438b0413688bff118a284f6f3a1019be39e4accb8b20b12d824e9ee76b48f", + "size": 101407 + } + }, + "agent_discovery_review": "passed: 185 tools, 47 typed payloads, all six new descriptions actionable", + "upstream_pr_created": false +} diff --git a/docs/fork-status.md b/docs/fork-status.md index b98bce5..52a15e4 100644 --- a/docs/fork-status.md +++ b/docs/fork-status.md @@ -1,6 +1,6 @@ # NX v2606 integration fork -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev14`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. +This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev16`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. @@ -16,7 +16,7 @@ See [agent UX](agent-ux.md) for focused discovery, inline artifact retrieval and - Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. - Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. -The dev14 opt-in integration profile exposes 179 tools. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. +The dev16 opt-in integration profile exposes 185 tools, including reference-set/datum controls and compact inventories. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. ## Start the graphical bridge and sidecar @@ -46,7 +46,7 @@ See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual t The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev13 acceptance](dev13-validation.json); [dev12 acceptance](dev12-validation.json) retains the preceding documentation results; [dev11 acceptance](dev11-validation.json) retains the earlier freeform/documentation results; [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. +A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev16 acceptance](dev16-validation.json); [dev13 acceptance](dev13-validation.json) retains preceding release-engineering evidence; [dev12 acceptance](dev12-validation.json) retains the preceding documentation results; [dev11 acceptance](dev11-validation.json) retains the earlier freeform/documentation results; [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). diff --git a/docs/output-contracts.md b/docs/output-contracts.md index 983f9ee..ab8b406 100644 --- a/docs/output-contracts.md +++ b/docs/output-contracts.md @@ -9,9 +9,9 @@ empty geometry. Success and error requirements are separate conditional branches Tool-specific success contracts cover bounds, distance, volume, topology, components, extrusion/revolve/pattern results, pairwise interference and clearance, operation receipts, checkpoints/rollback, workspace listings, downloads and the -main image/CAD/document exports. Inspect the live `tools/list` output for the +main image/CAD/document exports. Dev16 expands this to 47 tool-specific payloads, adding part lifecycle, sketch diagnostics, sheet-metal authoring/inspection, drawing inspection and reference-geometry controls. Inspect the live `tools/list` output for the exact fields. Other tools retain extensible success payloads; this is not a claim -that all 179 payloads are fully typed. +that all 185 payloads are fully typed. Geometry references keep opaque IDs separate from names and identify owner parts. Vectors have three coordinates; matrices contain three rows. Measurement fields diff --git a/docs/releases.md b/docs/releases.md index 0ca179d..0c24400 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -56,7 +56,7 @@ The command records atomic phase receipts in `acceptance.json`: wheel. Reject a source overlay or extra executable/configuration files. 2. Check Python 3.12, all pinned dependency versions and `pip check`. 3. Inspect the live NX version/tool count and original saved session. Defaults are - NX `v2606` and 179 tools; explicit expected-value options support later releases. + NX `v2606` and 185 tools; explicit expected-value options support later releases. 4. Run the existing six native release suites serially, retain their logs and receipts, and stop at the first failure. The native runner checks its own preservation evidence. From dbb421ba0d11eb6ff94231730be9a6085164d4e5 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 20:17:47 +0200 Subject: [PATCH 50/69] Add compact agent profile with discovery, receipts and artifact resources --- README.md | 2 + docs/agent-surface.md | 78 ++++++++ pyproject.toml | 3 +- scripts/benchmark_agent_surface.py | 74 +++++++ scripts/download_artifact.py | 67 +++++++ scripts/validate_agent_ux.py | 31 ++- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/agent_surface.py | 311 +++++++++++++++++++++++++++++ src/nx_mcp/http_surface.py | 58 ++++++ src/nx_mcp/server.py | 14 +- tests/test_agent_surface.py | 279 ++++++++++++++++++++++++++ 11 files changed, 913 insertions(+), 6 deletions(-) create mode 100644 docs/agent-surface.md create mode 100644 scripts/benchmark_agent_surface.py create mode 100644 scripts/download_artifact.py create mode 100644 src/nx_mcp/agent_surface.py create mode 100644 src/nx_mcp/http_surface.py create mode 100644 tests/test_agent_surface.py diff --git a/README.md b/README.md index a2905cb..29c00f0 100644 --- a/README.md +++ b/README.md @@ -152,3 +152,5 @@ See [editable documentation and manufacturing](docs/documentation-manufacturing. See [release engineering and native acceptance](docs/release-engineering.md) for dev13 drawing authoring, assembly refresh, retained-dimension repair, imported geometry references, mixed units and serial release validation. The [dev13 native acceptance receipt](docs/dev13-validation.json) records installed runtime tests, preserved session state and artifact hashes. + +See [the compact agent profile](docs/agent-surface.md) for on-demand tool discovery, expandable receipts, binary artifact retrieval and token benchmarks. diff --git a/docs/agent-surface.md b/docs/agent-surface.md new file mode 100644 index 0000000..8591f51 --- /dev/null +++ b/docs/agent-surface.md @@ -0,0 +1,78 @@ +# Agent surface + +The full profile remains compatible: 185 tools with existing defaults. The opt-in +agent profile lists 11 tools: eight core tools plus `nx_discover_tools`, `nx_invoke` +and `nx_result`. All 185 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` +until explicitly removed during workspace maintenance; 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. + +## 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. diff --git a/pyproject.toml b/pyproject.toml index 5634d1b..726e822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev16" +version = "0.2.0.dev17" 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", 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/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/validate_agent_ux.py b/scripts/validate_agent_ux.py index aba2382..6dd60d0 100644 --- a/scripts/validate_agent_ux.py +++ b/scripts/validate_agent_ux.py @@ -9,6 +9,7 @@ import time import uuid from pathlib import Path +from types import SimpleNamespace from jsonschema import Draft202012Validator from mcp import ClientSession @@ -21,13 +22,21 @@ def __init__(self, session, output): 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(tool_name, params) + 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( @@ -59,12 +68,28 @@ 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) - client.schemas = {t.name: t.inputSchema for t in (await session.list_tools()).tools} + 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 = {t.name: t for t in (await c.c.list_tools()).tools} + tools = c.tools original_call = c.call records = [] phase = "inventory" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 5c81b06..ebc85b2 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.dev16" +__version__ = "0.2.0.dev17" diff --git a/src/nx_mcp/agent_surface.py b/src/nx_mcp/agent_surface.py new file mode 100644 index 0000000..c24b0b5 --- /dev/null +++ b/src/nx_mcp/agent_surface.py @@ -0,0 +1,311 @@ +"""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 Any, Literal +from urllib.parse import quote, unquote + +from mcp.server.fastmcp.exceptions import ToolError +from mcp.types import CallToolResult, ResourceLink, TextContent, ToolAnnotations + +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): + """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(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): + if full.get("status") == "error": + return full + omitted: dict[str, Any] = {} + payload = compact(full, omitted=omitted) + 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"} + } + return payload + + +class ResultStore: + """Durable inspection snapshots, separate from authoritative mutation receipts.""" + + def __init__(self, root): + self.root = Path(root) / ".nx-mcp" / "agent-results" + + def put(self, payload): + 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) + 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): + 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)) + 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, + offset: int = 0, + limit: int = 10, + ) -> CallToolResult: + """Discover task tools by name/description or domain. Domains: modeling, sketch, assembly, drawing, manufacturing, inspection, display, files. Request exact-name schema before nx_invoke; 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") + rows = [ + t + for n, t in sorted(registry.items()) + if (domain is None or category(n) == domain) + and (query.casefold() in (n + " " + t.description).casefold()) + ] + # 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, 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) + + @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, + } + ) + + helper_names = {"nx_discover_tools", "nx_invoke", "nx_result"} + 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", + ) + except (ValueError, KeyError, IndexError, OSError, TypeError, ToolError) as error: + from nx_mcp.runtime import NXToolError + + return envelope( + NXToolError( + "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. 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/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/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/tests/test_agent_surface.py b/tests/test_agent_surface.py new file mode 100644 index 0000000..93500ff --- /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) == 11 + 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", 185), ("/agent/mcp", 11)]: + 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 From 5f98e55d61fe7b6ae2bb815bcbc65efad21e22a8 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 20:31:10 +0200 Subject: [PATCH 51/69] Record dev17 native acceptance and scoped token measurements --- docs/dev17-validation.json | 181 +++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/dev17-validation.json diff --git a/docs/dev17-validation.json b/docs/dev17-validation.json new file mode 100644 index 0000000..f1febe1 --- /dev/null +++ b/docs/dev17-validation.json @@ -0,0 +1,181 @@ +{ + "release": { + "commit": "dbb421ba0d11eb6ff94231730be9a6085164d4e5", + "sha256": "e3f730baf0382a63f40a97725fe3aa64e088d978c182479e23f3ab197d2ab9ab", + "version": "0.2.0.dev17", + "build_run": 34051262299 + }, + "full_endpoint": "/mcp", + "agent_endpoint": "/agent/mcp", + "visible_tools": { + "full": 185, + "agent": 11 + }, + "client_configuration": "nx-mcp URL updated to /agent/mcp; reconnect required", + "automated_tests": { + "passed": 859, + "skipped_dedicated_native": 1, + "branch_coverage_percent": 78.89, + "mypy_modules": 47, + "precommit": "passed", + "ci_run": 34051262537, + "ci": "passed" + }, + "native_workflows": { + "prefix": "validation/dev16-ux-9128540e", + "checks": [ + "body-only reference sets assigned without pose changes; exploded drawing exported", + "flat pattern committed first attempt using discovered face-boundary edge", + "imported-face edit 3000 to 3600 mm3 persisted through reopen" + ], + "passed": true, + "session_preserved": { + "parts": 38, + "occurrences": 116 + } + }, + "native_workflow_mode": "agent gateway with explicit full responses for geometry/schema assertions; separate compact-mode acceptance passed", + "compact_native": { + "status": "passed", + "checks": [ + "11 visible tools", + "bounded 38-part and 116-occurrence inventories", + "exact schema discovery", + "committed mutation deduplication", + "snapshot expansion", + "native PNG image and resource checksum", + "38 saved parts restored" + ], + "calls": [ + { + "tool": "nx_list_open_parts", + "error": false, + "chars": 7776 + }, + { + "tool": "nx_list_components", + "error": false, + "chars": 9425 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 24616 + }, + { + "tool": "nx_discover_tools", + "error": false, + "chars": 2902 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 547 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 565 + }, + { + "tool": "nx_operation_status", + "error": false, + "chars": 1073 + }, + { + "tool": "nx_result", + "error": false, + "chars": 1002 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 802 + }, + { + "tool": "nx_screenshot", + "error": false, + "chars": 1417 + }, + { + "tool": "nx_download_file", + "error": false, + "chars": 287 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 680 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 568 + }, + { + "tool": "nx_invoke", + "error": false, + "chars": 24616 + } + ] + }, + "windows_finalizer": { + "exit_code": 0, + "source": "completed exec session 58353; installed source hash comparison, stdio 185, HTTP 185, agent 11 and PNG checksum passed" + }, + "token_benchmark": { + "scope": "paired recorded response projection; same completed native tasks, not an autonomous-agent A/B trial", + "tokenizer": "o200k_base", + "serialization": "compact JSON structuredContent counted once; excludes protocol, duplicate text, image tokens and initial discovery", + "full_response_tokens": 414225, + "compact_response_tokens": 111949, + "response_token_reduction_percent": 72.97, + "recorded_calls": 96, + "recorded_errors": 0, + "binary_transfer_calls": 2, + "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" + }, + "catalog_benchmark": { + "full": { + "tools": 185, + "serialized_catalog_tokens": 102726 + }, + "agent": { + "tools": 11, + "serialized_catalog_tokens": 1881 + }, + "scope": "Serialized MCP tool catalog under o200k_base; excludes client wrapping and dynamic discovery; not billed input tokens" + }, + "limitations": [ + "No provider-reported model token usage or autonomous-agent A/B success comparison is available. Token counts are exact serialized-text tokenizer counts with explicitly excluded overhead.", + "Initial local compact helper incorrectly expected object instead of part; fixture was reconciled and corrected acceptance passed. No product code redeployment was needed.", + "Result snapshots persist until explicit workspace maintenance; NX references can become stale." + ], + "artifact_checks": { + "pdf": "visually checked: three exploded blocks, balloon and BOM; no datum axes", + "dxf": { + "passed": true, + "dimensions_mm": [ + 135.49823, + 115.49823 + ], + "expected_dimensions_mm": [ + 115.49822911213865, + 135.49822911213863 + ], + "absolute_tolerance_mm": 0.001 + }, + "step_download_sha256": "d7e77e16cd80d360cd9d67b5c53d601c941b0c514af08e7d2d57e169e074f3ba", + "whole_vm": "visually checked: original Baldower assembly, no blocking dialogs" + }, + "preservation": { + "saved_parts": 38, + "occurrences": 116, + "source_paths_transforms_suppression_reference_sets": "unchanged" + } +} From f91cb2b628068188baa50a9e6e31353e8ddbbd8e Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 20:45:12 +0200 Subject: [PATCH 52/69] Add reviewed agent guidance, stable inspection pages and snapshot retention --- docs/agent-surface.md | 50 +++++++- pyproject.toml | 2 +- scripts/agent_benchmark_client.py | 98 ++++++++++++++++ src/nx_mcp/__init__.py | 2 +- src/nx_mcp/agent_guidance.py | 187 ++++++++++++++++++++++++++++++ src/nx_mcp/agent_surface.py | 156 +++++++++++++++++++++++-- src/nx_mcp/result_retention.py | 59 ++++++++++ tests/test_agent_improvements.py | 153 ++++++++++++++++++++++++ tests/test_agent_surface.py | 4 +- 9 files changed, 696 insertions(+), 15 deletions(-) create mode 100644 scripts/agent_benchmark_client.py create mode 100644 src/nx_mcp/agent_guidance.py create mode 100644 src/nx_mcp/result_retention.py create mode 100644 tests/test_agent_improvements.py diff --git a/docs/agent-surface.md b/docs/agent-surface.md index 8591f51..52d3abf 100644 --- a/docs/agent-surface.md +++ b/docs/agent-surface.md @@ -1,8 +1,8 @@ # Agent surface The full profile remains compatible: 185 tools with existing defaults. The opt-in -agent profile lists 11 tools: eight core tools plus `nx_discover_tools`, `nx_invoke` -and `nx_result`. All 185 underlying tools remain available, subject to their +agent profile lists 13 tools: eight core tools plus `nx_discover_tools`, `nx_invoke` +`nx_result`, `nx_inspect` and `nx_result_cleanup`. All 185 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 @@ -32,7 +32,7 @@ to issue concurrent mutations. The host must restrict network access as before. `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` -until explicitly removed during workspace maintenance; they do not keep NX +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. @@ -76,3 +76,47 @@ 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. diff --git a/pyproject.toml b/pyproject.toml index 726e822..131e5db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev17" +version = "0.2.0.dev18" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" 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/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index ebc85b2..a844e71 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.dev17" +__version__ = "0.2.0.dev18" diff --git a/src/nx_mcp/agent_guidance.py b/src/nx_mcp/agent_guidance.py new file mode 100644 index 0000000..a31848f --- /dev/null +++ b/src/nx_mcp/agent_guidance.py @@ -0,0 +1,187 @@ +"""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"], ["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"} +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), + "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.""" + 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 index c24b0b5..02ff1f7 100644 --- a/src/nx_mcp/agent_surface.py +++ b/src/nx_mcp/agent_surface.py @@ -13,6 +13,9 @@ from mcp.server.fastmcp.exceptions import ToolError from mcp.types import CallToolResult, ResourceLink, TextContent, ToolAnnotations +from nx_mcp.agent_guidance import guidance, next_actions +from nx_mcp.result_retention import LOCK, maintain, settings + CORE = { "nx_status", "nx_workspace_info", @@ -83,7 +86,7 @@ def compact(value, path="", omitted=None): return value -def compact_payload(full, result_id): +def compact_payload(full, result_id, method=None): if full.get("status") == "error": return full omitted: dict[str, Any] = {} @@ -98,6 +101,8 @@ def compact_payload(full, result_id): for k, v in changes.items() if k in {"created", "modified", "deleted"} } + if actions := next_actions(method, full): + payload["next_actions"] = actions return payload @@ -106,8 +111,15 @@ class ResultStore: 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") @@ -117,6 +129,7 @@ def put(self, payload): 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): @@ -143,7 +156,7 @@ def artifact(path: str) -> bytes: raise ValueError("Resource exceeds 8 MiB; use programmatic nx_download_file chunks") return file.read_bytes() - def present(response, mode): + def present(response, mode, method=None): if not isinstance(response, CallToolResult) or mode == "full" or response.isError: return response full = response.structuredContent @@ -151,7 +164,7 @@ def present(response, mode): return response # Never turn an already-committed operation into an error if caching fails. try: - payload = compact_payload(full, store.put(full)) + payload = compact_payload(full, store.put(full), method) except OSError: return response result = envelope(payload) @@ -200,7 +213,11 @@ async def nx_discover_tools( "defaults": DEFAULTS.get(tool.name, {}), } if include_schema: - row.update(inputSchema=tool.parameters, outputSchema=tool.fn_metadata.output_schema) + row.update( + inputSchema=tool.parameters, + outputSchema=tool.fn_metadata.output_schema, + guidance=guidance(tool), + ) selected.append(row) return envelope( { @@ -220,7 +237,7 @@ async def nx_invoke( 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) + return present(await original_call(tool, params), detail, tool) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True)) async def nx_result( @@ -262,7 +279,124 @@ async def nx_result( } ) - helper_names = {"nx_discover_tools", "nx_invoke", "nx_result"} + @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" @@ -278,13 +412,19 @@ async def call(name, arguments): 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_INVALID_ARGUMENT", str(error), details={"mutation_outcome": "not_started"} + "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, ) @@ -308,4 +448,4 @@ async def listing(): 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. 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." + 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/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/tests/test_agent_improvements.py b/tests/test_agent_improvements.py new file mode 100644 index 0000000..fc205ac --- /dev/null +++ b/tests/test_agent_improvements.py @@ -0,0 +1,153 @@ +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) diff --git a/tests/test_agent_surface.py b/tests/test_agent_surface.py index 93500ff..4d1b417 100644 --- a/tests/test_agent_surface.py +++ b/tests/test_agent_surface.py @@ -49,7 +49,7 @@ async def test_discovery_defaults_dispatch_and_expansion(tmp_path): 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) == 11 + 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} @@ -211,7 +211,7 @@ def test_dual_http_profiles(tmp_path, monkeypatch): 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", 185), ("/agent/mcp", 11)]: + for path, count in [("/mcp", 185), ("/agent/mcp", 13)]: response = client.post( path, headers={"Accept": "application/json, text/event-stream"}, From 9254c028eac8ffdfeed54201377ba62a052b0a1c Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 21:07:50 +0200 Subject: [PATCH 53/69] Fix page cardinality and discovery issues found by fresh agents --- docs/agent-surface.md | 13 ++++++++++++ src/nx_mcp/agent_guidance.py | 2 ++ src/nx_mcp/agent_surface.py | 34 +++++++++++++++++++++++------- tests/test_agent_improvements.py | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 8 deletions(-) diff --git a/docs/agent-surface.md b/docs/agent-surface.md index 52d3abf..c4f91db 100644 --- a/docs/agent-surface.md +++ b/docs/agent-surface.md @@ -120,3 +120,16 @@ 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/src/nx_mcp/agent_guidance.py b/src/nx_mcp/agent_guidance.py index a31848f..8f34f27 100644 --- a/src/nx_mcp/agent_guidance.py +++ b/src/nx_mcp/agent_guidance.py @@ -150,6 +150,8 @@ def effect(group): 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): diff --git a/src/nx_mcp/agent_surface.py b/src/nx_mcp/agent_surface.py index 02ff1f7..c39c936 100644 --- a/src/nx_mcp/agent_surface.py +++ b/src/nx_mcp/agent_surface.py @@ -59,7 +59,7 @@ def category(name): ) -def compact(value, path="", omitted=None): +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): @@ -77,7 +77,11 @@ def compact(value, path="", omitted=None): elif key in {"warnings", "retry_guidance", "recovery"}: result[key] = item else: - result[key] = compact(item, location, omitted) + 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: @@ -90,7 +94,10 @@ def compact_payload(full, result_id, method=None): if full.get("status") == "error": return full omitted: dict[str, Any] = {} - payload = compact(full, omitted=omitted) + # 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 @@ -187,20 +194,30 @@ async def nx_discover_tools( query: str = "", domain: str | None = None, include_schema: bool = False, + include_output_schema: bool = True, offset: int = 0, limit: int = 10, ) -> CallToolResult: - """Discover task tools by name/description or domain. Domains: modeling, sketch, assembly, drawing, manufacturing, inspection, display, files. Request exact-name schema before nx_invoke; results are paged. Discovery does not mutate NX or the session's catalog.""" + """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 sorted(registry.items()) - if (domain is None or category(n) == domain) - and (query.casefold() in (n + " " + t.description).casefold()) + 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]] @@ -215,9 +232,10 @@ async def nx_discover_tools( if include_schema: row.update( inputSchema=tool.parameters, - outputSchema=tool.fn_metadata.output_schema, guidance=guidance(tool), ) + if include_output_schema: + row["outputSchema"] = tool.fn_metadata.output_schema selected.append(row) return envelope( { diff --git a/tests/test_agent_improvements.py b/tests/test_agent_improvements.py index fc205ac..753d711 100644 --- a/tests/test_agent_improvements.py +++ b/tests/test_agent_improvements.py @@ -151,3 +151,39 @@ async def test_cleanup_tool_defaults_to_preview(tmp_path): 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] From a8ef3a270fbf4083b54ea48b06b64257dd910357 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 21:20:25 +0200 Subject: [PATCH 54/69] Document dev18 deployment and fresh-agent validation --- docs/dev18-validation.json | 96 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/dev18-validation.json diff --git a/docs/dev18-validation.json b/docs/dev18-validation.json new file mode 100644 index 0000000..fe9f501 --- /dev/null +++ b/docs/dev18-validation.json @@ -0,0 +1,96 @@ +{ + "release": { + "version": "0.2.0.dev18", + "commit": "9254c028eac8ffdfeed54201377ba62a052b0a1c", + "sha256": "7462e053c4261e0e726611e9b7835b3efb11ee833f436658312a11c48af58021", + "build_run": 34053906206 + }, + "ci_run": 34053906444, + "automated_validation": { + "passed": 868, + "skipped": 1, + "skip_reason": "Dedicated real-NX test requires its runner; separate live checks completed", + "branch_coverage_percent": 79.08, + "mypy_modules": 49 + }, + "deployment": { + "complete_package": true, + "offline_verified_files": 247, + "windows_stdio_http_source_validation": "passed", + "native_inline_png_validation": "passed", + "agent_tool_count": 13, + "full_tool_count": 185 + }, + "implemented": [ + "Prerequisites, supported object kinds, examples and native evidence in discovery", + "Task-specific compact receipt follow-up hints", + "Filtered snapshot inspection and pagination", + "Conservative mutation and side-effect guidance", + "Snapshot age/storage retention and explicit cleanup", + "Fresh serial agent trials with analytic geometry verification" + ], + "guidance_scope": { + "curated_workflows": 17, + "unreviewed_effects": null, + "native_status_source": "Existing version-specific capability manifest; not all tools newly native-tested" + }, + "fresh_agent_comparison": { + "sample_size_per_profile": 1, + "baseline": "dev17 full profile", + "candidate_commit": "f91cb2b628068188baa50a9e6e31353e8ddbbd8e", + "tasks": [ + "80 x 50 x 4 mm plate with two radius-3 through holes", + "Three-instance assembly at X=0,100,200 mm", + "STEP export/import geometry equivalence" + ], + "profiles": { + "full": { + "explicit_mcp_calls": 59, + "discovery_and_catalog_calls": 24, + "reported_tool_errors": 0, + "reported_retries": 1, + "response_and_discovery_text_tokens_o200k_base": 66957, + "analytic_geometry_audit": "passed: two plate volumes, two plate bounds, assembly bounds" + }, + "agent": { + "explicit_mcp_calls": 59, + "discovery_and_catalog_calls": 20, + "reported_tool_errors": 0, + "reported_retries": 1, + "response_and_discovery_text_tokens_o200k_base": 46589, + "analytic_geometry_audit": "passed: two plate volumes, two plate bounds, assembly bounds" + } + }, + "both_profiles_all_tasks_passed": true, + "provider_token_usage": null, + "limitations": [ + "One trial per profile; no statistical efficiency conclusion", + "Response tokenizer counts are not provider usage or total agent-context cost", + "Full-profile discovery uses local catalog filtering in the benchmark client", + "Initial connection retries occurred before NX mutation" + ] + }, + "post_trial_fixes": { + "commit": "9254c028eac8ffdfeed54201377ba62a052b0a1c", + "agent_read_only_followup": "passed", + "checks": [ + "38 requested part rows retained with coherent pagination", + "Spaced discovery query ranks nx_create_part first", + "Output schema omitted on request", + "Inspection terminal page coherence", + "Cleanup dry run preserves recovery receipts" + ], + "multi_page_inspection": "Exercised separately in initial dev18 native inspection; final follow-up checked terminal page only" + }, + "preservation": { + "saved_original_parts": 38, + "unchanged_original_occurrences": 116, + "active_original_assembly_restored": true, + "serial_ui_main_thread_verified": true + }, + "remaining_observations": [ + "Broad discovery queries can return secondary matches", + "Benchmark client connection failures still emit verbose tracebacks", + "Exact provider model identifier and usage unavailable" + ] +} From b2584eeef629791b04cf6ad68fe40a428bfe5aec Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 22:11:40 +0200 Subject: [PATCH 55/69] Repair remaining legacy tools and consolidate integration documentation --- README.md | 207 ++++----- docs/advanced-authoring.md | 41 -- docs/advanced-roadmap.md | 28 -- docs/agent-ux.md | 49 -- docs/agent-workflows.md | 121 ----- docs/architecture.md | 56 ++- docs/authoring-review.md | 41 -- docs/capability-matrix.md | 30 +- docs/dev10-validation.json | 62 --- docs/dev11-validation.json | 119 ----- docs/dev12-validation.json | 173 -------- docs/dev13-validation.json | 309 ------------- docs/dev14-release.md | 32 -- docs/dev14-validation.json | 58 --- docs/dev15-validation.json | 61 --- docs/dev16-validation.json | 99 ----- docs/dev17-validation.json | 181 -------- docs/dev18-validation.json | 96 ---- docs/dev3-validation.json | 37 -- docs/dev4-validation.json | 72 --- docs/dev5-validation.json | 86 ---- docs/dev6-validation.json | 92 ---- docs/dev7-validation.json | 77 ---- docs/dev8-validation.json | 121 ----- docs/dev9-validation.json | 40 -- docs/documentation-manufacturing.md | 39 -- docs/engineering-tools.md | 43 -- docs/exploded-views.md | 71 --- docs/fork-status.md | 61 --- docs/fork-validation.md | 93 ---- docs/freeform-manufacturing.md | 41 -- docs/migration-0.2.md | 4 +- docs/output-contracts.md | 34 -- docs/project-folders.md | 34 -- docs/real-nx-validation.md | 37 +- docs/release-engineering.md | 59 --- docs/releases.md | 91 ---- docs/sheet-metal-native-validation.json | 540 ----------------------- docs/sheet-metal.md | 169 ------- docs/tools.md | 469 ++++++++++++++++++++ docs/upstream-review.md | 45 -- docs/visual-tools.md | 35 -- examples/validate_remaining_tools.py | 143 ++++++ pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/capability_manifest.json | 28 +- src/nx_mcp/hardened.py | 5 + src/nx_mcp/integration_server.py | 28 ++ src/nx_mcp/legacy_repairs.py | 132 ++++++ src/nx_mcp/sheet_metal_catalog.json | 6 +- tests/fixtures/sheet-metal-examples.json | 30 ++ tests/test_legacy_repairs.py | 95 ++++ 52 files changed, 1101 insertions(+), 3523 deletions(-) delete mode 100644 docs/advanced-authoring.md delete mode 100644 docs/advanced-roadmap.md delete mode 100644 docs/agent-ux.md delete mode 100644 docs/agent-workflows.md delete mode 100644 docs/authoring-review.md delete mode 100644 docs/dev10-validation.json delete mode 100644 docs/dev11-validation.json delete mode 100644 docs/dev12-validation.json delete mode 100644 docs/dev13-validation.json delete mode 100644 docs/dev14-release.md delete mode 100644 docs/dev14-validation.json delete mode 100644 docs/dev15-validation.json delete mode 100644 docs/dev16-validation.json delete mode 100644 docs/dev17-validation.json delete mode 100644 docs/dev18-validation.json delete mode 100644 docs/dev3-validation.json delete mode 100644 docs/dev4-validation.json delete mode 100644 docs/dev5-validation.json delete mode 100644 docs/dev6-validation.json delete mode 100644 docs/dev7-validation.json delete mode 100644 docs/dev8-validation.json delete mode 100644 docs/dev9-validation.json delete mode 100644 docs/documentation-manufacturing.md delete mode 100644 docs/engineering-tools.md delete mode 100644 docs/exploded-views.md delete mode 100644 docs/fork-status.md delete mode 100644 docs/fork-validation.md delete mode 100644 docs/freeform-manufacturing.md delete mode 100644 docs/output-contracts.md delete mode 100644 docs/project-folders.md delete mode 100644 docs/release-engineering.md delete mode 100644 docs/releases.md delete mode 100644 docs/sheet-metal-native-validation.json delete mode 100644 docs/sheet-metal.md create mode 100644 docs/tools.md delete mode 100644 docs/upstream-review.md delete mode 100644 docs/visual-tools.md create mode 100644 examples/validate_remaining_tools.py create mode 100644 src/nx_mcp/legacy_repairs.py create mode 100644 tests/fixtures/sheet-metal-examples.json create mode 100644 tests/test_legacy_repairs.py diff --git a/README.md b/README.md index 29c00f0..2f9b600 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,121 @@ # NX MCP Server -See the generated [capability evidence matrix](docs/capability-matrix.md) for manifest-scoped native testing, contract-only testing, experimental tools, and unavailable capabilities. - -> **Fork status:** This fork targets Siemens NX v2606 with the `0.2.0.dev16` integration and 185 opt-in tools. It includes visible UI control, recovery, artifact transfer, native interference, rendering, engineering authoring, native sheet-metal features and drawing/PDF support. Start with [fork setup and scope](docs/fork-status.md) and [engineering tool contracts](docs/engineering-tools.md). The original upstream README follows; its smaller default surface and NX2506 validation describe the upstream baseline. - -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. - -## Current status - -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. - -The default `tools/list` exposes only these 16 tools: +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. -- 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` +## Choose a tool profile -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`. +| Profile | Exposure | Configuration | +| --- | --- | --- | +| Default | 16 original core tools | No experimental opt-in | +| Integration | 185 tools | `NX_MCP_ENABLE_EXPERIMENTAL=1` | +| Agent | 13 entry points; discover/invoke integration tools on demand | Integration opt-in plus `NX_MCP_SURFACE=agent` | -## Requirements +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. -- 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` +## Start graphical NX -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). -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. +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. -## Security model +## Workflows -- 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. +| 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) | -## Local quality gates +## References, recovery and paths -The ordinary suite does not require NX. Install the Git hooks once, then use -the same checks as CI: +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. -```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. +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. -## Star History +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). - - - Star History Chart - +## Validation and limitations -Authoring and review tools add geometric selection, expression binding, model health, sketch editing, assembly maintenance, saved presentations, inspection reports, compact summaries, and reversible previews. See [supported operations and limits](docs/authoring-review.md). +Run the ordinary quality gates without NX: -Advanced NX 2606 tools: [exact selection, associative component patterns and sketch dimensions](docs/advanced-authoring.md). Proposed upstream review slices are documented in the [review package](docs/upstream-review.md); no PR is opened by the release workflow. +```powershell +python -m pre_commit run --all-files +python -m pytest -q -p no:cacheprovider -m "not real_nx" --basetemp .pytest-tmp +``` -See [freeform, assembly documentation and manufacturing](docs/freeform-manufacturing.md) for the dev11 additions and scoped native verification. +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). -See [editable documentation and manufacturing](docs/documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. +The [dev18 receipt](docs/real-nx-validation.md) records 868 automated passes and +scoped 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). -See [release engineering and native acceptance](docs/release-engineering.md) for dev13 drawing authoring, assembly refresh, retained-dimension repair, imported geometry references, mixed units and serial release validation. +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. -The [dev13 native acceptance receipt](docs/dev13-validation.json) records installed runtime tests, preserved session state and artifact hashes. +## Upstream contribution -See [the compact agent profile](docs/agent-surface.md) for on-demand tool discovery, expandable receipts, binary artifact retrieval and token benchmarks. +[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/docs/advanced-authoring.md b/docs/advanced-authoring.md deleted file mode 100644 index 9945726..0000000 --- a/docs/advanced-authoring.md +++ /dev/null @@ -1,41 +0,0 @@ -# Advanced authoring on NX 2606 - -Version `0.2.0.dev6` adds ten tools (104 in the opt-in integration profile). Every native call remains serialized on the NX thread. Model mutations retain operation-ID deduplication, checkpoint rollback, and owner-scoped references. The ordinary-instance `nx_pattern_components` tool is unchanged. - -## 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. - -## Validation - -Run `examples/validate_advanced_tools.py` against a disposable NX workspace via `NX_MCP_TEST_ENDPOINT`; optionally set `NX_ADVANCED_RESULTS` for local receipts. It creates fixture parts and does not close or save unrelated user parts. Restore your original active part afterward. The local regression suite distinguishes fake NX boundary tests from native geometry tests. Deployment acceptance and scoped limitations are recorded separately in `dev6-validation.json`. diff --git a/docs/advanced-roadmap.md b/docs/advanced-roadmap.md deleted file mode 100644 index 09edc3b..0000000 --- a/docs/advanced-roadmap.md +++ /dev/null @@ -1,28 +0,0 @@ -# Advanced authoring roadmap - -This was the pre-dev10 roadmap. Sheet-metal authoring and flat patterns are now described in [sheet metal](sheet-metal.md). Dev11 implements curves/meshes/bridges, trim/sew/thicken, direct face editing, BOMs/balloons/traces/animation, manual threads, native datum/FCF PMI and sampled surface/thickness/draft analysis; see [contracts and verification limits](freeform-manufacturing.md). - -The historical list below also contains extensions that remain unimplemented or unverified, including surface extension, zebra inspection and full standards-table manufacturing workflows. It must not be read as a current capability manifest. - -1. **Freeform curves and surfaces:** editable 3D splines, through-curve and mesh - surfaces, bridge surfaces, trim/extend, sew, thicken and offset. Include - continuity (G0/G1/G2), curvature and gap diagnostics so the agent can verify - surface quality. NX v2606 builder presence was inspected; each operation still - needs native license/API and geometry tests. Build on existing loft and sweep. -2. **Service and assembly documentation:** explosion trace lines, BOMs and - associative balloons, then assembly sequences and animation. -3. **Imported-part direct editing:** move/offset/replace/delete-heal faces with - geometric selection and before/after validity checks. -4. **Sheet metal:** bends, flanges, reliefs, bend allowance and flat patterns. -5. **Manufacturing detail:** threaded holes and cosmetic threads, GD&T/PMI, - datum schemes and drawing sections/details. -6. **Design validation:** wall thickness, draft analysis, curvature/zebra - inspection and tolerance-aware clearance reports. - -Realize Shape subdivision is a later freeform stage. It needs separate NXOpen -and license verification; exposing a named builder alone is insufficient. -Keep commands intent-oriented, with typed selections, preview/commit boundaries, -checkpoints, units, actual result counts and native verification evidence. - -Siemens references: [freeform surface workflow](https://blogs.sw.siemens.com/nx-design/freeform-modeling-walk-through/) -and [Realize Shape](https://blogs.sw.siemens.com/designcenter/nx-tips-and-tricks-realize-shape/). diff --git a/docs/agent-ux.md b/docs/agent-ux.md deleted file mode 100644 index 7023bba..0000000 --- a/docs/agent-ux.md +++ /dev/null @@ -1,49 +0,0 @@ -# Agent UX and recovery - -Three independent agents sampled live discovery, artifacts and recovery. They -used read-only MCP calls while serial native acceptance ran. This was a focused -usability evaluation, not testing every tool or every geometric option. - -## Efficient discovery and artifacts - -- Start with `nx_workspace_info`. All file arguments name files on the NX host. -- Use `nx_capabilities(tool="nx_revolve")` or `prefix="nx_sheet"` for focused - native evidence. Full capability discovery remains available without filters. -- Request `nx_sheet_metal_schema(operation="unbend")` before authoring. Top-level - `status` describes call success; `validation_status` describes native evidence. - Length conventions are explicit and independent of whichever part is active. -- Use `nx_workspace_list(path="project", prefix="review", limit=100)` and follow - `next_offset`. `count` is page size; `total_count` is the filtered total. -- Use `nx_download_file(path="project/view.png", delivery="image")` for an inline - PNG, with size, resolution and checksum in structured metadata. The PNG bytes - are MCP image content rather than a large base64 text field. Limit: 8 MiB. -- Use `delivery="metadata"` for one file's size and SHA-256. Default `base64` - delivery remains available for PDFs/CAD and large images. Follow - `bytes_returned`, `next_offset` and `eof`; chunk length is 1–262144 bytes. - -Discovery and inspection no longer request a model viewport refresh. Drawing -mutations do not invoke a model-view refresh while a drawing sheet is active. - -## Recovery - -Supply an operation ID before a mutation. After a transport failure, query that -ID. `committed` permits replay of the identical request without repeating its -mutation. A changed payload with the same ID is rejected. `unknown` never proves -failure and never authorizes a blind retry. Receipt identities and query identities -are separate. An earlier-session receipt cannot make old object references valid. - -Closing an assembly can cause NX to unload unused prototypes. Inspect returned -`closed_parts` and re-list the session between closes. Reacquire references after -close, rollback or manual handoff. Saving expires native checkpoints; durable -operation receipts survive a bridge restart, but native undo marks do not. - -## Response contracts and remaining scope - -Every integration tool advertises a common structured output schema for status, -warnings, units and optional operation identity/outcome. Tool-specific result -fields remain extensible; this is not a complete typed schema for every feature -builder. Existing structured/text compatibility is retained. Long native calls -still serialize discovery queries that need installed NX API detection. Generic -PDF delivery remains chunked; there is no inline PDF renderer in the MCP server. - -Refresh the client's tool catalog after deployment to discover new arguments. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md deleted file mode 100644 index 82fa23d..0000000 --- a/docs/agent-workflows.md +++ /dev/null @@ -1,121 +0,0 @@ -# Agent workflow evaluations - -These are scoped native workflow observations, not general correctness or manufacturing certification. - -## Exploded assembly drawing and BOM - -A public-MCP workflow on NX v2606 created a rectangular solid prototype, added three occurrences, assigned absolute exploded positions, and produced an A3 drawing with an associative native BOM and grouped callout. The BOM aggregated the repeated prototype to quantity 3. The drawing view referenced the named explosion, and assembled occurrence positions remained unchanged. The exported PDF was downloaded, checksum-verified, rendered and visually inspected. All 27 calls succeeded without schema errors, retries or corrective calls; this count includes initial/final session inventories and fixture cleanup. - -An efficient sequence is: - -1. Record the current session with `nx_list_open_parts`. Create a uniquely named disposable prototype using `nx_create_part`, `nx_create_sketch`, `nx_sketch_rectangle`, `nx_finish_sketch`, `nx_extrude` and `nx_save_part`. -2. Create the assembly and add occurrences with `nx_add_component`. Retain returned IDs instead of listing them again. -3. Use `nx_create_explosion` and `nx_edit_explosion` for absolute exploded poses. The edit response includes pose readback and whether assembled placements were preserved; a routine workflow can omit a duplicate `nx_explosion_info` query. -4. Create the sheet with `nx_create_drawing`, then call `nx_add_base_view` with `scope="assembly"` and the explosion ID. Direct attachment avoids a separate `nx_show_explosion` or viewport-fit call. -5. Use `nx_create_parts_list` and `nx_parts_list_balloons`. Inspect the returned evaluated rows; no duplicate BOM read is required. Native grouping may produce one balloon for several identical occurrences. -6. Check sheet containment with `nx_drawing_view_info` and assembled placements with `nx_list_components`. Save, export with `nx_export_drawing_pdf`, and retrieve with `nx_download_file`. Verify bytes/checksum and inspect the rendered PDF. Restore the prior work/display part, close only the disposable parts, and verify the session inventory. - -**Presentation limitation:** the tested occurrences used the native `Entire Part` reference set, which includes datum geometry. Coordinate/datum arrows appeared beside the blocks in the PDF. No explosion trace lines were requested or created; those arrows must not be interpreted as disassembly instructions. The MCP surface currently exposes no reference-set editing control, so this workflow does not prescribe a geometry-only reference-set switch. Inspect the exported PDF before treating it as a manufacturing document; successful BOM aggregation and sheet containment do not establish presentation readiness. - -## Imported-part face editing - -An independent fresh-MCP workflow created a synthetic 20 × 15 × 10 mm solid, -exported it to STEP, imported it into a new part and moved its unique upward-facing -planar top face by 2 mm. Native volume changed from 3,000 to 3,600 mm³ and dimensions -from 20 × 15 × 10 to 20 × 15 × 12 mm. Save/close/reopen preserved both measurements; -new object IDs were acquired after reopen. This validates a simple planar face move, -not arbitrary vendor-model healing or topology changes. - -The workflow used 34 tool calls plus one fresh catalog read, with no errors or -corrective calls. The original saved session and assembly placements were restored. -For a routine workflow, retain the edited body and health information returned by -`nx_edit_faces`; separate `nx_list_bodies` and `nx_model_health` calls duplicated -those results in this evaluation. An explicit seed save immediately before STEP -export was also redundant because export saves the work part. Account for that -side effect when choosing the disposable part boundary. - -Select the face with `nx_find_geometry` using planar type, normal and location, -require an unambiguous match, and use its typed ID in `nx_edit_faces`. Measure native -bounds and volume before/after; reacquire references after reopen. Keep the source -and edited part separate and close only the disposable fixtures during cleanup. - -## Four-wall sheet-metal fixture - -An independent agent created a 100 × 80 × 2 mm web with four shortened 90-degree -walls, leaving deliberate corner gaps. Native health and thickness checks passed. -Unbend and rebend committed successfully; final formed volume returned to the -original value. Intermediate flattened bounds were not independently asserted. -The exported DXF extents, 115.498230 × 135.498230 mm, matched an independent -bend-allowance calculation within 0.000001 mm; the PNG visibly showed four walls. -This is an open tray fixture, not a sealed or production-qualified enclosure. - -The evaluation recorded 49 workflow/recovery calls and four preparation schema -calls. An invalid flat-pattern orientation edge was rejected and its rollback -receipt checked before correction. After forming, use `nx_sheet_metal_info` and -geometry selection to reacquire a current straight boundary/tangent edge of the -stationary web for `x_axis_edge`; the original tab outer-edge location is no longer -reliable. The corrected selection produced a native flat pattern. Two local -response-parsing mistakes were evaluator errors, not NX failures. - -Retain typed references under their actual response fields (`part.id`, for -example), distinguish selected feature bends from duplicate historical information, -and reacquire geometry after rollback. Save/export the disposable fixture, restore -the prior work/display part, close the fixture and compare the original inventory. - -## Principal-axis revolve recipe - -The final dev15 runtime independently verified this millimeter fixture: - -1. Create an XY sketch and a rectangle with local corners `[1,0]` and `[3,5]`. -2. Finish the sketch and retain its typed ID. -3. Call `nx_revolve` with `sketch_name` equal to that ID, `axis="Y"`, `angle=360`, - and `boolean="none"`. The axis passes through the part origin; there is no - arbitrary-origin argument. `sketch_name` is required in the published schema. -4. Measure the resulting annular cylinder. Expected volume is - `π × (3² − 1²) × 5 = 125.66370614359172 mm³`; native measurement was - `125.66370614359175 mm³`. Exact bounds were `[-3,0,-3]` to `[3,5,3]`. -5. Save, close and reopen the disposable part, then measure again. The volume - persisted. A new load reports `already_loaded=false` and `Opened part`; - another open of the same loaded file reports `already_loaded=true` and - `Reused loaded part`. Inspect returned work/display flags for activation state. - -The test restored the original saved session and captured a native PNG. It covers -this finished XY profile and principal Y axis, not arbitrary custom-axis geometry. - -## Drawing reference geometry and compact inspection (dev16) - -For a clean assembly drawing, create a custom reference set in each prototype with -`nx_create_reference_set(name="SOLIDS", objects=[body_id, ...])`, save the prototype, -and assign it to direct occurrences with `nx_set_component_reference_set` in their -owning assembly. The assignment leaves component poses unchanged. Nested children -require activating their owning assembly. Hide the assembly's own datum geometry -with `nx_set_datum_visibility(visible=false)` before creating the drawing view. -Prototype reference sets alone do not hide assembly-owned coordinate systems. -`nx_list_datums` inspects the affected owned objects; `nx_restore_display` restores -the returned snapshot in reverse order. Existing drafting views may require an -explicit view update; `nx_edit_drawing_view` with its current position updates it. - -Use `nx_list_topology(body=..., face=..., include_adjacency=true)` for current face -boundaries and bidirectional edge adjacency. For flat patterns, -`nx_flat_pattern_orientation_edges(upward_face=...)` returns straight boundary -candidates with endpoints. Choose the desired axis from those endpoints and pass -its opaque ID as `x_axis_edge`; reacquire after geometry edits or rollback. This is -geometric eligibility, not a promise that every candidate will pass the native -Flat Pattern commit on every formed body. - -For inventory use `nx_list_open_parts(compact=true)` and -`nx_list_components(compact=true, include_transforms=false)`. Full responses remain -the default. Compact references retain identity and occurrence paths. Filters -precede pagination; `count` counts returned rows and `total_count` counts matching -rows. `path_prefix` uses absolute Windows host paths, with either slash style. -Request transforms when checking placement, rather than inferring them from an -inventory without pose fields. - -Advanced-flange end planes were exercised on a 100×80×2 mm tab, 20 mm flange and -90° angle. A +X plane at x=10 trims one end; another at x=90 trims the other. Their -volumes were 19242.97335529231 and 18829.309649148734 mm³, versus -19656.637061435922 mm³ without trimming. `infer_length=true` in ByValue mode -produced the same geometry as the numeric-length baseline and does not establish -inference behavior. ToReference requires reference faces on the same body; -tested web/formed-wall combinations still failed native geometric construction. -No successful ToReference recipe is claimed. Failures restored baseline volume. diff --git a/docs/architecture.md b/docs/architecture.md index ca046ea..40931ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,17 +30,45 @@ 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. A long native call can block the UI. 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/authoring-review.md b/docs/authoring-review.md deleted file mode 100644 index 511e02f..0000000 --- a/docs/authoring-review.md +++ /dev/null @@ -1,41 +0,0 @@ -# Authoring and review tools (NX v2606) - -The dev5 release added 17 tools to the existing 77-tool profile. Dev6 adds ten more; see [advanced authoring](advanced-authoring.md). All native calls run serially on the NX UI thread. Existing operation IDs, deduplication and model rollback envelopes apply. Names remain separate from opaque session/owner-scoped references. - -## 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. - -## Validation - -Local tests use stateful NX seams to check ownership, error cleanup, stale preview rejection, retry behavior, artifact integrity and display restoration. They do not simulate the Siemens kernel. - -The native runner exercises eight groups on disposable geometry: expression binding/rollback and health, geometric selection/highlight, sketch reopening/constraints/deletion, assembly maintenance/patterns, saved presentations/camera, inspection ZIPs, preview acceptance/staleness, and summary pagination. The separate eleven-group visualization runner protects the preceding release. - -Run `examples/validate_authoring_tools.py` with `NX_MCP_TEST_ENDPOINT` and optionally `NX_AUTHORING_RESULTS`. It creates a unique workspace subdirectory and leaves test parts for inspection. It changes active parts and does not restore an unrelated user's session; use a dedicated validation session or preserve/restore the original session externally. The runner verifies downloaded artifact checksums and ZIP manifests. diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 6069ef4..79e7e80 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -4,7 +4,7 @@ Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by Run `python scripts/generate_capability_matrix.py` to regenerate, or add `--check` to detect drift. Manifest revision: **2606-agent-ux-r2**. NX: **v2606**. Bridge protocol: **1**. -Canonical manifest SHA-256: `63274526e0132077e6920f0c690d9004ab51295c3ad83ae4e04ebff85a019f1a`. +Canonical manifest SHA-256: `adf9794221153a9d312ac1b48afa3eba03d8f1f8f1699f1c6de66913750cf864`. 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. @@ -44,7 +44,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Saved part closure; NX may unload unused prototypes. Closed-part reporting invalidates all unloaded part 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/authoring-review.md for supported operations and limits. | +| 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. | @@ -71,7 +71,7 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| 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 PDF plot export with A3 page size, two views and 10mm dimension; file parsed and visually reviewed. | @@ -82,7 +82,7 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | 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. | @@ -90,10 +90,10 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| 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/authoring-review.md for supported operations and limits. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | @@ -103,7 +103,7 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | 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. | @@ -118,8 +118,8 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.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/authoring-review.md for supported operations and limits. | +| 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 | Already-loaded paths reused without close/recreation | | nx_operation_status | Contract/sidecar-tested | tested | local_contract_test | Durable committed/failed/unknown receipt tests; no crash reconstruction claimed | @@ -128,10 +128,10 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| 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/authoring-review.md for supported operations and limits. | +| 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_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. | @@ -141,21 +141,21 @@ These labels report manifest evidence, not certification or independent verifica | 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/authoring-review.md for supported operations and limits. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | | nx_save_part | Native-tested | tested | real_NX_v2606 | Save with documented native mark expiration | -| 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/authoring-review.md for supported operations and limits. | +| 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/authoring-review.md for supported operations and limits. | +| 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/authoring-review.md for supported operations and limits. | +| 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. | diff --git a/docs/dev10-validation.json b/docs/dev10-validation.json deleted file mode 100644 index 18441c1..0000000 --- a/docs/dev10-validation.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "version": "0.2.0.dev10", - "runtime_commit": "80eed43bc8339494717ab0b0daa37191bc17c1f4", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 140, - "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/33999666551", - "release_build": "https://github.com/xuio/NX_MCP/actions/runs/33999684234", - "release_sha256": "78555841cad6821908a614dd5dca962d60a66123894e8c45cb09cf914d7261a6", - "native_creation_families_passed": 34, - "public_workflow_checks": [ - "tab_analytic_volume_flange_bend_info_retry_pmi", - "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", - "unsupported_edit_unchanged_checkpoint_rollback", - "path_sketch_secondary_contour_analytic_volume" - ], - "public_workflow_passed": true, - "original_session_restored": true, - "original_saved_parts": 38, - "original_occurrences_verified": 116, - "local_validation": { - "pytest_passed": 666, - "pytest_skipped": 1, - "combined_statement_branch_coverage_percent": 79.9, - "unchanged_coverage_gate_percent": 78, - "mypy": "passed", - "pre_commit": "passed", - "ci": "passed" - }, - "windows_validators": { - "stdio_tools": 140, - "http_tools": 140, - "native_inline_capture_checksum": "passed", - "serialized_ui_thread": "verified" - }, - "developed_dimensions_mm": { - "bracket.dxf": [ - 97.749115, - 100.0 - ], - "bracket-edited.dxf": [ - 100.0, - 102.749115 - ] - }, - "visual_review": { - "render": "Native 1600x1000 PNG: folded bracket and readable measured-snapshot PMI, unclipped.", - "drawing": "Native one-page A4 PDF: centered developed perimeter and bend/tangent lines, unclipped; no manufacturing-drawing completeness claim.", - "vm": "Controller restored in the visible graphical NX session; no blocking dialog." - }, - "scopes": [ - "The 34-family native fixture evidence is in sheet-metal-native-validation.json. It does not verify every option or edit combination.", - "Part defaults inherited correctly: native 3 mm radius read back as 2.9999999999999996. Acceptance uses numerical tolerance.", - "Native path sketch and secondary contour flange also passed through public MCP with independent 300 mm^3 volume.", - "The acceptance script was corrected after deployment to read expression-valued defaults, use geometry tolerance and pass the existing orientation argument. Runtime geometry code was unchanged.", - "Measured PMI snapshots require explicit refresh; automatic numeric text updates and drawing bend tables are not implemented.", - "Metaform and manufacturing nesting are not exposed; Remove Bends was unavailable on the tested installation.", - "Material/tool-table and custom bend-table workflows remain experimental.", - "Edge rip passed for an interior planar-face sketch slit; selected-edge ripping remains unverified." - ], - "pull_request_opened": false -} diff --git a/docs/dev11-validation.json b/docs/dev11-validation.json deleted file mode 100644 index 6b09df5..0000000 --- a/docs/dev11-validation.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "version": "0.2.0.dev11", - "runtime_commit": "b88c7100cac34127aad722c6ac2ea1ba3c836971", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 160, - "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/34005240174", - "release_build": "https://github.com/xuio/NX_MCP/actions/runs/34005239661", - "release_sha256": "1e9dba523612a9c7fcf0a8d9c32f154aa64ea6d756e6850223aadac200668cb4", - "local_validation": { - "pytest_passed": 706, - "pytest_skipped": 1, - "combined_statement_branch_coverage_percent": 79.49, - "unchanged_coverage_gate_percent": 78, - "mypy": "passed", - "pre_commit": "passed", - "ci": "passed" - }, - "native_trace_persistence": { - "created_endpoints_mm": [ - [ - 20, - 5, - 5 - ], - [ - 40, - 5, - 5 - ] - ], - "reopened_endpoints_mm": [ - [ - 20, - 5, - 5 - ], - [ - 40, - 5, - 5 - ] - ], - "edited_endpoints_mm": [ - [ - 20, - 5, - 5 - ], - [ - 60, - 5, - 5 - ] - ], - "anchor_scheme": "UF.Tag persistent handles for occurrence and prototype edge" - }, - "native_additional_fixtures": [ - "G1 and G2 bridge builder creation", - "symbolic and detailed external threads", - "tangent plane/quadratic-surface continuity G0=true G1=true G2=false" - ], - "scopes": [ - "Native capabilities are scoped, not blanket certification of all licenses, inputs or options.", - "Sampled continuity, thickness and draft do not certify global extrema or manufacturability.", - "Trace endpoints refresh through MCP explosion edits/show/animation; manual NX edits require explicit refresh.", - "Thread dimensions are manual; no standards-table fit class is implied.", - "Native PMI supports the published datum/single-frame fields; not every GD&T modifier.", - "Imported-face wrappers were verified on controlled solids; arbitrary damaged vendor B-reps remain unverified." - ], - "public_workflow_checks": [ - "associative_3d_spline_edit_and_idempotent_retry", - "native_mesh_sew_thicken_and_matching_surface_continuity", - "native_sheet_trim", - "native_bridge_and_deliberate_gap_detection", - "tangent_but_curvature_discontinuous_surface_pair", - "move_offset_replace_analytic_volumes", - "delete_heal_wall_thickness_draft_and_native_pmi", - "native_symbolic_thread", - "native_detailed_thread", - "native_bom_quantity_update_balloons_trace_animation_and_pdf" - ], - "public_workflow_passed": true, - "original_session_restored": true, - "native_pdf_from_modeling": { - "single_sheet": "passed", - "two_sheets": "passed", - "prior_modeling_view_restored": "passed" - }, - "visual_review": { - "animation": "Three native PNG frames; full motion framed, exact first-to-second trace, no ghost geometry.", - "drawing": "Native A3 PDF: four separated prototype instances, quantity 4, grouped associative balloon and trace; no complete manufacturing-drawing claim." - }, - "public_pdf_reopen_animation_restoration": true, - "protected_sheet_metal_checks": [ - "tab_analytic_volume_flange_bend_info_retry_pmi", - "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", - "unsupported_edit_unchanged_checkpoint_rollback", - "path_sketch_secondary_contour_analytic_volume" - ], - "protected_sheet_metal_passed": true, - "original_saved_parts": 38, - "original_occurrences_verified": 116, - "windows_validators": { - "stdio_tools": 160, - "http_tools": 160, - "native_inline_capture_checksum": "passed", - "serialized_ui_thread": "verified", - "installed_runtime_matches_release": true - }, - "offline_bundle_verified_files_before_evidence_overlay": 197, - "public_acceptance_initial_runtime": "b2f1acc9709463ab4298dd61de9fb7152463fabf", - "final_runtime_regressions": [ - "native and public PDF export from modeling view", - "persistent trace refresh and animation after saved assembly reopen", - "four protected sheet-metal groups", - "Windows stdio/HTTP and final 38-part/116-occurrence preservation" - ] -} diff --git a/docs/dev12-validation.json b/docs/dev12-validation.json deleted file mode 100644 index 9f2a127..0000000 --- a/docs/dev12-validation.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "version": "0.2.0.dev12", - "runtime_commit": "c3bdb52814f18d67b9df3ed7dd42e7a795fb9ba5", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 170, - "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/34023328645", - "release_build": "https://github.com/xuio/NX_MCP/actions/runs/34023334813", - "release_sha256": "cf46f43065906c8eeceeb9be41a7ea161019e1db8859ceb2becf1b489e954b21", - "local_validation": { - "pytest_passed": 715, - "pytest_skipped": 1, - "combined_statement_branch_coverage_percent": 78.51, - "unchanged_coverage_gate_percent": 78, - "mypy": "passed", - "pre_commit": "passed", - "ci": "passed" - }, - "added_tools": [ - "nx_list_drawings", - "nx_activate_drawing", - "nx_list_annotations", - "nx_edit_annotation", - "nx_parts_list_column", - "nx_edit_explosion_trace", - "nx_thread_catalog", - "nx_standard_thread", - "nx_bend_table", - "nx_refresh_annotations" - ], - "public_workflow_checks": [ - "rounded_enclosure_analytic_shell_step_roundtrip_local_edit_rollback_reopen", - "curved_parabolic_G2_join_and_deliberate_tangent_discontinuity", - "thin_wall_measurement_and_outside_ray_origin_rejection", - "known_five_degree_draft_signed_transition_and_threshold", - "native_bend_table_managed_PMI_transactional_update_and_persistent_reopen", - "standard_table_thread_False_and_GDT_modifiers", - "standard_table_thread_True_and_GDT_modifiers", - "sheet_metal_assembly_explosion_trace_edit_BOM_columns_balloon_placement_drawings" - ], - "public_workflow_passed": true, - "annotation_recovery": { - "automatic_without_table_edit": true, - "operation_retry_deduplicated": true, - "table_rows": [ - [ - "1", - "85,00", - "3,00" - ] - ], - "disable_preserves_measured_snapshot": true, - "rollback_restored_annotation_and_source": true, - "passed": true, - "session_restored": true - }, - "native_thread_cases": [ - { - "internal": false, - "detailed": false, - "pitch_mm": 1.0, - "volume_before_mm3": 263.66158778851576, - "volume_after_mm3": 263.66158778851576 - }, - { - "internal": false, - "detailed": true, - "pitch_mm": 1.0, - "volume_before_mm3": 263.66158778851576, - "volume_after_mm3": 235.58194147836767 - }, - { - "internal": true, - "detailed": false, - "pitch_mm": 1.0, - "volume_before_mm3": 3803.650459150639, - "volume_after_mm3": 3803.650459150639 - }, - { - "internal": true, - "detailed": true, - "pitch_mm": 1.0, - "volume_before_mm3": 3803.650459150639, - "volume_after_mm3": 3769.746530347013 - } - ], - "numeric_evidence": { - "enclosure_shell_volume_mm3": 40369.1325000739, - "curved_join_checks": { - "G0": true, - "G1": true, - "G2": true - }, - "sampled_thin_wall_mm": 0.2, - "bend_table_before": [ - [ - "1", - "75,00", - "3,00" - ] - ], - "bend_table_after": [ - [ - "1", - "80,00", - "3,00" - ] - ] - }, - "protected_freeform_checks": [ - "associative_3d_spline_edit_and_idempotent_retry", - "native_mesh_sew_thicken_and_matching_surface_continuity", - "native_sheet_trim", - "native_bridge_and_deliberate_gap_detection", - "tangent_but_curvature_discontinuous_surface_pair", - "move_offset_replace_analytic_volumes", - "delete_heal_wall_thickness_draft_and_native_pmi", - "native_symbolic_thread", - "native_detailed_thread", - "native_bom_quantity_update_balloons_trace_animation_and_pdf" - ], - "protected_sheet_metal_checks": [ - "tab_analytic_volume_flange_bend_info_retry_pmi", - "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", - "unsupported_edit_unchanged_checkpoint_rollback", - "path_sketch_secondary_contour_analytic_volume" - ], - "deployment": { - "stdio_tools": 170, - "http_tools": 170, - "native_inline_screenshot_checksum": "passed", - "saved_parts": 38, - "unchanged_component_occurrences": 116, - "main_thread_dispatch": true - }, - "original_session_restored": true, - "visual_review": { - "service_pdf": "Complete exploded enclosure/bracket view, native trace, evaluated BOM and associative balloons within A3 sheet. Test fixture, not a complete manufacturing drawing.", - "bracket_pdf": "Native flat-pattern view and bend-table row at 80 degrees; no clipping.", - "assembly_png": "Full native exploded enclosure and bent bracket with managed trace." - }, - "scopes": [ - "Native tests use millimeter parts; imperial and mixed-unit drafting have not been native-tested.", - "Curvature, draft and wall thickness remain sampled diagnostics, not global manufacturing certification.", - "ThreadTable tests select installed Metric Coarse M6 x 1.0 metadata in place; no catalog file is copied and no unexposed fit class is inferred.", - "Native FCF creation accepts published modifiers; this is not full GD&T standards validation.", - "Managed PMI and automatic bend tables refresh transactionally after MCP model mutations; call nx_refresh_annotations after manual NX changes.", - "NX v2606 automatic bend-table flag alone left stale rows after reopen; native builder rebuild in the transaction fixes the reproduced case.", - "Invalid managed-source rollback has local regression coverage; no blanket guarantee for every damaged vendor B-rep." - ], - "artifacts": [ - { - "file": "enclosure.png", - "sha256": "67bf37b47feb480bb9a58054d74ba9f173037a768815b9e3d258eb5f202dd9f5", - "size": 20815 - }, - { - "file": "bracket.pdf", - "sha256": "5a46ac0ffb9835f6ae0bd0f05e59aa0e8294d6f37e92ec7e5a2575bfc37fc20a", - "size": 21298 - }, - { - "file": "assembly.png", - "sha256": "f795309e78685a289cec680e0624a8c4113f16e3653e7bb546c5da196ad634f9", - "size": 25132 - }, - { - "file": "service.pdf", - "sha256": "f7a2069f4cf4f5fb365196cd4f794cf5940e81811f7beb839ac63e512fddfd0d", - "size": 26618 - } - ] -} diff --git a/docs/dev13-validation.json b/docs/dev13-validation.json deleted file mode 100644 index 0cac541..0000000 --- a/docs/dev13-validation.json +++ /dev/null @@ -1,309 +0,0 @@ -{ - "version": "0.2.0.dev13", - "runtime_commit": "7942284e0402f54d3ca54a6d6481b58a92a27c9c", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 179, - "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/34032586103", - "release_build": "https://github.com/xuio/NX_MCP/actions/runs/34032585472", - "release_sha256": "196eef7af7dc85b7abc9f958fbcb33d79ae0d10174fe30ac5252d532e35ba9ed", - "local_validation": { - "pytest_passed": 745, - "dedicated_nx_test_deselected": 1, - "combined_statement_branch_coverage_percent": 78.68, - "unchanged_coverage_gate_percent": 78, - "mypy": "passed", - "pre_commit": "passed", - "ci_jobs_passed": 13 - }, - "added_tools": [ - "nx_drawing_view_info", - "nx_edit_drawing_view", - "nx_add_section_drawing_view", - "nx_add_detail_drawing_view", - "nx_drawing_table", - "nx_geometry_anchor", - "nx_resolve_geometry_anchor", - "nx_update_assembly_documentation", - "nx_list_dimensions" - ], - "native_release_passed": true, - "native_suites": [ - { - "script": "validate_release_engineering.py", - "exit_code": 0 - }, - { - "script": "validate_documentation_manufacturing.py", - "exit_code": 0 - }, - { - "script": "validate_annotation_recovery.py", - "exit_code": 0 - }, - { - "script": "validate_freeform_manufacturing.py", - "exit_code": 0 - }, - { - "script": "validate_sheet_metal.py", - "exit_code": 0 - } - ], - "release_engineering_checks": [ - "native_view_edits_section_detail_dimension_title_revision_pdf_and_anchor_reopen", - "inch_volume_and_metric_inch_sheets_in_inch_part", - "thread_Metric Fine_right", - "thread_Metric Fine_left", - "thread_Inch UNC_right", - "thread_Inch UNC_left", - "mixed_unit_component_bounds_volume_and_clearance", - "prototype_resize_replace_mates_clearance_explosion_trace_BOM_and_drawing_propagation", - "two_bend_partial_width_channel_square_round_reliefs_analytic_flat_pattern", - "adjacent_mitered_flanges_native_validity_and_flat_export", - "vendor_step_native_offset_repair_rollback_and_persistent_face_reopen" - ], - "protected_documentation_checks": [ - "rounded_enclosure_analytic_shell_step_roundtrip_local_edit_rollback_reopen", - "curved_parabolic_G2_join_and_deliberate_tangent_discontinuity", - "thin_wall_measurement_and_outside_ray_origin_rejection", - "known_five_degree_draft_signed_transition_and_threshold", - "native_bend_table_managed_PMI_transactional_update_and_persistent_reopen", - "standard_table_thread_False_and_GDT_modifiers", - "standard_table_thread_True_and_GDT_modifiers", - "sheet_metal_assembly_explosion_trace_edit_BOM_columns_balloon_placement_drawings" - ], - "protected_annotation_recovery": { - "automatic_without_table_edit": true, - "operation_retry_deduplicated": true, - "table_rows": [ - [ - "1", - "85,00", - "3,00" - ] - ], - "disable_preserves_measured_snapshot": true, - "rollback_restored_annotation_and_source": true, - "passed": true, - "session_restored": true - }, - "protected_freeform_checks": [ - "associative_3d_spline_edit_and_idempotent_retry", - "native_mesh_sew_thicken_and_matching_surface_continuity", - "native_sheet_trim", - "native_bridge_and_deliberate_gap_detection", - "tangent_but_curvature_discontinuous_surface_pair", - "move_offset_replace_analytic_volumes", - "delete_heal_wall_thickness_draft_and_native_pmi", - "native_symbolic_thread", - "native_detailed_thread", - "native_bom_quantity_update_balloons_trace_animation_and_pdf" - ], - "protected_sheet_metal_checks": [ - "tab_analytic_volume_flange_bend_info_retry_pmi", - "flat_pattern_dxf_geo_drawing_pdf_reopen_stale", - "unsupported_edit_unchanged_checkpoint_rollback", - "path_sketch_secondary_contour_analytic_volume" - ], - "numeric_evidence": { - "inch_fixture_volume_mm3": 8193.532, - "mixed_assembly_clearance_mm": 13.65, - "assembly_resize_dimension_mm": 8.0, - "assembly_rebound_dimension_mm": 10.0, - "assembly_replacement_clearance_mm": 10.0, - "sheet_channel": { - "bend_count": 2, - "developed_dimensions": [ - 100.0, - 120.49823 - ], - "analytic_dimensions": [ - 100, - 120.49822911213865 - ], - "reliefs": [ - "Square", - "Round" - ] - } - }, - "public_vendor_fixture": { - "name": "KiCad USB4085", - "sha256": "82235f7275d07f720e3c050f781397f4bef47fdc7e15dd507d68ccba861f1a35", - "source": "https://gitlab.com/kicad/libraries/kicad-packages3D/-/blob/8fb0194639525261cd642ec40d62ee26e1f601de/Connector_USB.3dshapes/USB_C_Receptacle_GCT_USB4085.step", - "native_offset_rollback_and_reopen": "passed" - }, - "deployment": { - "stdio_tools": 179, - "http_tools": 179, - "native_inline_screenshot_checksum": "passed", - "saved_parts": 38, - "unchanged_component_occurrences": 116, - "main_thread_dispatch": true, - "original_session_restored": true - }, - "visual_review": { - "service_pdf": "Native section through an 8mm hole, 3:1 circular detail, 50mm dimension, editable revision/title tables and reopened title edit.", - "assembly_pdf": "Updated two-row BOM, recreated native balloons, 10mm reassociated dimension and managed explosion trace.", - "mixed_units_pdf": "Metric and inch drawing sheets in an inch part, with native aligned projected views.", - "native_renders": "Adjacent mitered sheet-metal flanges and public USB connector STEP geometry." - }, - "scope_limits": [ - "Validation covers bounded NX v2606 fixtures; it is not blanket certification of every feature or manufacturing standard.", - "Section view authoring creates a native simple section; complex stepped sections are outside this tool.", - "Detail center/radius are explicit model coordinates, not automatic material-point tracking across arbitrary shape edits.", - "Component replacement can retain old dimensions or balloons; diagnostics mark documentation incomplete and explicit reassociation/recreation is required.", - "Geometry anchors resolve exact surviving owned native entities; no geometric nearest-neighbor substitution or occurrence anchors.", - "Sheet-metal flat-pattern numeric validation covers the two-bend channel; adjacent mitered-corner validation covers native validity and export.", - "Thread cases add Metric Fine M6x0.75 and Inch UNC 1/4-20 in both hands; no complete standards/fit-class certification.", - "Existing wall-thickness/draft/curvature diagnostics remain bounded sampling." - ], - "artifacts": [ - { - "path": "documentation/assembly.png", - "size": 24696, - "sha256": "f4412b7fc3146347abf29f590d4411b9ed2a33def9da110fed2bc200b9b20929" - }, - { - "path": "documentation/bracket.pdf", - "size": 21300, - "sha256": "1b6bd77db2e0e2bc2749ed27b5ee5ef2f426e25d42c5a77f2dde78d2172585c4" - }, - { - "path": "documentation/documentation-manufacturing-validation.json", - "size": 125022, - "sha256": "6bb8532b15302230bf58f09a618ce5811d793d66320c20fe3b74a5501db3eb39" - }, - { - "path": "documentation/enclosure.png", - "size": 20722, - "sha256": "120496d2d28ee44cab016023414f7f43977488114cec1c7c5593141f42eaf7e1" - }, - { - "path": "documentation/refresh-check.json", - "size": 314, - "sha256": "396e9ffbafb6dd9b22323b4f59cfc10918fa0378b44e43779d46676b5e9c7cb3" - }, - { - "path": "documentation/service.pdf", - "size": 26316, - "sha256": "9fcea4fa90d1b54e8ca3faf61bec5c3f94ea5f6a503de4de9a14f4630d769c33" - }, - { - "path": "freeform/animation.html", - "size": 48925, - "sha256": "471fcbe5ead5242fea6d9fd49f594ea2ed0f06d6121047a2a3e01491620b7cd1" - }, - { - "path": "freeform/freeform-manufacturing-validation.json", - "size": 30191, - "sha256": "4f1d9747029712a9b9906f451340e24b80e90fb50044bcc7d37c8915c37edd46" - }, - { - "path": "freeform/service.pdf", - "size": 20541, - "sha256": "58f42d9fb724192cc231893b02dcbc712e39d56ab898b8a837bfe32653606bdc" - }, - { - "path": "release_engineering/assembly.pdf", - "size": 24100, - "sha256": "8c99e78592513b37bc66506de3c2194d7199783ed1aa7587119320bbe0a184e4" - }, - { - "path": "release_engineering/channel.dxf", - "size": 98241, - "sha256": "748ccd7ceee4b92711116df76d66df51973be4f2299ae32fd32b94036d07fc19" - }, - { - "path": "release_engineering/corner.dxf", - "size": 96156, - "sha256": "a2bbddb027897a8c4a62bfbb91858b3a52e75bd05bc52267c887fc6143365087" - }, - { - "path": "release_engineering/corner.png", - "size": 38583, - "sha256": "c7ba6d2959aafac44e1dfb2c9a44cc670ee1981e811351ae4753587c41d9cc25" - }, - { - "path": "release_engineering/mixed-units.pdf", - "size": 3594, - "sha256": "78d28f32004a2906d07df3a4cc681564c479937f9f369e50f462c6b10b3a979e" - }, - { - "path": "release_engineering/release-engineering-validation.json", - "size": 146140, - "sha256": "f9cc5e518171457e684387f140a9b75970db90bc87f711b984f21f7bd1140534" - }, - { - "path": "release_engineering/service-reopened.pdf", - "size": 42243, - "sha256": "ebe258d12db2d764063f2f88dbf167aeb125fda42216d7b6935eb8980de40ed1" - }, - { - "path": "release_engineering/service.pdf", - "size": 41575, - "sha256": "96a706e67acbad379bb34d900183415c4a52d03a5e7c19b668573219c1e4adb2" - }, - { - "path": "release_engineering/vendor.png", - "size": 64913, - "sha256": "2b6285de87b3ef6aedb3d1fac984cc21b4f710976f2f6f311b7bf57923d67ccd" - }, - { - "path": "sheet_metal/bracket-edited.dxf", - "size": 95203, - "sha256": "386f447fdc9ecb466b64aa7fd4f1ffa8870e4c8808924db9cc7180ab201da1fd" - }, - { - "path": "sheet_metal/bracket.dxf", - "size": 95199, - "sha256": "854d4bbc3668c7a6cd8594934bd9eb224aea517e71e251cffde9333776e33a3a" - }, - { - "path": "sheet_metal/bracket.geo", - "size": 905, - "sha256": "863211fcdca82295441761f4931596584a23d480691674675cd74873a0e8e451" - }, - { - "path": "sheet_metal/bracket.pdf", - "size": 2426, - "sha256": "bd20e6801851616d97c44eb76eee64f1e655f6909ea8bb53c1f7b5c7d194ca43" - }, - { - "path": "sheet_metal/bracket.png", - "size": 25762, - "sha256": "a7c5efaf6b359ec5f06abb9b852bd55a49c1f2bf9a794ab77e98fc3a6184a6f1" - }, - { - "path": "sheet_metal/sheet-metal-validation.json", - "size": 4242, - "sha256": "e6a0fb3254af5c388cb2e15e1c91cf641b1a7182cd79caf1e18c2eb8487e8da4" - }, - { - "path": "validate_annotation_recovery.log", - "size": 254, - "sha256": "b4be8e731a299040f027b619b1874565a6f3cab8f0e508c4fbed117a86728a23" - }, - { - "path": "validate_documentation_manufacturing.log", - "size": 547, - "sha256": "f33d9355e55cdd303e7b1ceb11a29ca15f6d675245876fdff660cc5940e755b8" - }, - { - "path": "validate_freeform_manufacturing.log", - "size": 465, - "sha256": "4e70cc3fd57847167b09e146d7fff23b05be9c7da74f28d1a8f81f78fee5e4e0" - }, - { - "path": "validate_release_engineering.log", - "size": 617, - "sha256": "5a60baa08b3cba389ef9d9f6ec0f325732505651d6ec7ac443c20e829d78bd12" - }, - { - "path": "validate_sheet_metal.log", - "size": 228, - "sha256": "e24612fe4a65a11e0afccae25e07c97d349db3dc5f1cc6cf1eb08b507170ea24" - } - ] -} diff --git a/docs/dev14-release.md b/docs/dev14-release.md deleted file mode 100644 index 0aff3ba..0000000 --- a/docs/dev14-release.md +++ /dev/null @@ -1,32 +0,0 @@ -# Consolidated dev14 release - -Dev14 packages the dev13 runtime, validation-runner fixes and capability metadata -together. No validation overlay is required. Installation removes obsolete overlay -metadata; rollback restores the prior runtime and its original metadata. - -Three failures reproduced during native acceptance are addressed: - -- Closing an assembly can unload unused prototypes despite `CloseWholeTree=False`. - The result now reports `closed_parts`, `closed_count` and `remaining_count`, and - invalidates references for every unloaded part. Re-list parts between closes. -- Native bridge receipt queries preserve the target operation ID, session and - mutation outcome. Query identity is separate. HTTP receipt queries already read - the durable store directly. -- The STEP translator can accept a truncated file and import partial geometry. - Missing exchange-file opening/closing markers now fail before translation. This - is a completeness check, not a complete STEP syntax or geometry validator. - -`examples/validate_hard_geometry.py` adds truncated vendor STEP, native hole-face -healing, removed anchors, rotated nested mixed-unit assemblies and three adjacent -mitered sheet-metal walls. It is included in the serial native release runner. - -`examples/validate_transport_recovery.py` runs on Windows beside the bridge. Set -`NX_BRIDGE_DESCRIPTOR`, `NX_WORKSPACE` and `NX_VALIDATION_OUTPUT`. It disconnects -before receiving a relative-move result, verifies the committed receipt, retries -the same ID, checks rollback and manual handoff, then restores the prior session. -Tokens remain local and are never written to test receipts. Keep the receipt to -verify it after a real bridge restart; an old receipt never restores old IDs. - -Native validation results must be recorded against the exact deployed package. - -The subsequent [agent UX review](agent-ux.md) adds focused capability queries, paged artifact listing, inline existing-PNG retrieval and a common structured output schema. Read-only calls no longer refresh model views; sheet-metal evidence status and unit conventions are explicit. diff --git a/docs/dev14-validation.json b/docs/dev14-validation.json deleted file mode 100644 index c23ec26..0000000 --- a/docs/dev14-validation.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "version": "0.2.0.dev14", - "runtime_commit": "0023441de6f486adc68935eabbd73e8af5481b09", - "release_sha256": "3c52035c91efae376abdba9de91dbe3f4b29e59312d4447c9145793f3fb65fa4", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 179, - "ci": "https://github.com/xuio/NX_MCP/actions/runs/34037571927", - "build": "https://github.com/xuio/NX_MCP/actions/runs/34037571466", - "automated_tests": 753, - "coverage_percent": 78.89, - "coverage_gate_percent": 78, - "native_suite_commit": "052a9d3c5ce50f0e600d30ce352f92b90a453b66", - "native_modules_unchanged_in_final_package": true, - "native_suites": [ - "hard_geometry", - "release_engineering", - "documentation_manufacturing", - "annotation_recovery", - "freeform_manufacturing", - "sheet_metal" - ], - "recovery_checks": [ - "transport_disconnect_committed_receipt", - "same_id_relative_move_not_repeated", - "checkpoint_rollback", - "manual_handoff_stale_references", - "committed_receipt_survives_real_bridge_restart" - ], - "ux_evaluations": 3, - "ux_improvements": [ - "focused_capabilities", - "consistent_capability_claims", - "no_readonly_view_refresh", - "inline_existing_png", - "targeted_file_metadata", - "paged_filtered_listing", - "chunk_bounds_and_continuation", - "common_structured_output_schema", - "explicit_boolean_operands_and_enums", - "explicit_revolve_origin", - "distinct_missing_directory_error" - ], - "limits": [ - "Tool-specific output fields remain extensible.", - "Advanced native geometry prerequisites are not fully expressible in JSON schemas.", - "STEP marker check detects incomplete framing, not arbitrary STEP syntax/geometry faults.", - "No general modeling or manufacturing certification." - ], - "final_transport_recovery_passed": true, - "final_restart_recovery_passed": true, - "windows_stdio_http_passed": true, - "native_suites_passed": true, - "final_native_smoke_passed": true, - "install_rollback_passed": true, - "original_saved_parts": 38, - "original_occurrences_preserved": 116 -} diff --git a/docs/dev15-validation.json b/docs/dev15-validation.json deleted file mode 100644 index dfcf70c..0000000 --- a/docs/dev15-validation.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "version": "0.2.0.dev15", - "runtime_commit": "077f222832369f96b64885c1eabed79e8ba6c60b", - "package_sha256": "cdd0547358be63acaa48567eb74d1fa90ff7412c4831bf555324ab12f548874a", - "nx_version": "v2606", - "bridge_protocol": 1, - "tools": 179, - "typed_success_payloads": 22, - "automated_tests_passed": 806, - "native_only_test_skipped": 1, - "coverage_percent": 79.0, - "coverage_gate_percent": 78, - "build": "https://github.com/xuio/NX_MCP/actions/runs/34041696528", - "ci": "https://github.com/xuio/NX_MCP/actions/runs/34041696775", - "acceptance_command": "scripts/accept_release.py", - "verify_only_then_resume_native_passed": true, - "native_suites_passed": 6, - "schema_live_checks_passed": true, - "analytic_revolve_and_reopen_passed": true, - "windows_stdio_http_passed": true, - "original_saved_parts": 38, - "original_component_records_preserved": 116, - "agent_workflows": { - "exploded_drawing_bom": { - "calls": 27, - "tool_errors": 0, - "evaluated_runtime": "55eebce" - }, - "imported_face_edit": { - "calls": 34, - "tool_errors": 0, - "redundant_calls": 3, - "evaluated_runtime": "f44ffb3" - }, - "sheet_metal_tray": { - "workflow_calls": 49, - "preparation_calls": 4, - "native_selection_errors": 1, - "confirmed_rollback_and_corrected_selection": true, - "evaluated_runtime": "f44ffb3" - } - }, - "fixes": [ - "tool_specific_geometry_measurement_artifact_recovery_schemas", - "typed_error_branch", - "required_revolve_sketch", - "sheet_metal_recipes_and_flat_orientation_guidance", - "STEP_autosave_checkpoint_description", - "accurate_open_reuse_message", - "shared_drive_acceptance_paths", - "repeatable_acceptance_receipts_lock_and_safe_resume", - "generated_evidence_matrix" - ], - "limits": [ - "Other tool-specific success payloads remain extensible.", - "Advanced flange reference/plane mode combinations still require further native evidence.", - "Entire Part drawings can include datum arrows; reference-set editing is not exposed.", - "Native acceptance covers selected fixtures, not general manufacturing certification.", - "Installed hashes do not attest bytes already loaded by a process; final deployment included a controlled restart." - ] -} diff --git a/docs/dev16-validation.json b/docs/dev16-validation.json deleted file mode 100644 index 8d1d80b..0000000 --- a/docs/dev16-validation.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "version": "0.2.0.dev16", - "deployed_commit": "d68441ad8ae33263043ee877825ed8128171fde4", - "zip_sha256": "3b22f2947b8c65cc73698d56a0581235d4d0867f5e9fa2a4e14ba2c979bca828", - "fork": "https://github.com/xuio/NX_MCP", - "build_run": 34046387111, - "ci_run": 34046387266, - "tools": 185, - "typed_success_payloads": 47, - "automated_tests_passed": 846, - "native_only_test_skipped_locally": 1, - "coverage_percent": 78.5, - "native_release_acceptance": "six_suites_passed_across_reconciled_runs", - "native_suite_count": 6, - "native_execution": "Four passing suites retained; failed freeform gate and unrun sheet-metal suite completed with corrected profile count. Original session restored after every run.", - "retained_harness_failure": "Freeform stopped before geometry because its obsolete tool-count assertion expected 179 instead of 185. The failed receipt is retained, not relabeled as passed.", - "native_agent_workflows": [ - "body-only reference sets assigned without pose changes; exploded drawing exported", - "flat pattern committed first attempt using discovered face-boundary edge", - "imported-face edit 3000 to 3600 mm3 persisted through reopen" - ], - "agent_workflows_runtime_provenance": "Agent workflows ran against e666c8e. All 58 NX runtime source files match the final deployed release byte-for-byte. Four native suites passed on e3d4c0a; two completed with corrected profile assertions against the same runtime.", - "session_preserved": { - "parts": 38, - "occurrences": 116 - }, - "compact_inventory_reduction_percent": { - "parts": 40.5, - "components_without_transforms": 53.9 - }, - "baseline_workflows": { - "exploded": { - "calls": 27, - "json_characters": 126993 - }, - "imported": { - "calls": 34, - "json_characters": 359158 - }, - "sheet_metal": { - "calls": 49, - "json_characters": 1049413 - } - }, - "current_workflows": { - "inventory": { - "calls": 5, - "json_characters": 186331 - }, - "exploded": { - "calls": 31, - "json_characters": 116220 - }, - "sheet_metal": { - "calls": 29, - "json_characters": 328673 - }, - "imported": { - "calls": 17, - "json_characters": 32799 - }, - "cleanup": { - "calls": 14, - "json_characters": 298349 - } - }, - "benchmark_method": "JSON characters of structured responses, counted once including artifact base64; not tokens. Baselines include individual setup/cleanup; current three workflows share 19 setup/cleanup calls and add new datum/reference-set/diagnostic checks. This is a recipe comparison, not an isolated backend performance A/B.", - "advanced_flange": { - "numeric_length": "passed", - "single_end_plane": "passed", - "paired_end_planes": "passed", - "by_value_infer_length_true": "committed, geometry identical to numeric baseline; inference not established", - "to_reference": "No successful fixture established; web/formed-wall combinations rejected with baseline volume restored." - }, - "dxf": { - "extents": [ - 115.49823, - 135.49823 - ], - "expected": [ - 115.49822911213865, - 135.49822911213863 - ], - "tolerance_mm": 0.001, - "passed": true - }, - "artifacts": { - "review.pdf": { - "sha256": "8904633ac78a5326b0c2243e4e2fa02f789321a911c04a534e8bf0e32b6896e1", - "size": 19520 - }, - "sheet.dxf": { - "sha256": "6a9438b0413688bff118a284f6f3a1019be39e4accb8b20b12d824e9ee76b48f", - "size": 101407 - } - }, - "agent_discovery_review": "passed: 185 tools, 47 typed payloads, all six new descriptions actionable", - "upstream_pr_created": false -} diff --git a/docs/dev17-validation.json b/docs/dev17-validation.json deleted file mode 100644 index f1febe1..0000000 --- a/docs/dev17-validation.json +++ /dev/null @@ -1,181 +0,0 @@ -{ - "release": { - "commit": "dbb421ba0d11eb6ff94231730be9a6085164d4e5", - "sha256": "e3f730baf0382a63f40a97725fe3aa64e088d978c182479e23f3ab197d2ab9ab", - "version": "0.2.0.dev17", - "build_run": 34051262299 - }, - "full_endpoint": "/mcp", - "agent_endpoint": "/agent/mcp", - "visible_tools": { - "full": 185, - "agent": 11 - }, - "client_configuration": "nx-mcp URL updated to /agent/mcp; reconnect required", - "automated_tests": { - "passed": 859, - "skipped_dedicated_native": 1, - "branch_coverage_percent": 78.89, - "mypy_modules": 47, - "precommit": "passed", - "ci_run": 34051262537, - "ci": "passed" - }, - "native_workflows": { - "prefix": "validation/dev16-ux-9128540e", - "checks": [ - "body-only reference sets assigned without pose changes; exploded drawing exported", - "flat pattern committed first attempt using discovered face-boundary edge", - "imported-face edit 3000 to 3600 mm3 persisted through reopen" - ], - "passed": true, - "session_preserved": { - "parts": 38, - "occurrences": 116 - } - }, - "native_workflow_mode": "agent gateway with explicit full responses for geometry/schema assertions; separate compact-mode acceptance passed", - "compact_native": { - "status": "passed", - "checks": [ - "11 visible tools", - "bounded 38-part and 116-occurrence inventories", - "exact schema discovery", - "committed mutation deduplication", - "snapshot expansion", - "native PNG image and resource checksum", - "38 saved parts restored" - ], - "calls": [ - { - "tool": "nx_list_open_parts", - "error": false, - "chars": 7776 - }, - { - "tool": "nx_list_components", - "error": false, - "chars": 9425 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 24616 - }, - { - "tool": "nx_discover_tools", - "error": false, - "chars": 2902 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 547 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 565 - }, - { - "tool": "nx_operation_status", - "error": false, - "chars": 1073 - }, - { - "tool": "nx_result", - "error": false, - "chars": 1002 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 802 - }, - { - "tool": "nx_screenshot", - "error": false, - "chars": 1417 - }, - { - "tool": "nx_download_file", - "error": false, - "chars": 287 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 680 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 568 - }, - { - "tool": "nx_invoke", - "error": false, - "chars": 24616 - } - ] - }, - "windows_finalizer": { - "exit_code": 0, - "source": "completed exec session 58353; installed source hash comparison, stdio 185, HTTP 185, agent 11 and PNG checksum passed" - }, - "token_benchmark": { - "scope": "paired recorded response projection; same completed native tasks, not an autonomous-agent A/B trial", - "tokenizer": "o200k_base", - "serialization": "compact JSON structuredContent counted once; excludes protocol, duplicate text, image tokens and initial discovery", - "full_response_tokens": 414225, - "compact_response_tokens": 111949, - "response_token_reduction_percent": 72.97, - "recorded_calls": 96, - "recorded_errors": 0, - "binary_transfer_calls": 2, - "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" - }, - "catalog_benchmark": { - "full": { - "tools": 185, - "serialized_catalog_tokens": 102726 - }, - "agent": { - "tools": 11, - "serialized_catalog_tokens": 1881 - }, - "scope": "Serialized MCP tool catalog under o200k_base; excludes client wrapping and dynamic discovery; not billed input tokens" - }, - "limitations": [ - "No provider-reported model token usage or autonomous-agent A/B success comparison is available. Token counts are exact serialized-text tokenizer counts with explicitly excluded overhead.", - "Initial local compact helper incorrectly expected object instead of part; fixture was reconciled and corrected acceptance passed. No product code redeployment was needed.", - "Result snapshots persist until explicit workspace maintenance; NX references can become stale." - ], - "artifact_checks": { - "pdf": "visually checked: three exploded blocks, balloon and BOM; no datum axes", - "dxf": { - "passed": true, - "dimensions_mm": [ - 135.49823, - 115.49823 - ], - "expected_dimensions_mm": [ - 115.49822911213865, - 135.49822911213863 - ], - "absolute_tolerance_mm": 0.001 - }, - "step_download_sha256": "d7e77e16cd80d360cd9d67b5c53d601c941b0c514af08e7d2d57e169e074f3ba", - "whole_vm": "visually checked: original Baldower assembly, no blocking dialogs" - }, - "preservation": { - "saved_parts": 38, - "occurrences": 116, - "source_paths_transforms_suppression_reference_sets": "unchanged" - } -} diff --git a/docs/dev18-validation.json b/docs/dev18-validation.json deleted file mode 100644 index fe9f501..0000000 --- a/docs/dev18-validation.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "release": { - "version": "0.2.0.dev18", - "commit": "9254c028eac8ffdfeed54201377ba62a052b0a1c", - "sha256": "7462e053c4261e0e726611e9b7835b3efb11ee833f436658312a11c48af58021", - "build_run": 34053906206 - }, - "ci_run": 34053906444, - "automated_validation": { - "passed": 868, - "skipped": 1, - "skip_reason": "Dedicated real-NX test requires its runner; separate live checks completed", - "branch_coverage_percent": 79.08, - "mypy_modules": 49 - }, - "deployment": { - "complete_package": true, - "offline_verified_files": 247, - "windows_stdio_http_source_validation": "passed", - "native_inline_png_validation": "passed", - "agent_tool_count": 13, - "full_tool_count": 185 - }, - "implemented": [ - "Prerequisites, supported object kinds, examples and native evidence in discovery", - "Task-specific compact receipt follow-up hints", - "Filtered snapshot inspection and pagination", - "Conservative mutation and side-effect guidance", - "Snapshot age/storage retention and explicit cleanup", - "Fresh serial agent trials with analytic geometry verification" - ], - "guidance_scope": { - "curated_workflows": 17, - "unreviewed_effects": null, - "native_status_source": "Existing version-specific capability manifest; not all tools newly native-tested" - }, - "fresh_agent_comparison": { - "sample_size_per_profile": 1, - "baseline": "dev17 full profile", - "candidate_commit": "f91cb2b628068188baa50a9e6e31353e8ddbbd8e", - "tasks": [ - "80 x 50 x 4 mm plate with two radius-3 through holes", - "Three-instance assembly at X=0,100,200 mm", - "STEP export/import geometry equivalence" - ], - "profiles": { - "full": { - "explicit_mcp_calls": 59, - "discovery_and_catalog_calls": 24, - "reported_tool_errors": 0, - "reported_retries": 1, - "response_and_discovery_text_tokens_o200k_base": 66957, - "analytic_geometry_audit": "passed: two plate volumes, two plate bounds, assembly bounds" - }, - "agent": { - "explicit_mcp_calls": 59, - "discovery_and_catalog_calls": 20, - "reported_tool_errors": 0, - "reported_retries": 1, - "response_and_discovery_text_tokens_o200k_base": 46589, - "analytic_geometry_audit": "passed: two plate volumes, two plate bounds, assembly bounds" - } - }, - "both_profiles_all_tasks_passed": true, - "provider_token_usage": null, - "limitations": [ - "One trial per profile; no statistical efficiency conclusion", - "Response tokenizer counts are not provider usage or total agent-context cost", - "Full-profile discovery uses local catalog filtering in the benchmark client", - "Initial connection retries occurred before NX mutation" - ] - }, - "post_trial_fixes": { - "commit": "9254c028eac8ffdfeed54201377ba62a052b0a1c", - "agent_read_only_followup": "passed", - "checks": [ - "38 requested part rows retained with coherent pagination", - "Spaced discovery query ranks nx_create_part first", - "Output schema omitted on request", - "Inspection terminal page coherence", - "Cleanup dry run preserves recovery receipts" - ], - "multi_page_inspection": "Exercised separately in initial dev18 native inspection; final follow-up checked terminal page only" - }, - "preservation": { - "saved_original_parts": 38, - "unchanged_original_occurrences": 116, - "active_original_assembly_restored": true, - "serial_ui_main_thread_verified": true - }, - "remaining_observations": [ - "Broad discovery queries can return secondary matches", - "Benchmark client connection failures still emit verbose tracebacks", - "Exact provider model identifier and usage unavailable" - ] -} diff --git a/docs/dev3-validation.json b/docs/dev3-validation.json deleted file mode 100644 index 2e748ef..0000000 --- a/docs/dev3-validation.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "version": "0.2.0.dev3", - "source_commit": "2148867ed86800b54749944a3eae2b3a9bcc6bd7", - "fork": "https://github.com/xuio/NX_MCP", - "nx_version": "v2606", - "tool_count": 77, - "local_tests": { - "passed": 183, - "skipped": 1, - "deselected": 1 - }, - "lint": "passed", - "type_check": "passed", - "pre_commit": "passed", - "hosted_test_matrix": { - "passed": 9, - "total": 9 - }, - "coverage": { - "percent": 50.63, - "required": 78, - "status": "failed", - "threshold_unchanged": true - }, - "public_native_groups": { - "passed": 11, - "total": 11 - }, - "windows_stdio": "passed", - "windows_http": "passed", - "offline_install_rollback_test": "passed", - "two_builds_byte_identical": true, - "archive_sha256": "f4387913f2d6adbd0671ecaa733286cb651bd1061636729cb243102f42b572c9", - "ci_url": "https://github.com/xuio/NX_MCP/actions/runs/33971909403", - "release_workflow_url": "https://github.com/xuio/NX_MCP/actions/runs/33972182302", - "release_workflow": "passed" -} diff --git a/docs/dev4-validation.json b/docs/dev4-validation.json deleted file mode 100644 index e8466ed..0000000 --- a/docs/dev4-validation.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "version": "0.2.0.dev4", - "source_commit": "5e4d66eacbee8c45257c5c0088c90bf32c447efc", - "fork": "https://github.com/xuio/NX_MCP", - "nx_version": "v2606", - "tool_count": 77, - "local_tests": { - "passed": 303, - "skipped": 1, - "deselected": 1 - }, - "lint": "passed", - "type_check": "passed", - "pre_commit": "passed", - "hosted_ci": { - "status": "passed", - "test_matrix_passed": 9, - "test_matrix_total": 9, - "url": "https://github.com/xuio/NX_MCP/actions/runs/33974202492" - }, - "coverage": { - "percent": 79.59, - "required": 78, - "status": "passed", - "threshold_unchanged": true, - "scope_unchanged": true - }, - "runtime_fixes": [ - "Restore rendering style even when screenshot builder destruction fails", - "Rollback temporary sketch work region even when deactivation fails", - "Preserve explicit partial outcome when native inspection cleanup fails" - ], - "public_native_groups": { - "passed": 11, - "total": 11, - "names": [ - "schemas_and_visible_ui", - "underconstrained_diagnostics_preserve_state", - "color_transparency_and_face_restore", - "visibility_restore_and_order_preflight", - "native_section_lifecycle_and_saved_state", - "nested_collision_highlighting", - "nested_isolation_and_previous_visibility", - "occurrence_appearance_does_not_recolor_prototype", - "assembly_section_preserves_geometry", - "fully_constrained_fixture_diagnostics", - "manual_handoff_clears_highlights" - ] - }, - "windows_stdio": "passed", - "windows_http": "passed", - "archive_sha256": "b8271895eb77b7e4a257e9a9de9cb7f7ef37cb59c50b7bec19add8f3e5c2fa1e", - "release_workflow": { - "status": "passed", - "url": "https://github.com/xuio/NX_MCP/actions/runs/33974451357", - "archive_sha256": "808048064db0e3114b7f7e4dd5d889bec4e32967bc0d24e8dace7072275b98b0", - "source_byte_identical_to_local": true, - "archive_byte_identical_to_local": false, - "difference": "Windows-generated metadata uses CRLF, changing wheel RECORD and manifest hashes; archive entry ordering also differs." - }, - "session_preservation": { - "restored_parts": 38, - "modified_parts": 0, - "verified_component_placements": 116, - "design_geometry_changed": false - }, - "limitations": [ - "Fault-injected cleanup failures are covered by local stateful NX test doubles, not forced in the Siemens kernel.", - "Native acceptance covers the 11 listed groups on NX v2606; it does not certify all exposed tools." - ], - "same_platform_rebuild_byte_identical": true -} diff --git a/docs/dev5-validation.json b/docs/dev5-validation.json deleted file mode 100644 index 29a6e35..0000000 --- a/docs/dev5-validation.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "version": "0.2.0.dev5", - "source_commit": "9b89fb5daaefdd900a7c404b3329f9791749ac88", - "fork": "https://github.com/xuio/NX_MCP", - "nx_version": "v2606", - "tool_count": 94, - "new_tool_count": 17, - "features": [ - "geometric selection and highlighting", - "expressions and supported parameter binding", - "model health and pending-update rebuild", - "atomic sketch editing", - "inspection report ZIPs with native viewport images", - "component maintenance and independent instance patterns", - "camera and saved presentations", - "reversible change previews with guarded accept/discard", - "paginated model summaries" - ], - "local_tests": { - "passed": 393, - "skipped": 1, - "deselected": 1 - }, - "coverage": { - "percent": 81.49, - "required": 78, - "threshold_unchanged": true, - "scope_unchanged": true - }, - "lint": "passed", - "type_check": "passed", - "pre_commit": "passed", - "hosted_ci": { - "status": "passed", - "url": "https://github.com/xuio/NX_MCP/actions/runs/33978613971", - "test_matrix_passed": 9, - "test_matrix_total": 9 - }, - "hosted_release": { - "status": "passed", - "url": "https://github.com/xuio/NX_MCP/actions/runs/33978733276" - }, - "native_authoring": { - "passed": 8, - "total": 8, - "groups": [ - "expressions_binding_health_rollback", - "geometric_selection_and_highlight", - "sketch_reopen_edit_constraints_delete", - "assembly_maintenance_and_instances", - "saved_presentation_and_camera", - "inspection_report_artifacts_restore", - "change_preview_accept_and_staleness", - "compact_summary_pagination" - ] - }, - "native_visualization": { - "passed": 11, - "total": 11 - }, - "supplemental_clash_report": { - "known_overlap_mm3": 500, - "geometry_preserved": true, - "camera_preserved": true, - "saved_state_preserved": true, - "zip_and_file_checksums_verified": true - }, - "windows_stdio": "passed", - "windows_http": "passed", - "artifact_downloads": "ZIP and PNG SHA-256 verified; ZIP member manifest verified", - "session_preservation": { - "restored_parts": 38, - "modified_parts": 0, - "verified_component_placements": 116, - "design_geometry_changed": false - }, - "archive_sha256": "1a5ca7bd3aeccaf004acc42dc8b09365dc33cf8607c746a9789eea00ed107cc0", - "limitations": [ - "Component patterns create independent occurrences; they are not associative native component patterns.", - "Parameter binding and sketch operations are limited to the explicitly documented types.", - "Geometric candidate ranking uses conservative bounds centers; use native distance for exact clearance.", - "Saved presentations require compatible journal references after model revisions.", - "Viewport PNGs are native raster captures; materials and photorealistic rendering are not added.", - "Native and local results describe tested scope, not general NX certification." - ] -} diff --git a/docs/dev6-validation.json b/docs/dev6-validation.json deleted file mode 100644 index f99cf4d..0000000 --- a/docs/dev6-validation.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "version": "0.2.0.dev6", - "source_commit": "d9b5a1c5dd46c4e75ece66425a9cf1844aad0034", - "fork": "https://github.com/xuio/NX_MCP", - "nx_version": "v2606", - "tool_count": 104, - "new_tool_count": 10, - "features": [ - "native BREP nearest geometry and reusable selection rules", - "bore axes and coaxial grouping", - "native associative component patterns with count/pitch editing", - "owned feature expression editing", - "native sketch dimensions and modern solver relations with geometric residual verification", - "bounded conflict diagnostics and explicit persistent-relation contradictions", - "prepared upstream review slices and draft first description; no PR opened" - ], - "local_tests": { - "passed": 453, - "skipped": 1, - "deselected": 1 - }, - "coverage": { - "percent": 82.68, - "required": 78, - "threshold_unchanged": true, - "scope_unchanged": true - }, - "lint": "passed", - "type_check": "passed (25 source files)", - "pre_commit": "passed", - "hosted_ci": { - "status": "passed", - "jobs_passed": 13, - "url": "https://github.com/xuio/NX_MCP/actions/runs/33981485352" - }, - "hosted_release": { - "status": "passed", - "url": "https://github.com/xuio/NX_MCP/actions/runs/33981514750" - }, - "native_advanced_staging": { - "passed": 9, - "total": 9, - "groups": [ - "exact_selection_edit_reopen_ambiguity", - "bore_axis_recognition", - "native_pattern_261_5_span_edit_reopen", - "native_dimensions_and_expression_edit", - "native_relations_and_diagnostics", - "owned_feature_parameters", - "horizontal_vertical_diameter_dimensions", - "two_curve_relation_types", - "conflict_sensitivity_and_restoration" - ] - }, - "deployed_public_mcp": { - "advanced": { - "passed": 8, - "total": 8 - }, - "prior_authoring": { - "passed": 8, - "total": 8 - }, - "prior_visualization": { - "passed": 11, - "total": 11, - "rerun": "Initial invocation omitted the fixed-fixture environment setting; the one affected check was rerun successfully. Original receipt retained." - }, - "supplemental_relation_persistence": "passed: equal radius follows driving-expression edits and survives save/reopen", - "total_successful_workflow_checks": 28 - }, - "windows_stdio": "passed: 104 tools", - "windows_http": "passed: 104 tools and native inline PNG checksum", - "installed_source_hashes": "match hosted release", - "session_preservation": { - "restored_parts": 38, - "modified_parts": 0, - "verified_component_placements": 116, - "design_geometry_changed": false - }, - "archive_sha256": "c4ee680145204109a1d2f86636c6379a9e597f29194845b1fc41a0c3e918fc2c", - "deployment_transport": "QGA completion response was unavailable; reconciled installed version, runtime backup, restarted bridge and all installed source hashes before proceeding. Installation was not retried.", - "limitations": [ - "Selectors re-evaluate geometric rules; they do not promise permanent topological identity across arbitrary edits.", - "Highest/lowest ordering still uses conservative bounds centers.", - "Bore recognition reports inward cylindrical faces; no threads, blind/through classification or manufacturing-feature inference.", - "Constraint sensitivity is bounded and is not a minimal conflicting set. Explicit legacy contradiction detection covers horizontal/vertical on a nonzero line only.", - "Modern sketch relation branches were validated natively. Legacy branch and reference-dimension guards have local boundary coverage only. Coincident selections support line endpoints and arc centers.", - "General feature expression APIs were validated natively on extrusion expressions; untested feature constructors remain scoped experimental.", - "Runtime source commit is fixed above; later documentation-only commits do not change the deployed runtime." - ] -} diff --git a/docs/dev7-validation.json b/docs/dev7-validation.json deleted file mode 100644 index 7ca9e91..0000000 --- a/docs/dev7-validation.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "version": "0.2.0.dev7", - "runtime_commit": "740a034a92b99d0b1b137d06ad2c43401a29397b", - "archive_sha256": "a0fb304234f9cdbd2da6ee3fc3b1d11ca2aefbd10ba02f776194fae88bbe32aa", - "validated_at": "2026-09-05T18:11:28.561519+00:00", - "nx_version": "v2606", - "tool_count": 106, - "hosted_ci": { - "url": "https://github.com/xuio/NX_MCP/actions/runs/33982851326", - "status": "passed", - "jobs": 13 - }, - "hosted_release": "https://github.com/xuio/NX_MCP/actions/runs/33982850744", - "local_validation": { - "suite_before_final_path_normalization_test": { - "passed": 462, - "branch_coverage_percent": 82.9 - }, - "path_normalization_and_recovery_suite": { - "passed": 12 - }, - "mypy": "passed", - "pre_commit": "passed" - }, - "native_checks": [ - { - "name": "workspace_discovery", - "passed": true - }, - { - "name": "directory_creation_absolute_relative_retry", - "passed": true - }, - { - "name": "nested_part_creation_save_1000mm3", - "passed": true - }, - { - "name": "absolute_save_as_creates_missing_parents_preserves_volume", - "passed": true - }, - { - "name": "save_as_no_overwrite", - "passed": true - }, - { - "name": "absolute_reopen_relative_reuse_and_activation", - "passed": true - }, - { - "name": "nested_absolute_step_export_download_upload_integrity", - "passed": true - }, - { - "name": "outside_and_reserved_paths_rejected", - "passed": true - } - ], - "native_passed": 8, - "deployment": { - "installed_runtime_hashes": "match release", - "stdio": "passed", - "http": "passed", - "inline_viewport_png_checksum": "passed", - "session_restored": true, - "original_parts": 38, - "assembly_occurrences": 116, - "backup_created": true - }, - "scope": "Absolute paths inside the configured NX-host workspace and explicit project subfolders. No outside-root access or project relocation.", - "runner_corrections": [ - "Initial harness used volume instead of documented volume_mm3; corrected.", - "Harness expected export directory to contain only STEP; native export also writes translator logs. Corrected to identify STEP by filename." - ], - "runtime_failures_in_acceptance": 0, - "pull_request_opened": false -} diff --git a/docs/dev8-validation.json b/docs/dev8-validation.json deleted file mode 100644 index cbe3499..0000000 --- a/docs/dev8-validation.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "version": "0.2.0.dev8", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 124, - "native_evidence_scope": "Serialized NX 2606 backend fixtures plus deployed public MCP acceptance; native geometry evidence is separate from mocked contract coverage.", - "native_passed_scenarios": [ - "assembly_constraint_fixed", - "assembly_distance_edit", - "associative_body_copy_absolute_edit", - "component_arrays", - "component_pattern_edits", - "extrusion_offsets_symmetric_arbitrary", - "extrusion_through_all_up_to_face", - "legacy_blend", - "legacy_chamfer", - "legacy_hole", - "legacy_sweep", - "native_boolean_volumes", - "native_drawing_pdf", - "native_edge_hole_sweep_volumes", - "native_mate_geometry", - "native_mirror_verified", - "native_nested_mass", - "physical_material_and_mass", - "project_copy", - "render_lighting", - "shell_open_box", - "sketch_angle", - "sketch_extend", - "sketch_primitives_solid_profiles", - "sketch_symmetry", - "sketch_tangent", - "sketch_trim", - "solid_loft" - ], - "original_loaded_parts_restored": 38, - "local_validation": { - "pytest_passed": 551, - "pytest_skipped": 1, - "branch_coverage_percent": 79.78, - "unchanged_coverage_gate_percent": 78, - "mypy": "passed" - }, - "visual_review": { - "native_png": "800x600 PNG inspected", - "drawing_pdf": "A3 PDF, base and projected views, computed 10 mm dimension inspected after public MCP download" - }, - "known_scopes": [ - "Line-pair sketch symmetry; line/circle tangent native fixture.", - "Solid loft native fixture; sheet configuration contract-tested.", - "Simple cylindrical holes, no drill-tip or thread authoring.", - "Single-body drawing base views; linear dimensions only.", - "Native preset2/custom background tested; no blanket rendering certification." - ], - "deployment": { - "status": "deployed_and_verified", - "runtime_commit": "d381426d31a25398daaea5910519a2bce24b4d38", - "release_archive_sha256": "dbd2db057900a87d63403987b00c4523d621ef6968c07f6ae6e095fe8312edd9", - "release_build": "https://github.com/xuio/NX_MCP/actions/runs/33988896807", - "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/33988873985", - "runtime_ci_status": "success", - "interactive_cold_start": "passed", - "main_thread_dispatch": "verified", - "stdio_tools": 124, - "http_tools": 124, - "runtime_source_files_checksum_verified": 38, - "original_saved_parts_restored": 38, - "original_component_paths_and_transforms_verified": 116, - "inline_native_png_checksum": "passed", - "native_pdf_download_checksum": "passed", - "offline_bundle_refreshed": true, - "runtime_backup_retained": true, - "journal_execution_enabled": false - }, - "public_endpoint_validation": { - "passed": 13, - "total": 13, - "groups": [ - "assembly_constraint_geometry", - "associative_motion_persistence_retry", - "component_arrays_and_edits", - "extrusion_limits", - "failed_mutation_and_checkpoint_rollback", - "native_drafting_pdf", - "native_render_inline_artifact", - "physical_material_mass_inertia", - "project_copy_dependencies", - "repaired_native_modeling", - "shell_loft_draft", - "sketch_primitives", - "sketch_relations_and_local_edits" - ], - "runs": [ - { - "run": "public", - "passed": 8, - "total": 10, - "restored_parts": 38 - }, - { - "run": "public-followup", - "passed": 4, - "total": 5, - "restored_parts": 38 - }, - { - "run": "public-recovery", - "passed": 1, - "total": 1, - "restored_parts": 38 - } - ], - "fixture_corrections": [ - "Reacquire face IDs after sketch mutations.", - "Use a self-intersecting extrusion for native failure testing; NX accepts the oversized shell fixture.", - "Reacquire surviving body IDs after native rollback." - ], - "runtime_changes_after_release": "none" - } -} diff --git a/docs/dev9-validation.json b/docs/dev9-validation.json deleted file mode 100644 index 829212e..0000000 --- a/docs/dev9-validation.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "version": "0.2.0.dev9", - "runtime_commit": "bc54d38ecde44bd3a36ca89e1830959a83d47961", - "nx_version": "v2606", - "bridge_protocol": 1, - "tool_count": 130, - "runtime_ci": "https://github.com/xuio/NX_MCP/actions/runs/33992483926", - "release_build": "https://github.com/xuio/NX_MCP/actions/runs/33992483710", - "release_sha256": "8f57ef6156688581154c0051f3b92b1a528e2c8863c435493fdd88005f12fcf4", - "native_passed_groups": [ - "absolute_nested_poses_reset_retry", - "views_pdf_save_as_reopen", - "preflight_rollback_view_guard_delete_stale" - ], - "original_loaded_parts_restored": 38, - "original_occurrences_verified": 116, - "local_validation": { - "pytest_passed": 610, - "pytest_skipped": 1, - "combined_statement_branch_coverage_percent": 80.51, - "unchanged_coverage_gate_percent": 78, - "mypy": "passed", - "pre_commit": "passed", - "ci": "all jobs passed" - }, - "visual_review": { - "native_png": "Native 2946x1575 viewport PNG delivered and checksum verified; three separated solid occurrences visible. NX returned a larger size than requested and reports a warning.", - "drawing_pdf": "A3 one-page PDF with assembled/exploded/projected views; downloaded, checksum verified, parsed and visually inspected. Fixture datum axes remain visible." - }, - "known_scopes": [ - "Native absolute parent/child placement, inherited reset, identical operation-ID retry and new-ID absolute replay.", - "Base/projected drawing association and update propagation; CGM-preserving save, Save As and save-on-close; reopen persistence.", - "Native checkpoint rollback, preflight rejection without pose changes, referenced-view deletion guard, view detach, deletion and stale-reference rejection.", - "Local tests inject mid-batch native failure; real transport interruption during a native explosion edit was not injected.", - "The runtime manifest conservatively labels deletion experimental because its deployed acceptance followed packaging; the native pass is recorded here.", - "Ordinary collision, clearance and mass tools measure assembled geometry. No trace lines, automatic layouts, animation, BOMs or balloons.", - "Freeform surfaces and other roadmap items are proposed, not implemented." - ], - "pull_request_opened": false -} diff --git a/docs/documentation-manufacturing.md b/docs/documentation-manufacturing.md deleted file mode 100644 index c48eb39..0000000 --- a/docs/documentation-manufacturing.md +++ /dev/null @@ -1,39 +0,0 @@ -# Editable documentation and manufacturing validation - -The dev12 integration adds ten tools, bringing the integration profile to 170. All modeling calls remain serial on the graphical NX thread. Operation IDs, explicit rollback and stale-reference rejection apply to the new model edits. - -## 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. - -## Validation scope - -Run `examples/validate_documentation_manufacturing.py` with `NX_MCP_URL` and optionally `NX_VALIDATION_OUTPUT`. It preserves the existing saved session and creates isolated fixtures: a rounded enclosure and STEP copy, curved surface joins, a thin plate, a drafted block, a sheet-metal bracket and a service assembly. It checks analytic dimensions/volumes, local editing and recovery, stale references after reopen, documentation updates and downloaded native artifacts. - -The acceptance script is executable test intent; a successful run and its receipt are required evidence. Local mocked tests check contracts and failure handling, not NX geometry. Sampled curvature, draft and wall-thickness results retain their explicitly sampled scope; no global manufacturing certification is claimed. - -Then run `examples/validate_annotation_recovery.py` with the same environment and output directory. It verifies automatic table row changes before any explicit table edit, idempotent expression retries, disabling managed PMI, and checkpoint rollback of both geometry and annotations. The prior fixture parts must be closed and user parts saved. - -## Recorded dev12 acceptance - -[The final acceptance receipt](dev12-validation.json) records the deployed runtime commit, Windows package checksum, numeric fixtures, recovery checks and protected workflows. The drawing/annotation fixtures use millimeter workparts; imperial and mixed-unit drafting remain outside this native test scope. - -The receipt distinguishes the reproduced stale bend-table issue from the corrected transaction path. A native rebuild is required after model changes even when the table already has automatic updating enabled. The service PDF was visually checked after moving its view inside the sheet. diff --git a/docs/engineering-tools.md b/docs/engineering-tools.md deleted file mode 100644 index 2563cd9..0000000 --- a/docs/engineering-tools.md +++ /dev/null @@ -1,43 +0,0 @@ -# Engineering tools on NX v2606 - -The dev8 opt-in profile exposes 124 tools. Mutations run serially on the NX thread and use durable operation IDs and NX undo marks. The capability manifest records the specific native fixtures tested; a tested tool is not a claim that every parameter combination or NX version is supported. - -## 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. - -## Reproducible validation - -Run `examples/validate_engineering_tools.py` against the deployed public MCP endpoint using `NX_MCP_URL` and `NX_VALIDATION_OUTPUT`. It creates an isolated fixture directory, requires a saved session, verifies analytic geometry, nested material mass, pattern placements, persistence/retry and inline images, and restores the original loaded parts. `NX_VALIDATION_GROUP` selects one or more comma-separated groups for a focused rerun. The suite also covers repaired blends, chamfers, holes, sweeps and mirrors, local sketch solver edits, and native drafting/PDF download. See [dev8 acceptance](dev8-validation.json) for the deployed results. Native evidence is kept separate from local mocked API-contract tests. diff --git a/docs/exploded-views.md b/docs/exploded-views.md deleted file mode 100644 index 76d8bb9..0000000 --- a/docs/exploded-views.md +++ /dev/null @@ -1,71 +0,0 @@ -# Native exploded views (NX v2606) - -The dev9 integration exposes 130 tools, including six named explosion tools. -Explosions are presentation transforms: they do not reposition the assembled -components, change mates or alter prototype geometry. - -| Tool | Contract | -| --- | --- | -| `nx_create_explosion(name)` | Create a named native explosion in the active assembly. | -| `nx_list_explosions()` | List explosion IDs and associated model/drawing views. | -| `nx_explosion_info(explosion, offset, limit)` | Read occurrence paths, assembled and exploded poses, suppression and view references. | -| `nx_edit_explosion(explosion, placements, reset_components)` | Assign absolute poses or reset selected occurrence offsets. | -| `nx_show_explosion(explosion, drawing_view, model_view)` | Display an explosion; omit explosion to restore assembled presentation. | -| `nx_delete_explosion(explosion)` | Delete an unused explosion; detach all referencing views first. | - -All references are typed opaque IDs. An explosion belongs to the work part; -mutations require matching work/display parts and a finished sketch. Reacquire -references after close/reopen, rollback or manual-control handoff. - -## 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. diff --git a/docs/fork-status.md b/docs/fork-status.md deleted file mode 100644 index 52a15e4..0000000 --- a/docs/fork-status.md +++ /dev/null @@ -1,61 +0,0 @@ -# NX v2606 integration fork - -This fork of [DreamEnding/NX_MCP](https://github.com/DreamEnding/NX_MCP) preserves the upstream history and MIT license. The initial import was deployed against Siemens NX v2606 as `0.2.0.dev2`; subsequent releases extend it through `0.2.0.dev16`. The fork follows upstream base `179086b6de28a53d340132aca7678fa6ed03b422` and retains the deployment history. Machine provisioning, private CAD, credentials and deployment session logs are outside this repository. - -See [engineering tools and scoped validation](engineering-tools.md) for the latest solid modeling, sketches, assemblies, materials, project copying, rendering and drafting additions. - -See [agent UX](agent-ux.md) for focused discovery, inline artifact retrieval and recovery guidance. - -## Included changes - -- NX v2606 API repairs, sketch bases, object references and multi-body results. -- Durable operation receipts, retry deduplication, explicit checkpoints and rollback. -- Assembly-aware inspection, workspace artifact transfer and capability reporting. -- Serialized execution in graphical NX with Pause, Resume and Stop controls. -- Native interference and clearance queries, inline viewport PNGs and view metadata. -- Collision highlighting, single-plane capped sections, body/component visibility, colors and transparency with restoration. -- Native sketch solver status, remaining degrees of freedom and persistent constraint-to-geometry links. - -The dev16 opt-in integration profile exposes 185 tools, including reference-set/datum controls and compact inventories. Tool status describes scoped validation on NX v2606, not universal certification. Journal execution remains disabled. The default sidecar retains upstream's smaller tool surface unless experimental mode is enabled. - -## Start the graphical bridge and sidecar - -Use Windows with native Siemens NX v2606 and Python 3.10 or newer. The tested sidecar used Python 3.12, MCP 1.29.1 and Pydantic 2.13.5. Install from this checkout: - -```powershell -python -m pip install -e ".[dev]" -``` - -Before launching NX, set `NX_MCP_WORKSPACE` to a dedicated CAD workspace and optionally set `NX_MCP_UI_DESCRIPTOR` to the desired descriptor file. In graphical NX, play `examples/start_nx_interactive.py`. The journal returns while its retained Win32 callback dispatches commands on the NX UI thread. Stop any batch bridge using the same workspace before attaching it. - -In a separate PowerShell window, configure the sidecar to use the same workspace and descriptor: - -```powershell -$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' -python -m nx_mcp.server -``` - -Use these same environment values in the MCP client's stdio server configuration. If `NX_MCP_UI_DESCRIPTOR` was customized on the NX side, set `NX_MCP_BRIDGE_DESCRIPTOR` to that exact path. A cross-machine HTTP deployment needs separate transport/authentication and network configuration; no private machine service is bundled here. - -See [interactive behavior and viewport capture](../INTERACTIVE-NX.md), [visual tool usage](visual-tools.md), and the runtime `nx_capabilities` result. Long native calls can temporarily block NX. Pause releases model input for manual editing and invalidates agent references/checkpoints; reacquire references on resume. NXOpen mutations are never issued concurrently. - -## Verification and upstream proposals - -The source matches the deployed runtime. The fork includes local tests and a configurable public MCP visualization regression runner. Historical live-NX results and current upstream-suite gaps are documented in [fork validation](fork-validation.md). Importing the source into this repository does not constitute a new native NX test run. - -A series of focused pull requests is preferable to the full integration diff. The [upstream review package](upstream-review.md) maps six proposed slices, supplies a draft first description, and lists compatibility decisions. Current runtime CI and native evidence are recorded in [dev16 acceptance](dev16-validation.json); [dev13 acceptance](dev13-validation.json) retains preceding release-engineering evidence; [dev12 acceptance](dev12-validation.json) retains the preceding documentation results; [dev11 acceptance](dev11-validation.json) retains the earlier freeform/documentation results; [dev10 acceptance](dev10-validation.json) retains sheet-metal results; [dev9 acceptance](dev9-validation.json) retains the exploded-view results; [dev8 acceptance](dev8-validation.json) retains the engineering results; [dev7 acceptance](dev7-validation.json) retains the preceding folder-support results; [dev6 acceptance](dev6-validation.json) retains the preceding authoring results. No pull request has been opened. - -Explicit nested and absolute in-workspace file paths, directory creation, and Save As parent creation are described in [project folders](project-folders.md). - -See [native exploded views](exploded-views.md) for dev9 presentation and drawing contracts, and the [advanced roadmap](advanced-roadmap.md) for proposed freeform and manufacturing work. - -See [native sheet metal](sheet-metal.md) for the dev10 operation catalog, verified scope, flat-pattern exports and measured PMI semantics. - -See [freeform, assembly documentation and manufacturing](freeform-manufacturing.md) for the dev11 additions and scoped native verification. - -See [editable documentation and manufacturing](documentation-manufacturing.md) for dev12 contracts and acceptance fixtures. - -See [release engineering and native acceptance](release-engineering.md) for dev13 drawing authoring, assembly refresh, retained-dimension repair, imported geometry references, mixed units and serial release validation. diff --git a/docs/fork-validation.md b/docs/fork-validation.md deleted file mode 100644 index 456993a..0000000 --- a/docs/fork-validation.md +++ /dev/null @@ -1,93 +0,0 @@ -# Validation and upstream PR readiness - -## Source provenance - -The five implementation commits import the previously deployed patches in order, starting at upstream `179086b6de28a53d340132aca7678fa6ed03b422`. All tracked runtime files, original examples and package metadata were compared byte-for-byte with the deployed source and matched. The subsequent documentation commit adds these notes and a configurable copy of the public visualization runner. No runtime changes were made while creating the fork. - -## Historical live-NX evidence - -The deployed version is `0.2.0.dev2`, tested against Siemens NX v2606 in a graphical session. The latest visualization release passed seven native feature groups and eleven public MCP groups, with 77 tools discoverable through the installed Windows stdio and HTTP transports. Its targeted local checks passed 37 tests with one platform skip. These are scoped results from the preceding implementation/deployment session, not a claim that upstream's entire CI suite passed. - -The eleven public groups covered schema/UI availability, underconstrained sketch diagnostics, color/transparency restoration, visibility restore ordering, native section lifecycle, nested collision highlighting, nested isolation, occurrence appearance without prototype recoloring, assembly sections preserving geometry, a fully constrained sketch fixture, and highlight cleanup on manual handoff. Native viewport images were retrieved with checksum verification. Positive and negative plane normals were visually checked on separated colored solids. - -Native validation does not cover every NX version, reference-set configuration, legacy tool, solver conflict state or geometry topology. Photorealistic rendering and material assignment remain outside this release. - -## Fresh fork comparison — 5 September 2026 - -Both source trees were tested on the same macOS/Python 3.12 environment with localhost socket access. The first sandboxed attempt was discarded as an environment-limited run; the following results use the required socket access: - -| Check | Unmodified upstream | Imported fork | -|---|---|---| -| Complete non-real-NX pytest suite | 161 passed, 1 skipped, 1 deselected | 174 passed, 7 failed, 1 skipped, 1 deselected | -| Ruff lint | Passed | 24 findings in imported implementation/test files | - -Command, with the checkout's `src` first on `PYTHONPATH`: - -```sh -python -m pytest -q -p no:cacheprovider -m "not real_nx" --basetemp /tmp/nx-tests -python -m ruff check . -``` - -The seven failing tests are new relative to this upstream baseline: - -- `tests/test_certified_server.py::test_experimental_file_tools_still_enforce_workspace_boundary`: the path is rejected, but the integration envelope returns `NX_INVALID_ARGUMENT` where upstream expects `NX_PATH_OUTSIDE_WORKSPACE`. -- `tests/test_nx_executor.py::test_open_save_export_and_close_part_lifecycle` and `test_mcp_sidecar_bridge_and_nx_executor_complete_core_workflow`: the fake NX module lacks the `StepCreator` enum required by the deployed export implementation. -- `tests/test_tools/test_measure.py::TestMeasureDistance::test_distance_success`, `TestMeasureAngle::test_angle_success`, `TestMeasureAngle::test_angle_custom_value`, and `tests/test_tools/test_modeling.py::TestSweep::test_sweep_success`: legacy mock tests return errors after shared lookup helpers changed. The mock/lookup contract needs reconciliation. These failures do not establish that angle or sweep are broken in real NX; those tools remain unverified there. - -Do not weaken tests merely to obtain a green run. Preserve stable public error codes, update fake seams to model verified API behavior, and add focused lookup regression coverage. Formatting, lint, mypy and coverage gates need a complete pass before an upstream merge request. Mypy, coverage and the hosted OS/Python matrix were not rerun during this fork import. - -## Reproduce public visualization checks - -`examples/validate_visual_tools.py` is an explicit live test, not part of ordinary pytest. It creates and saves disposable parts/assemblies, changes the active part and control mode, and leaves its fixture files for inspection. Run it only against a dedicated test NX session and workspace; it does not restore an unrelated user's session. - -Configure `NX_MCP_TEST_ENDPOINT` with a reachable HTTP MCP endpoint, `NX_VISUAL_RESULTS` with a local result directory, and `NX_FIXED_SKETCH_FIXTURE` with a workspace-relative part containing a fully constrained first sketch with persistent constraints. Create that simple fixture in NX first. No vendor or private CAD fixture is bundled. The runner assumes the endpoint's access is already configured; adapt its client transport if your endpoint requires additional authentication headers. - -```powershell -$env:NX_MCP_TEST_ENDPOINT = 'http://127.0.0.1:8765/mcp' -$env:NX_VISUAL_RESULTS = 'visual-results' -$env:NX_FIXED_SKETCH_FIXTURE = 'fixtures/fully-constrained.prt' -python examples/validate_visual_tools.py -``` - -The runner writes JSON results and native PNGs. It is the previously exercised runner with formatting and explicit endpoint configuration; the copied runner was syntax/lint checked, not executed against NX again for the fork import. - -## Proposed upstream sequence - -1. **Compatibility fixes:** isolate legacy imports from sidecar dependencies, repair NX2606 API use and sketch coordinate handling, and reconcile the core/legacy tests and stable error codes. -2. **Recovery and references:** agree on operation IDs, lifecycle semantics, checkpoint behavior and the public result contract. This is an architecture change requiring maintainer review. -3. **Graphical bridge and capture:** propose the serialized UI scheduler, manual handoff, capability gating and native viewport artifact delivery with NX-version-specific evidence. -4. **Inspection and visual tools:** propose assembly interference/clearance, highlighting, sections, appearance and solver diagnostics on the agreed foundations. - -The imported commits preserve deployment history but are not all independently PR-ready: some later commits depend on broad earlier hardening. Extract smaller patches with their own tests when preparing submissions. Discuss the architecture with the maintainer before asking them to review the complete integration. No pull request has been opened as part of this fork import. - -## Follow-up quality release: 0.2.0.dev3 - -All seven import-time test failures and the 24 lint findings were addressed. The full local suite now passes 183 tests (one platform skip), lint and sidecar mypy pass. The smoke workflow checks undo before export's save boundary and reacquires the sketch reference after undo. Added lookup tests check journal identifiers, case normalization, ambiguity and deduplication. Fake NX collections now expose the iterable interface verified in NX, and the STEP fake models the installed enum and a solid-bearing result. These remain fake seam tests, not native feature certification. - -Whole-project branch coverage is approximately 51%, below the inherited 78% gate. The gate is deliberately retained: GUI/NX modules have substantial uncovered Python paths despite their separate live-NX acceptance checks. A green functional suite does not resolve that coverage gap. Consult the current GitHub workflow result for the exact total and platform matrix. - -Versioned offline build, dependency locks, install and rollback procedures are documented in [releases](releases.md). - -Final dev3 deployment evidence is summarized in [the validation receipt](dev3-validation.json). All nine hosted test combinations, eleven deployed native MCP groups, Windows stdio/HTTP checks, isolated rollback testing and the Windows release build passed. The retained full-project coverage gate reports 50.63% against 78%. - -## Recovery coverage release: 0.2.0.dev4 - -The expanded local suite passes **303 tests**, with one platform skip and one real-NX deselection. Whole-project line/branch coverage reaches **79.59%**, above the unchanged **78%** gate. No coverage exclusions or threshold reductions were introduced. Stateful fake NX seams cover rollback, stale references, save boundaries, interrupted uploads, display snapshots, native inspection cleanup, coordinate/transform contracts and UI handoff failures. These tests verify Python control flow and arguments, not the Siemens geometry kernel. - -Fault injection reproduced three runtime defects before repair: - -- A failed screenshot-builder `Destroy()` skipped restoration of the original rendering style. Restoration now runs in a nested `finally`. -- A failed sketch `Deactivate()` skipped rollback of the temporary work region. Rollback now runs independently of deactivation; rollback failure is explicitly reported as partial. -- The executor overwrote an inspection handler's explicit partial-cleanup outcome with `not_started` when no outer undo mark existed. It now preserves that outcome, while a real outer rollback still determines its own result. - -The deployment follows the existing offline release and saved-session procedure. Historical dev3 results and receipts above remain unchanged as historical evidence. - -Final dev4 deployment evidence is summarized in [the validation receipt](dev4-validation.json). All hosted CI jobs, eleven deployed graphical NX groups, and Windows stdio/HTTP checks pass. The original 38 saved parts and all 116 component placements were restored unchanged. Local and hosted Windows packages contain byte-identical source; generated metadata line endings and archive ordering differ across build platforms. - -## Authoring and review release: 0.2.0.dev5 - -The opt-in profile adds 17 tools, for 94 total. The local suite passes 393 tests with 81.49% whole-project branch coverage against the unchanged 78% gate. Eight native acceptance groups pass for expressions/binding and health, geometry selection, sketch edits, component maintenance/instances, saved presentations, inspection artifacts, reversible previews and summaries. See [contracts and limits](authoring-review.md). Native probes caught differences in expression value units, face normal conventions, face lookup and sketch constraint enums before deployment. Circular-edge queries use the verified direct UF curve API. - -The public runner and preceding eleven visualization groups are rerun after deployment; their final receipts distinguish staged handler tests from public transport validation. - -Final dev5 deployment evidence is recorded in [the validation receipt](dev5-validation.json). The corrected deployed commit passes all hosted CI jobs, all eight public authoring groups, the existing eleven visualization groups, Windows stdio/HTTP checks, and a supplemental 500 mm³ interference report with native close-up and preserved saved state. Downloaded report/PNG checksums and ZIP member hashes were verified. All 38 saved user parts and 116 component placements were restored unchanged. Report and presentation results include a workspace-relative `artifact_path` for download tools. diff --git a/docs/freeform-manufacturing.md b/docs/freeform-manufacturing.md deleted file mode 100644 index 0880e95..0000000 --- a/docs/freeform-manufacturing.md +++ /dev/null @@ -1,41 +0,0 @@ -# Freeform, documentation and manufacturing - -The dev11 integration adds 20 tools (160 total) for NX v2606. These are intent-oriented wrappers around native NX geometry, annotations and rendering. Model mutations run serially on the graphical NX thread with the existing operation-ID deduplication and rollback framework. Coordinates are in the work part unless the tool explicitly uses assembly or drawing-sheet coordinates. - -See [dev12 editable documentation and manufacturing](documentation-manufacturing.md) for the subsequent ten tools and deeper acceptance fixtures. - -## 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](sheet-metal.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. - -## Repeatable acceptance - -Run `examples/validate_freeform_manufacturing.py` with `NX_MCP_URL` and an optional `NX_VALIDATION_OUTPUT`. It uses disposable workspace parts, records operation receipts and downloaded artifact checksums, and restores the original saved session. The graphical bridge and experimental integration profile must be enabled. The ordinary test suite also covers contracts, validation, cleanup, numerical invariance and animation failure recovery; mocked tests are not native NX evidence. 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/output-contracts.md b/docs/output-contracts.md deleted file mode 100644 index ab8b406..0000000 --- a/docs/output-contracts.md +++ /dev/null @@ -1,34 +0,0 @@ -# Integration output contracts - -All integration tools advertise an object `outputSchema`. The envelope requires -`status`, `warnings`, and nullable human-readable `units`. Errors additionally -require `code`, `message`, and `retryable`; error details may identify the operation -and mutation outcome. An error does not satisfy a success payload by returning -empty geometry. Success and error requirements are separate conditional branches. - -Tool-specific success contracts cover bounds, distance, volume, topology, -components, extrusion/revolve/pattern results, pairwise interference and clearance, -operation receipts, checkpoints/rollback, workspace listings, downloads and the -main image/CAD/document exports. Dev16 expands this to 47 tool-specific payloads, adding part lifecycle, sketch diagnostics, sheet-metal authoring/inspection, drawing inspection and reference-geometry controls. Inspect the live `tools/list` output for the -exact fields. Other tools retain extensible success payloads; this is not a claim -that all 185 payloads are fully typed. - -Geometry references keep opaque IDs separate from names and identify owner parts. -Vectors have three coordinates; matrices contain three rows. Measurement fields -state their coordinate frame. `volume_mm3` always uses cubic millimeters and sums -included bodies; it does not represent geometric union. Collision pair -classification distinguishes `clear`, `contact`, `penetration`, and -`below_clearance`. Bounding envelopes are explicitly exact or conservative. - -Download success has one of three shapes: metadata, inline PNG metadata with MCP -image content, or a base64 chunk with `bytes_returned`, `next_offset`, and `eof`. -An absent receipt is `state: unknown`, never proof that a mutation failed. A -committed receipt can carry `reverted_by` after subsequent undo; historical commit -is not proof that the geometry is still present. Reacquire references after -rollback, close, or manual handoff. - -These schemas permit additive metadata. They are advertised for client validation; -the server does not turn an already committed NX mutation into a retryable failure -by running an additional payload-validation step afterward. Native acceptance and -fresh MCP workflow tests check actual response conformance. After transport or -client validation failure, query the original operation ID before retrying. diff --git a/docs/project-folders.md b/docs/project-folders.md deleted file mode 100644 index d1dab87..0000000 --- a/docs/project-folders.md +++ /dev/null @@ -1,34 +0,0 @@ -# Project folders and explicit file paths - -The integration profile accepts either workspace-relative paths or absolute paths inside the configured `NX_MCP_WORKSPACE`. Paths refer to the **NX server**, not the Mac running the client. Call `nx_workspace_info({})` to discover the actual root. Forward slashes work on Windows and avoid JSON backslash escaping. - -For a workspace at `D:/CAD/NX_MCP_WORKSPACE`, these identify the same file: - -- `projects/controller/parts/controller_base.prt` -- `D:/CAD/NX_MCP_WORKSPACE/projects/controller/parts/controller_base.prt` - -There is no mutable current-directory setting. Include the project prefix on every file call so multiple tasks cannot redirect each other's files. - -## 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. - -## Live acceptance - -With a saved work part open in NX, set `NX_MCP_URL` to the integration endpoint and run `python examples/validate_project_folders.py`. Set `NX_VALIDATION_OUTPUT` for the local receipt directory. The runner creates disposable parts in a unique workspace subfolder, verifies a 1,000 mm³ solid across Save As and reopen, checks loaded-part reuse and activation, tests STEP artifact checksums and uploads, and rejects outside-root and reserved-state paths. It closes its fixtures and restores the original work part. Validation artifacts remain in the unique test subfolder. diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index 2d852cb..ac70522 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,36 @@ 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. diff --git a/docs/release-engineering.md b/docs/release-engineering.md deleted file mode 100644 index 0ee4b9b..0000000 --- a/docs/release-engineering.md +++ /dev/null @@ -1,59 +0,0 @@ -# Release engineering and native acceptance - -Dev13 adds nine tools for editable drawing views, assembly refresh and persistent imported geometry, plus a serial native release acceptance runner. The integration profile exposes 179 tools. NX calls remain on the graphical NX thread; model changes use the existing operation IDs and undo transactions. - -## 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. - -## Repeatable release validation - -Use trusted source from the release being tested: - -```sh -NX_MCP_URL=http://NX-HOST:8765/mcp \ -NX_VENDOR_STEP=/path/to/authorized-connector.step \ -python scripts/validate_native_release.py --output /new/receipt-directory -``` - -The runner executes the release-engineering, documentation, annotation-recovery, freeform and sheet-metal suites serially. It stops at the first failure, retains logs and geometry/artifact receipts, hashes outputs, and verifies original open parts, work/display state and component paths/transforms. There is no automatic mutation retry. A fresh output directory prevents stale evidence from being mistaken for a new pass. Native tests require saved existing parts and agent UI mode. - -A public connector fixture is available from [KiCad's USB4085 model](https://gitlab.com/kicad/libraries/kicad-packages3D/-/blob/8fb0194639525261cd642ec40d62ee26e1f601de/Connector_USB.3dshapes/USB_C_Receptacle_GCT_USB4085.step), SHA256 `82235f7275d07f720e3c050f781397f4bef47fdc7e15dd507d68ccba861f1a35`. Fetch it separately under its upstream license; vendor CAD is not bundled in this repository. Proprietary NX catalogs are read in place and never copied into release artifacts. - -Native acceptance is separate from mock/transport CI. A CI pass alone does not certify a release against NX. Keep the native receipt, package hash and exact source commit together, and run acceptance after every deployment before recording that release as verified. - -## Verified dev13 deployment - -[Native acceptance](dev13-validation.json) records all five installed suites passing: -11 release-engineering groups, eight protected documentation groups, annotation -recovery, ten freeform groups and four sheet-metal groups. The original 38 saved -parts and 116 component paths/transforms were preserved. All 29 downloaded -artifacts matched their native hashes; PDF layouts were visually reviewed. - -The runtime package is pinned to `7942284e0402f54d3ca54a6d6481b58a92a27c9c`. -A separate validation overlay records the final evidence, corrected capability -scope and two test-runner fixes: shared-drive output uses `absolute()` without -unsupported final-path resolution, and STEP uploads obey the 256 KiB chunk limit. -The NX modeling implementation remains the packaged runtime. The overlay's -`validation-release.json` records its commit and individual file checksums. diff --git a/docs/releases.md b/docs/releases.md deleted file mode 100644 index 0c24400..0000000 --- a/docs/releases.md +++ /dev/null @@ -1,91 +0,0 @@ -# Versioned offline releases - -Build from a clean commit on the fork with Python 3.12: - -```sh -python -m pip install -r requirements-build.txt -python scripts/build_release.py --output dist -``` - -The package contains the committed source, its built wheel, Windows/Python 3.12 dependency wheels, a hash-locked requirements file, a SHA256 manifest and the source commit/version. It excludes working-tree changes and machine configuration. GitHub's manual **Build offline NX release** workflow runs the same builder and retains the ZIP and checksum as artifacts. It does not publish a GitHub Release or deploy automatically. - -Dependencies are refreshed deliberately with: - -```sh -uv pip compile pyproject.toml -c constraints-windows.txt --python-platform windows --python-version 3.12 --generate-hashes --no-header -o requirements-windows.lock -``` - -Verify the downloaded ZIP checksum before extraction. Preserve the live session manifest and require all user parts to be saved before restarting NX. Stop the sidecar and its associated NX bridge, then run the package installer: - -```powershell -.\install_release.ps1 -InstallRoot C:\NX-MCP -BridgeDescriptor "$env:LOCALAPPDATA\nx-mcp\interactive-bridge.json" -``` - -The installer checks the package manifest, saves a rollback copy of the source and virtual environment, installs the hash-locked dependencies offline, replaces the source/wheel together and runs `pip check`. A failed installation restores the prior runtime. Host-specific launchers, network/authentication settings and CAD remain managed on the host. Restart the existing sidecar launcher and restore loaded parts from the preservation manifest. Run native validation before considering deployment complete. - -For an explicit rollback, stop that bridge/sidecar first and use the backup's script: - -```powershell -.\restore_release.ps1 -InstallRoot C:\NX-MCP -BackupRoot C:\NX-MCP\backups\release- -BridgeDescriptor "$env:LOCALAPPDATA\nx-mcp\interactive-bridge.json" -``` - -The rollback scripts restore runtime files, not CAD geometry or unsaved edits. Preserve CAD separately before deployment. Keep backups on the NX host; virtual environments or machine receipts can contain local paths and should not be published in the fork. - -## One-command installed-release acceptance - -After installing the reviewed package and restarting the existing launcher safely, -run this **on the Windows NX host**, with the installed Python 3.12 environment. -Keep the trusted ZIP and obtain its SHA-256/full commit from the reviewed build -receipt. Have a saved original part open, all loaded parts saved, and NX already in -agent mode. Keep exclusive use of this NX session during acceptance. Set the -authorized STEP fixture and a loopback MCP endpoint: - -```powershell -$env:NX_MCP_URL = 'http://127.0.0.1:8765/mcp' -$env:NX_VENDOR_STEP = 'C:\NX-MCP\fixtures\authorized-vendor.step' -C:\NX-MCP\venv\Scripts\python.exe C:\NX-MCP\source\scripts\accept_release.py ` - --release-zip C:\NX-MCP\releases\nx-mcp--windows-py312.zip ` - --sha256 --expected-commit ` - --install-root C:\NX-MCP --output C:\NX-MCP\acceptance\ -``` - -The command records atomic phase receipts in `acceptance.json`: - -1. Verify the ZIP hash, exact package manifest coverage, release commit/metadata, - every installed source file, and importable `nx_mcp` files against the packaged - wheel. Reject a source overlay or extra executable/configuration files. -2. Check Python 3.12, all pinned dependency versions and `pip check`. -3. Inspect the live NX version/tool count and original saved session. Defaults are - NX `v2606` and 185 tools; explicit expected-value options support later releases. -4. Run the existing six native release suites serially, retain their logs and - receipts, and stop at the first failure. The native runner checks its own - preservation evidence. -5. Independently compare open parts, work/display and saved flags, component - source paths and transforms with the preflight snapshot, including on failure. - -`--verify-only` ends with `state:verified` and does not run native suites. The same -command with `--resume` and without `--verify-only` rechecks the package/dependencies -and current saved session before starting native acceptance. Resume requires the -same ZIP/hash, commit, installation, interpreter, fixture hash, endpoint and runtime -expectations. A completed acceptance returns its historical receipt without running -again. An interrupted or failed native phase **cannot be resumed automatically**: -inspect its durable operation/suite receipts and reconcile NX first, then use a new -output directory for an explicitly chosen new acceptance run. Missing evidence is -never permission to replay mutations. - -An installation-wide `acceptance.lock` prevents two acceptance commands from running -together. A killed process leaves that lock; verify its PID and recorded receipt, -and reconcile the session before manually removing it. Other agents, users and -tools do not honor this lock, so exclusive NX access remains an operator prerequisite. - -Acceptance does not install, restart NX, save user work or force session restoration. -The native suites perform model tests in isolated test parts and restore saved user -state; errors remain visible for investigation. Deployment remains the separate -installer workflow above because it requires a stopped bridge and deliberate CAD -preservation. Installed-file hashes do **not** attest bytes already loaded by the -sidecar/NX processes; safe post-install launcher restart is still required, and the -receipt explicitly records `loaded_process_bytes_attested:false`. Dependency versions -are checked, not a byte-for-byte attestation of every third-party dependency. Native -acceptance covers `validate_native_release.py`'s selected suites; transport-disconnect -and real-restart tests remain separate exclusive-session checks. Output-schema -conformance is handled by the dedicated schema validator, not this command. diff --git a/docs/sheet-metal-native-validation.json b/docs/sheet-metal-native-validation.json deleted file mode 100644 index 0d67075..0000000 --- a/docs/sheet-metal-native-validation.json +++ /dev/null @@ -1,540 +0,0 @@ -{ - "nx_version": "v2606", - "execution": "Serialized graphical NX main thread, isolated disposable parts", - "stage": "pre-release native executor validation; public MCP acceptance is separate", - "operations": { - "contour_flange": { - "native_type": "BCFLANGE", - "source_fixture": "suite1", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "thickness": 2, - "sweep_distance": 40 - } - }, - "bend": { - "native_type": "BEND", - "source_fixture": "suite1", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "target_face": "$input_2", - "bend_angle": 90 - } - }, - "hem": { - "native_type": "Hem Flange", - "source_fixture": "suite1", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "edge_chain": [ - "$input_1" - ], - "type": "OpenHemType", - "first_flange_length": 10, - "first_bend_radius": 2 - } - }, - "break_corner": { - "native_type": "Break Corner", - "source_fixture": "suite1", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "edges": [ - "$input_1" - ], - "type": "Fillet", - "value": 3 - } - }, - "resize_bend_angle": { - "native_type": "Resize Bend Angle", - "source_fixture": "suite1", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "bend_face": "$input_1", - "angle": 110, - "reference_face": "$input_2" - } - }, - "resize_bend_radius": { - "native_type": "Resize Bend Radius", - "source_fixture": "suite1", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "bend_faces": [ - "$input_1" - ], - "bend_radius": 3, - "reference_entity": "$input_2" - } - }, - "jog": { - "native_type": "JOG", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "target_face": "$input_2", - "height": 10, - "angle": 90 - } - }, - "normal_cutout": { - "native_type": "Normal Cutout", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "target_body": "$input_2", - "depth_type": "ThroughAll", - "depth_side": "Symmetric", - "depth": 5 - } - }, - "dimple": { - "native_type": "Dimple", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "depth": 3, - "taper_angle": 15, - "include_rounding": false - } - }, - "drawn_cutout": { - "native_type": "Drawn Cutout", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "cutout_depth": 5, - "side_angle": 60, - "include_rounding": false - } - }, - "advanced_flange": { - "native_type": "AdvancedFlange", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "edges": [ - "$input_1" - ], - "length": 20, - "angle": 90 - } - }, - "variational_flange": { - "native_type": "VariationalFlange", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "edges": [ - "$input_1" - ], - "length_law_type": "Linear", - "start_length": 10, - "end_length": 20, - "angle": 90, - "radius": 2, - "neutral_factor": 0.33 - } - }, - "resize_neutral_factor": { - "native_type": "Resize Neutral Factor", - "source_fixture": "suite2", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "bend_faces": [ - "$input_1" - ], - "neutral_factor": 0.4 - } - }, - "lofted_flange": { - "native_type": "BLFLANGE", - "source_fixture": "suite4", - "model_health": true, - "saved_and_closed": true, - "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 - } - }, - "bead": { - "native_type": "Bead", - "source_fixture": "suite4", - "model_health": true, - "saved_and_closed": true, - "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 - } - }, - "convert": { - "native_type": "Convert To Sheetmetal", - "source_fixture": "suite5", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "base_face": "$input_1", - "is_uniform_thickness": true - } - }, - "gusset": { - "native_type": "SB_Gusset", - "source_fixture": "suite5", - "model_health": true, - "saved_and_closed": true, - "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 - } - }, - "bridge_bend": { - "native_type": "FPC Bridge Transition", - "source_fixture": "suite5", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "start_edge": "$input_1", - "end_edge": "$input_2", - "type": "Zu", - "width_type": "FullBothEdges", - "length": 20, - "width": 80 - } - }, - "bend_taper": { - "native_type": "Bend Taper", - "source_fixture": "suite5", - "model_health": true, - "saved_and_closed": true, - "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" - } - }, - "flat_solid": { - "native_type": "SB_FLAT_SOLID", - "source_fixture": "suite5", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "stationary_face": "$input_1", - "x_axis_edge": "$input_2", - "associative": true - } - }, - "lightening_cutout": { - "native_type": "Lightening Cutout", - "source_fixture": "suite5", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "type": "Hole", - "hole_center": [ - [ - 50, - 40, - 0 - ] - ], - "diameter": 12, - "length": 4, - "angle": 45, - "die_radius": 2 - } - }, - "closed_corner": { - "native_type": "Closed Corner", - "source_fixture": "suite6", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "face_pairs": [ - [ - "$input_1", - "$input_2" - ] - ], - "gap": 0.5, - "overlap_type": "NotSet", - "treatment_type": "CircularCutout", - "diameter": 6 - } - }, - "joggle": { - "native_type": "SB_Joggle", - "source_fixture": "suite6", - "model_health": true, - "saved_and_closed": true, - "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 - } - } - }, - "solid_punch": { - "native_type": "SMSPUNCH", - "source_fixture": "suite6", - "model_health": true, - "saved_and_closed": true, - "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 - } - }, - "from_solid": { - "native_type": "SB Sheet Metal from Solid", - "source_fixture": "suite7", - "model_health": true, - "saved_and_closed": true, - "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 - } - }, - "louver": { - "native_type": "Louver", - "source_fixture": "suite9", - "model_health": true, - "saved_and_closed": true, - "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 - } - }, - "three_bend_corner": { - "native_type": "Three Bend Corner", - "source_fixture": "suite11", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "face_pairs": [ - [ - "$input_1", - "$input_2" - ] - ], - "corner_gap": 1, - "flange_clearance": 1, - "treatment_type": "Open", - "diameter": 4 - } - }, - "bulge_relief": { - "native_type": "SB_BulgeRelief", - "source_fixture": "suite11", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "bend_edges": [ - "$input_1" - ], - "depth": 3, - "width": 6, - "radius": 2, - "relief_type": "Circular" - } - }, - "edge_rip": { - "native_type": "Edge Rip", - "source_fixture": "suite14", - "model_health": true, - "saved_and_closed": true, - "parameters": { - "section": "$input_1", - "width": 0.5, - "symmetric": true, - "use_system_width": false, - "end_cap_shape": "Round" - } - }, - "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" - }, - "tab": { - "native_type": "Base Tab", - "source_fixture": "api24", - "scope": "100 x 80 mm rectangular tab, thickness 2 mm; successful save and close" - }, - "flange": { - "native_type": "SB_FLANGE", - "source_fixture": "api12/api20/api24", - "scope": "90-degree flange, radius and neutral factor read-back; length edited from 20 to 25 mm" - }, - "flat_pattern": { - "native_type": "FLAT_PATTERN", - "source_fixture": "api15/api18", - "scope": "Native developed bracket, DXF/GEO export; flat pattern drawing and visually inspected PDF" - } - }, - "notes": [ - "Native creation success is scoped to the listed fixture; not all parameter combinations or edit modes were exercised.", - "Edge rip was verified with a short planar-face sketch slit; selected-edge ripping remains unverified.", - "PMI creation, geometric association and explicit refresh use measured text snapshots, not automatic numeric text updates.", - "Flat-pattern DXF dimensions were independently checked from LINE entities; header bounds are not trusted.", - "Part-wide bounds and summed volume include derived flat-pattern bodies and hidden source solids. Use the folded body ID for physical-part measurements." - ], - "secondary_features": { - "source_fixture": "suite16", - "secondary_tab_volume_mm3": 32000, - "secondary_contour": "Along-path sketch plus attached contour flange; native health, save and close passed" - } -} diff --git a/docs/sheet-metal.md b/docs/sheet-metal.md deleted file mode 100644 index 94f6072..0000000 --- a/docs/sheet-metal.md +++ /dev/null @@ -1,169 +0,0 @@ -# Native sheet metal - -The dev10 opt-in integration adds ten tools (140 total). It exposes 34 native -sheet-metal feature families and 412 top-level builder fields, with nested bend, -relief, miter, flange, corner and multi-thickness settings. These are native NX -features, not tessellated substitutes or ordinary solids renamed as sheet metal. - -Each family has a successful **NX v2606 creation fixture**. This is broad authoring -coverage, not a claim that every NX sheet-metal workflow or parameter combination -is complete. See [scoped native evidence](sheet-metal-native-validation.json). -Other NX versions, most feature edit combinations, table-driven materials, and -custom bend tables remain unverified. Metaform and manufacturing nesting are not -exposed. Remove Bends was unavailable under the tested installation's feature toggle. - -## 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. - -## Reproducible acceptance - -Run `examples/validate_sheet_metal.py` against an isolated test session using -`NX_MCP_URL` and `NX_VALIDATION_OUTPUT`. It preserves the original saved session, -creates a bracket in a unique folder, checks analytical volume and bend data, -checks same-ID replay, downloads native artifacts with checksum verification, -checks developed DXF dimensions before/after a parameter edit, exercises -save/reopen/stale references, and verifies rejection and checkpoint rollback. - -The public acceptance checks a complete bracket workflow. The separate 34-family -fixture evidence reports the broader creation coverage. Neither mocked wrapper -tests nor a builder's mere presence counts as native geometry verification. diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..dcda59e --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,469 @@ +# 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. + diff --git a/docs/upstream-review.md b/docs/upstream-review.md deleted file mode 100644 index 3f8e21b..0000000 --- a/docs/upstream-review.md +++ /dev/null @@ -1,45 +0,0 @@ -# Upstream review package — proposal only - -No pull request has been opened. This document prepares the discussion with DreamEnding/NX_MCP; it does not imply maintainer agreement or a supported-version commitment from Siemens. - -Comparison base: `179086b6de28a53d340132aca7678fa6ed03b422`, the recorded upstream base of this fork. The fork retains upstream history and the MIT license. Review the actual current diff with: - -```sh -git diff --stat 179086b6de28a53d340132aca7678fa6ed03b422...master -git log --reverse --oneline 179086b6de28a53d340132aca7678fa6ed03b422..master -``` - -The integration has grown beyond a suitable single PR. These are proposed review slices, in dependency order. Existing shared modules span slices; preparing mergeable branches will require extracting cohesive changes, not blindly cherry-picking deployment commits. - -| Proposed slice | Concrete change and principal files | Reviewer evidence | -| --- | --- | --- | -| 1. NX 2606 correctness repairs | Sketch local-to-world mapping, default extrusion normal, supported feature lookup/builders, STEP translator, loaded-part activation and multi-body results. `nx_bridge.py`, `hardened.py`, `utils/selection.py`. | Principal/custom plane solids, edited bounds/volume, import round trips, existing workflow regression tests. | -| 2. References and recovery | Owner/session/generation references, stale rejection, durable mutation IDs, deduplication and checkpoint rollback. `runtime.py`, `recovery.py`, executor and bridge boundaries. | Retry/failure/rollback tests; explicit partial/unknown outcomes; save-boundary semantics. | -| 3. Assembly inspection and artifacts | Occurrence-aware bounds/distance/interference/clearance, workspace transfers, structured results and capability metadata. `inspection.py`, `integration_server.py`, `workspace.py`. | Native transformed fixtures, analytic overlap volumes, ZIP/PNG checksums and round-trip receipts. | -| 4. Graphical NX host | Serialized Win32 UI-thread dispatch, manual handoff and view capture. `interactive.py`, graphical startup example, `bridge.py`. | Native thread identity, visible viewport artifacts, handoff and stop tests. Windows-specific scheduler must remain isolated. | -| 5. Authoring and presentation | Display/sections, expressions, atomic sketch edits, report/presentation/preview tools, exact selection and associative component patterns. `visual_tools.py`, `authoring.py`, `advanced_authoring.py`, `review_tools.py`. | Public MCP acceptance runners, editable native patterns, saved/reopened geometry rules, parameter and constraint checks. | -| 6. Reproducible release and documentation | Hash-locked Windows dependencies, offline packaging/install/rollback, CI and scoped validation receipts. `scripts/`, lock files, release workflow, docs. | Hosted OS/Python matrix, coverage gate, Windows release artifact and installed-source hashes. | - -## Draft description for the first proposal - -**Title:** Correct sketch coordinate mapping and native feature lookup on NX 2606 - -Sketch profiles requested in XZ could previously be created in XY, and feature lookup used an unsupported collection method. Map sketch-local points through the requested basis, derive extrusion direction from the sketch normal, and use the installed collection API. Return sketch origin/basis/normal so callers can verify the coordinate frame. - -Validation should accompany the extracted branch: XY/XZ/YZ and arbitrary-basis curve coordinates, independently expected solid bounds/volumes, a feature edit, and failure rollback. Keep unrelated assembly/UI tools out of this first review so the geometry fix is straightforward to assess. - -## Compatibility and decisions for maintainers - -- The larger integration is opt-in; keep the upstream default surface stable. Version-specific capability status must mean a documented tested scope, not general certification. -- Keep opaque IDs distinct from names/journals and use consistent structured success/error envelopes. Review that additive metadata is acceptable to existing clients. -- Nearest geometry ordering changes in dev6 from bounds-center distance to actual BREP distance. Independent component patterns retain their tool; native associative patterns use distinct tools. -- NXOpen mutations must remain serialized. The graphical timer is Windows-specific; any alternative host needs equivalent thread and handoff guarantees. -- Journal execution remains separately disabled by default. File transfer remains confined to the configured workspace. Private CAD, deployment credentials and host provisioning are excluded from the public fork. -- Confirm an NX version/CI policy and how maintainers want native evidence supplied. Mock tests cannot establish geometric correctness or SDK availability. -- Decide whether the larger authoring tools belong in core, an optional profile or a separate package before extracting those review branches. - -## Evidence and scope - -See [fork validation](fork-validation.md), [dev5 acceptance](dev5-validation.json), [advanced authoring](advanced-authoring.md), and the current `nx_capabilities` manifest. Retain historical receipts as historical; do not rewrite old test counts as current results. Deployment commits include documentation-only follow-ups, so cite the runtime source commit recorded in each receipt. - -No claim is made that every boolean, blend, chamfer, hole, sweep, mirror, mate, drawing or PDF operation is broken or verified. Controller design clashes and unresolved envelopes are design evidence, not MCP defects. A scoped native pass is evidence for its fixture and API, not a universal NX certificate. diff --git a/docs/visual-tools.md b/docs/visual-tools.md deleted file mode 100644 index 5844f3a..0000000 --- a/docs/visual-tools.md +++ /dev/null @@ -1,35 +0,0 @@ -# NX MCP visualization tools — 5 September 2026 - -NX MCP `0.2.0.dev2` adds ten tools to the existing integration, for **77 public tools**. Deployed to the NX v2606 machine using the visible, serialized UI-thread bridge. Journal tools remain disabled. - -| Capability | Tools | Behavior | -|---|---|---| -| Collision highlighting | `nx_highlight_collisions`, `nx_clear_highlights` | Highlights native interference pairs, including nested body occurrences; clear pairs remain unhighlighted. Uses NX selection highlighting and leaves persistent colors alone. | -| Section views | `nx_section_view`, `nx_section_control`, `nx_list_sections` | Native single-plane clips with arbitrary origin/normal, caps, edit, enable/disable and delete. Model solids and measurements remain unchanged. | -| Appearance and visibility | `nx_set_display`, `nx_display_info`, `nx_set_visibility`, `nx_restore_display` | Named colors or NX color-table indices, 0–100 transparency, show/hide and nested isolation. Returns restorable snapshots. Shared prototypes are not recolored by occurrence overrides. | -| Sketch diagnostics | `nx_sketch_diagnostics` | Native solver status, remaining DOF, persistent constraints, dimension expressions and constraint-to-geometry links. Supports active and inactive sketches. | - -## 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. diff --git a/examples/validate_remaining_tools.py b/examples/validate_remaining_tools.py new file mode 100644 index 0000000..ab1a9d2 --- /dev/null +++ b/examples/validate_remaining_tools.py @@ -0,0 +1,143 @@ +"""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: + await call("nx_status") + await call("nx_capabilities", tool="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} + ) + await call("nx_sketch_info", sketch_id=s) + 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"]: + await call("nx_set_view", orientation=v) + await call("nx_fit_view") + await call("nx_save_part") + await call("nx_save_as", path=prefix + "/nested/copy.prt") + await call("nx_workspace_list", path=prefix, limit=1) + 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] + ex = (await call("nx_create_explosion", name="Disposable"))["object"]["id"] + await call("nx_delete_explosion", explosion=ex) + assert not (await call("nx_list_explosions"))["items"] + 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; failures:", + [x["tool"] for x in log if x["error"]], + ) + + +asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 131e5db..e33b671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev18" +version = "0.2.0.dev19" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index a844e71..dd4e205 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.dev18" +__version__ = "0.2.0.dev19" diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index ba83113..81f7b60 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -395,17 +395,17 @@ }, "nx_highlight_objects": { "status": "tested", - "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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": { @@ -415,7 +415,7 @@ }, "nx_model_health": { "status": "tested", - "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.md for supported operations and limits.", + "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": { @@ -425,52 +425,52 @@ }, "nx_edit_sketch": { "status": "tested", - "scope": "Scoped v2606 native authoring/review acceptance and local failure-path regressions; see docs/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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/authoring-review.md for supported operations and limits.", + "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": { diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 899f675..405eba8 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -29,6 +29,7 @@ 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.recovery import OperationStore, timestamp @@ -151,6 +152,7 @@ def add(a, b): class HardenedExecutor( + LegacyRepairsMixin, ReleaseEngineeringMixin, ReferenceGeometryMixin, DocumentationEditingMixin, @@ -294,6 +296,9 @@ def __init__(self, *args, **kwargs): "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, } ) diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index bf60aba..df9382b 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -190,6 +190,31 @@ 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 @@ -344,6 +369,9 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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. Occurrence overrides do not recolor prototypes. Returns restore_id; restore in reverse order. Changes can persist on save.", 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/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json index c4b2a60..e1dcbd5 100644 --- a/src/nx_mcp/sheet_metal_catalog.json +++ b/src/nx_mcp/sheet_metal_catalog.json @@ -4723,7 +4723,7 @@ }, "edit_status": "experimental", "example_evidence": { - "source": "docs/sheet-metal-native-validation.json#/operations/advanced_flange", + "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." }, @@ -5455,7 +5455,7 @@ "reference_entity": "$input_2" }, "example_evidence": { - "source": "docs/sheet-metal-native-validation.json#/operations/unbend", + "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." }, @@ -5500,7 +5500,7 @@ "reference_entity": "$input_2" }, "example_evidence": { - "source": "docs/sheet-metal-native-validation.json#/operations/rebend", + "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." }, 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_legacy_repairs.py b/tests/test_legacy_repairs.py new file mode 100644 index 0000000..8c860af --- /dev/null +++ b/tests/test_legacy_repairs.py @@ -0,0 +1,95 @@ +"""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"] From d7d5dc36b915a631bdb6fe23987b9cb93a6787ae Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 22:19:25 +0200 Subject: [PATCH 56/69] Record scoped legacy acceptance and correct graphical view metadata --- docs/capability-matrix.md | 46 +++++------ docs/real-nx-validation.md | 19 +++++ docs/tools.md | 17 +++++ examples/validate_remaining_tools.py | 33 ++++++-- src/nx_mcp/capability_manifest.json | 109 ++++++++++++++------------- src/nx_mcp/nx_bridge.py | 6 +- tests/test_legacy_repairs.py | 15 ++++ 7 files changed, 160 insertions(+), 85 deletions(-) diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 79e7e80..5069c36 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -3,16 +3,16 @@ 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-agent-ux-r2**. NX: **v2606**. Bridge protocol: **1**. -Canonical manifest SHA-256: `adf9794221153a9d312ac1b48afa3eba03d8f1f8f1699f1c6de66913750cf864`. +Manifest revision: **2606-legacy-closeout-r3**. NX: **v2606**. Bridge protocol: **1**. +Canonical manifest SHA-256: `368307955169ea07d37a28e39425c3e91fa5a0e3454af31b1de093118daee714`. 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 | 162 | -| Contract/sidecar-tested | 5 | -| Experimental | 18 | +| Native-tested | 178 | +| Contract/sidecar-tested | 7 | +| Experimental | 0 | | Unavailable | 0 | ## Tools @@ -35,8 +35,8 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | -| nx_capabilities | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | @@ -56,8 +56,8 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not recorded | Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending. | -| nx_delete_feature | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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_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. | @@ -84,7 +84,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | @@ -96,7 +96,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Two-level transforms and STEP round-trip pose equality Compact inventory, no-pose projection and pagination checked against 116 occurrences. | | nx_list_datums | Native-tested | tested | real_NX_v2606_scoped | Native owned datum planes/axes and coordinate-system enumeration on template part. | @@ -104,17 +104,17 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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. | @@ -123,7 +123,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Already-loaded paths reused without close/recreation | | nx_operation_status | Contract/sidecar-tested | tested | local_contract_test | Durable committed/failed/unknown receipt tests; no crash reconstruction claimed | -| nx_package_assembly | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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. | @@ -135,16 +135,16 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Save with documented native mark expiration | | 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 | @@ -159,7 +159,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as 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. | @@ -173,10 +173,10 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 | @@ -186,7 +186,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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. | @@ -203,7 +203,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Experimental | experimental | not_tested_in_this_release | No correctness or failure claim; preserve as experimental. | +| 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 diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index ac70522..eb8634c 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -112,3 +112,22 @@ 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. diff --git a/docs/tools.md b/docs/tools.md index dcda59e..20908b7 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -467,3 +467,20 @@ Replacing a prototype may invalidate its edge anchors. Explicitly remove obsolet `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. diff --git a/examples/validate_remaining_tools.py b/examples/validate_remaining_tools.py index ab1a9d2..574721a 100644 --- a/examples/validate_remaining_tools.py +++ b/examples/validate_remaining_tools.py @@ -50,8 +50,13 @@ async def call(n, **p): original = next(x for x in before if x["work"]) created = [] try: - await call("nx_status") - await call("nx_capabilities", tool="nx_status") + 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"] @@ -61,7 +66,8 @@ async def call(n, **p): b = await call( "nx_sketch_line", sketch_id=s, start={"x": 0, "y": 0}, end={"x": 0, "y": 10} ) - await call("nx_sketch_info", sketch_id=s) + 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( @@ -87,11 +93,18 @@ async def call(n, **p): feature = e["feature"]["id"] await call("nx_rename_object", object_id=feature, name="ProbeExtrusion") for v in ["Top", "Back", "Isometric"]: - await call("nx_set_view", orientation=v) + 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") - await call("nx_workspace_list", path=prefix, limit=1) + 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"] @@ -110,9 +123,15 @@ async def call(n, **p): 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"))["items"] + 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") @@ -135,7 +154,7 @@ async def call(n, **p): 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; failures:", + "Completed; original session restored; expected rejection:", [x["tool"] for x in log if x["error"]], ) diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 81f7b60..b69288c 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-agent-ux-r2", + "revision": "2606-legacy-closeout-r3", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -54,9 +54,9 @@ "scope": "Extrusion distance 46.25 and native linear-pattern count/pitch; unsupported edit unchanged" }, "nx_sketch_info": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -84,9 +84,9 @@ "scope": "Undo after read-only inspection; save boundary explicitly inspected" }, "nx_status": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -104,9 +104,9 @@ "scope": "STEP solids and nested assembly through WorkPart importer, normal new-part creation; source prototypes closed explicitly; names preflighted" }, "nx_workspace_list": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -119,9 +119,9 @@ "scope": "Loaded names, paths, IDs, work/display status and modified flags" }, "nx_rename_object": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -164,19 +164,19 @@ "scope": "Already-loaded paths reused without close/recreation" }, "nx_measure_angle": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -194,9 +194,9 @@ "scope": "Native viewport PNG, white/transparent backgrounds, shaded/shaded-with-edges; requested dimensions advisory; actual device resolution returned" }, "nx_list_bodies": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -204,9 +204,9 @@ "scope": "Save with documented native mark expiration" }, "nx_set_view": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -229,14 +229,14 @@ "scope": "Native offset/symmetric/arbitrary-direction extrusion, through-all subtraction and up-to-face solids; analytic volume and bounds checked." }, "nx_list_sketches": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "status": "tested", + "evidence_type": "real_NX_v2606_scoped", + "scope": "Native two-sketch work-part inventory with typed references." }, "nx_list_features": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -249,9 +249,9 @@ "scope": "Extrude and Pattern Feature expressions and dependencies" }, "nx_package_assembly": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -259,14 +259,14 @@ "scope": "Native square sketch swept along a straight guide sketch; boolean variants use existing boolean operation." }, "nx_fit_view": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -274,9 +274,9 @@ "scope": "Native single-edge radius1 blend on a cube; installed AddChainset API and cleanup verified." }, "nx_cancel_operation": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -299,9 +299,9 @@ "scope": "Part and two-level assembly; conservative and exact with axis-aligned WCS" }, "nx_save_as": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -314,9 +314,9 @@ "scope": "Native metric A3 sheet at 1:1 first-angle projection; sheet opening and typed reference." }, "nx_sketch_constraint": { - "status": "experimental", - "evidence_type": "not_tested_in_this_release", - "scope": "No correctness or failure claim; preserve as experimental." + "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", @@ -639,8 +639,9 @@ "scope": "Native exploded and assembled occurrence poses and typed associated view references, including nested assembly." }, "nx_delete_explosion": { - "status": "experimental", - "scope": "Local unit tests cover in-use guard, native delete dispatch and stale reference invalidation; deployed native acceptance pending." + "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", diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py index fdc5f34..453302a 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -850,7 +850,11 @@ def _set_view(self, orientation): self._work_part().ModelingViews.WorkView.Orient( getattr(self.nxopen.View.Canned, options[key]), self.nxopen.View.ScaleAdjustment.Fit ) - return {"message": "View orientation set; batch bridge has no visible viewport"} + return { + "message": "View orientation set", + "orientation": key, + "viewport_available": not self.session.IsBatch, + } def _get_feature_info(self, name): part = self._work_part() diff --git a/tests/test_legacy_repairs.py b/tests/test_legacy_repairs.py index 8c860af..f70081f 100644 --- a/tests/test_legacy_repairs.py +++ b/tests/test_legacy_repairs.py @@ -93,3 +93,18 @@ def test_delete_feature_updates_native_delete_list_and_reports_reference(): 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 From 6d3266ca6e00261ecd2634d044a741ef0666f831 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Sun, 6 Sep 2026 22:25:07 +0200 Subject: [PATCH 57/69] Document final dev19 native and deployment acceptance --- README.md | 4 ++-- docs/real-nx-validation.md | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2f9b600..14e78b6 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ correctness. Native runners under `examples/validate_*.py` use disposable fixtur and document their environment variables. See [native validation](docs/real-nx-validation.md) and [release acceptance](docs/real-nx-validation.md). -The [dev18 receipt](docs/real-nx-validation.md) records 868 automated passes and -scoped live checks. Historical receipts identify their runtime commits and are +The [validation guide](docs/real-nx-validation.md) records 881 automated passes +and scoped dev19 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). diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index eb8634c..e00bcf6 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -131,3 +131,12 @@ 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. From 4ac96b79e898d7801b422961ba91e0525c4b6980 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 13:07:44 +0200 Subject: [PATCH 58/69] Fix manufacturing drawing contracts, import recovery and bounded results --- README.md | 6 +- docs/agent-surface.md | 10 +- docs/capability-matrix.md | 17 +- docs/real-nx-validation.md | 23 ++ docs/tools.md | 14 + examples/validate_advanced_tools.py | 2 +- examples/validate_authoring_tools.py | 2 +- examples/validate_engineering_tools.py | 2 +- examples/validate_freeform_manufacturing.py | 2 +- examples/validate_project_folders.py | 2 +- examples/validate_release_engineering.py | 35 ++- examples/validate_sheet_metal.py | 2 +- examples/validate_visual_tools.py | 2 +- pyproject.toml | 2 +- scripts/accept_release.py | 2 +- scripts/validate_native_release.py | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/agent_guidance.py | 6 +- src/nx_mcp/agent_surface.py | 7 +- src/nx_mcp/authoring.py | 28 ++ src/nx_mcp/bridge.py | 16 +- src/nx_mcp/capability_manifest.json | 29 +- src/nx_mcp/documentation_editing_server.py | 68 +++- src/nx_mcp/drawing_preferences.py | 267 ++++++++++++++++ src/nx_mcp/engineering.py | 32 +- src/nx_mcp/exploded_views.py | 2 + src/nx_mcp/hardened.py | 96 +++++- src/nx_mcp/integration_server.py | 126 +++++++- src/nx_mcp/interactive.py | 6 +- src/nx_mcp/manufacturing_server.py | 20 +- src/nx_mcp/nx_bridge.py | 34 +- src/nx_mcp/output_schemas.py | 64 +++- src/nx_mcp/planar_dxf.py | 221 +++++++++++++ src/nx_mcp/release_engineering.py | 75 ++++- src/nx_mcp/result_transport.py | 174 +++++++++++ src/nx_mcp/schema_types.py | 19 ++ src/nx_mcp/visual_tools.py | 26 +- tests/fakes/__init__.py | 21 ++ tests/test_agent_surface.py | 2 +- tests/test_bridge_import.py | 11 + tests/test_exploded_views.py | 8 +- tests/test_manufacturing_report.py | 327 ++++++++++++++++++++ tests/test_native_release_runner.py | 2 +- tests/test_release_engineering.py | 11 + tests/test_result_transport.py | 49 +++ tests/test_visual_tools.py | 2 +- 46 files changed, 1791 insertions(+), 85 deletions(-) create mode 100644 src/nx_mcp/drawing_preferences.py create mode 100644 src/nx_mcp/planar_dxf.py create mode 100644 src/nx_mcp/result_transport.py create mode 100644 src/nx_mcp/schema_types.py create mode 100644 tests/test_manufacturing_report.py create mode 100644 tests/test_result_transport.py diff --git a/README.md b/README.md index 14e78b6..a262e5a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ limits. “Tested” applies to the recorded fixtures, not every option of a bui | Profile | Exposure | Configuration | | --- | --- | --- | | Default | 16 original core tools | No experimental opt-in | -| Integration | 185 tools | `NX_MCP_ENABLE_EXPERIMENTAL=1` | +| 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 legacy environment flag enables the integration profile; it is **not** a @@ -103,8 +103,8 @@ correctness. Native runners under `examples/validate_*.py` use disposable fixtur 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 881 automated passes -and scoped dev19 live checks. Historical receipts identify their runtime commits and are +The [validation guide](docs/real-nx-validation.md) records 906 automated passes +and scoped dev20 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). diff --git a/docs/agent-surface.md b/docs/agent-surface.md index c4f91db..a3bfebf 100644 --- a/docs/agent-surface.md +++ b/docs/agent-surface.md @@ -1,8 +1,8 @@ # Agent surface -The full profile remains compatible: 185 tools with existing defaults. The opt-in +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 185 underlying tools remain available, subject to their +`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 @@ -42,6 +42,12 @@ 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 diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 5069c36..79a2765 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -3,15 +3,15 @@ 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-legacy-closeout-r3**. NX: **v2606**. Bridge protocol: **1**. -Canonical manifest SHA-256: `368307955169ea07d37a28e39425c3e91fa5a0e3454af31b1de093118daee714`. +Manifest revision: **2606-manufacturing-r1**. NX: **v2606**. Bridge protocol: **1**. +Canonical manifest SHA-256: `0bc6f76bf3845c3f10b0b60faba9fa2c666cc20f1a8a7448ef19d252dc41e286`. 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 | 178 | -| Contract/sidecar-tested | 7 | +| Native-tested | 181 | +| Contract/sidecar-tested | 8 | | Experimental | 0 | | Unavailable | 0 | @@ -58,6 +58,7 @@ These labels report manifest evidence, not certification or independent verifica | 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. | @@ -66,7 +67,8 @@ These labels report manifest evidence, not certification or independent verifica | 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_drawing_view | Native-tested | tested | real_NX_v2606_scoped | Absolute base-view placement and scale with native readback; circular detail boundary refresh. | +| 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 | Absolute base-view placement and scale with native readback; circular detail boundary refresh. Native hidden/visible font, width and rendering readback; per-view construction erasure preserves model visibility. PDF distinguishes dashed blind pocket from solid through-hole. Centerlines are read-only because native setters did not persist. | | 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. | @@ -77,6 +79,7 @@ These labels report manifest evidence, not certification or independent verifica | nx_export_drawing_pdf | Native-tested | tested | real_NX_v2606_scoped | Native PDF plot export with A3 page size, two views and 10mm dimension; file parsed and visually reviewed. | | 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. | @@ -132,6 +135,7 @@ These labels report manifest evidence, not certification or independent verifica | 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. | @@ -224,3 +228,6 @@ These are capability names, separate from the tool counts above. - 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/real-nx-validation.md b/docs/real-nx-validation.md index e00bcf6..9307c38 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -140,3 +140,26 @@ metadata checks. Windows source hashes, stdio/HTTP and inline PNG validation pas 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. diff --git a/docs/tools.md b/docs/tools.md index 20908b7..fa99206 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -484,3 +484,17 @@ 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. diff --git a/examples/validate_advanced_tools.py b/examples/validate_advanced_tools.py index ea85107..c129ef1 100644 --- a/examples/validate_advanced_tools.py +++ b/examples/validate_advanced_tools.py @@ -306,7 +306,7 @@ async def main(): ): 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", "185")) + assert len(tools) == int(os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")) assert tools["nx_resolve_geometry"].annotations.readOnlyHint async def call(method, **params): diff --git a/examples/validate_authoring_tools.py b/examples/validate_authoring_tools.py index 19dd2a8..15904cc 100644 --- a/examples/validate_authoring_tools.py +++ b/examples/validate_authoring_tools.py @@ -296,7 +296,7 @@ async def main(): ): 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", "185")) + 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 diff --git a/examples/validate_engineering_tools.py b/examples/validate_engineering_tools.py index 8939b96..3e3a138 100644 --- a/examples/validate_engineering_tools.py +++ b/examples/validate_engineering_tools.py @@ -109,7 +109,7 @@ async def assembly(name): 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", "185") + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") ) async def limits(): diff --git a/examples/validate_freeform_manufacturing.py b/examples/validate_freeform_manufacturing.py index 445d14f..eb6dc36 100644 --- a/examples/validate_freeform_manufacturing.py +++ b/examples/validate_freeform_manufacturing.py @@ -138,7 +138,7 @@ async def checked(name): 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", "185") + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") ) await new("spline") token = "spline-" + uuid.uuid4().hex diff --git a/examples/validate_project_folders.py b/examples/validate_project_folders.py index 596ef6f..7fad5f4 100644 --- a/examples/validate_project_folders.py +++ b/examples/validate_project_folders.py @@ -42,7 +42,7 @@ async def rejected(name, **p): prefix = "folder-validation-" + uuid.uuid4().hex[:10] try: assert len((await c.list_tools()).tools) == int( - os.environ.get("NX_EXPECTED_TOOL_COUNT", "185") + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") ) info = await call("nx_workspace_info") root = PureWindowsPath(info["root"]) diff --git a/examples/validate_release_engineering.py b/examples/validate_release_engineering.py index 796063d..4cf69c1 100644 --- a/examples/validate_release_engineering.py +++ b/examples/validate_release_engineering.py @@ -98,13 +98,25 @@ async def download(meta, name): 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"))["object"]["id"] + 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"] - edited = await call("nx_edit_drawing_view", view=view, scale=1.5, position=[105, 180]) + 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, @@ -163,6 +175,23 @@ async def download(meta, name): "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" ) @@ -331,7 +360,7 @@ async def download(meta, name): ) for c in components: await call("nx_assembly_constraint", constraint_type="fix", component=c["object"]["id"]) - ex = (await call("nx_create_explosion", name="Service"))["object"]["id"] + ex = (await call("nx_create_explosion", name="Service", scale=2))["object"]["id"] await call( "nx_edit_explosion", explosion=ex, diff --git a/examples/validate_sheet_metal.py b/examples/validate_sheet_metal.py index fc93a89..b35c41e 100644 --- a/examples/validate_sheet_metal.py +++ b/examples/validate_sheet_metal.py @@ -91,7 +91,7 @@ async def volume(body): 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", "185") + os.environ.get("NX_EXPECTED_TOOL_COUNT", "189") ) catalog = await call("nx_sheet_metal_schema") assert len(catalog["operations"]) == 34 diff --git a/examples/validate_visual_tools.py b/examples/validate_visual_tools.py index 5b5a8e1..ddcc84c 100644 --- a/examples/validate_visual_tools.py +++ b/examples/validate_visual_tools.py @@ -62,7 +62,7 @@ async def cube(path): 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", "185")), len(names) + 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) diff --git a/pyproject.toml b/pyproject.toml index e33b671..e4d4155 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev19" +version = "0.2.0.dev20" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/accept_release.py b/scripts/accept_release.py index 6755b3d..d94567b 100644 --- a/scripts/accept_release.py +++ b/scripts/accept_release.py @@ -224,7 +224,7 @@ def main(): 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=185) + 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") diff --git a/scripts/validate_native_release.py b/scripts/validate_native_release.py index a804aab..783d4c1 100644 --- a/scripts/validate_native_release.py +++ b/scripts/validate_native_release.py @@ -116,7 +116,7 @@ def stable(value): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--expected-tool-count", type=int, default=185) + parser.add_argument("--expected-tool-count", type=int, default=189) args = parser.parse_args() if args.output.exists(): raise RuntimeError( diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index dd4e205..a9b537d 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.dev19" +__version__ = "0.2.0.dev20" diff --git a/src/nx_mcp/agent_guidance.py b/src/nx_mcp/agent_guidance.py index 8f34f27..9f2bfeb 100644 --- a/src/nx_mcp/agent_guidance.py +++ b/src/nx_mcp/agent_guidance.py @@ -96,7 +96,7 @@ "nx_highlight_collisions", "nx_clear_highlights", } -SAVES = {"nx_save_part", "nx_save_as", "nx_export_step", "nx_close_part"} +SAVES = {"nx_save_part", "nx_save_as", "nx_export_step", "nx_close_part", "nx_export_drawing_pdf"} FILES = SAVES | { "nx_screenshot", "nx_render_view", @@ -143,6 +143,10 @@ def effect(group): "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.", }, } diff --git a/src/nx_mcp/agent_surface.py b/src/nx_mcp/agent_surface.py index c39c936..0080b7a 100644 --- a/src/nx_mcp/agent_surface.py +++ b/src/nx_mcp/agent_surface.py @@ -7,11 +7,12 @@ import re import uuid from pathlib import Path -from typing import Any, Literal +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 @@ -195,8 +196,8 @@ async def nx_discover_tools( domain: str | None = None, include_schema: bool = False, include_output_schema: bool = True, - offset: int = 0, - limit: int = 10, + 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: diff --git a/src/nx_mcp/authoring.py b/src/nx_mcp/authoring.py index 25d141f..dcde0bd 100644 --- a/src/nx_mcp/authoring.py +++ b/src/nx_mcp/authoring.py @@ -336,6 +336,28 @@ def _highlight_objects(self, objects): 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 @@ -397,6 +419,12 @@ def _model_health(self, scope="part", offset=0, limit=50): "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], } diff --git a/src/nx_mcp/bridge.py b/src/nx_mcp/bridge.py index 78f7863..c7643d2 100644 --- a/src/nx_mcp/bridge.py +++ b/src/nx_mcp/bridge.py @@ -90,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) @@ -120,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, @@ -149,8 +153,8 @@ def handle(self) -> None: 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) -> None: + self._server = _BridgeTCPServer(executor, token, result_directory) self._thread: Thread | None = None @property @@ -316,6 +320,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", diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index b69288c..4408e30 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -1,5 +1,5 @@ { - "revision": "2606-legacy-closeout-r3", + "revision": "2606-manufacturing-r1", "nx_version": "v2606", "bridge_protocol": 1, "tools": { @@ -851,7 +851,7 @@ "nx_edit_drawing_view": { "status": "tested", "evidence_type": "real_NX_v2606_scoped", - "scope": "Absolute base-view placement and scale with native readback; circular detail boundary refresh." + "scope": "Absolute base-view placement and scale with native readback; circular detail boundary refresh. Native hidden/visible font, width and rendering readback; per-view construction erasure preserves model visibility. PDF distinguishes dashed blind pocket from solid through-hole. Centerlines are read-only because native setters did not persist." }, "nx_add_section_drawing_view": { "status": "tested", @@ -927,6 +927,26 @@ "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": [ @@ -939,7 +959,10 @@ "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" + "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", diff --git a/src/nx_mcp/documentation_editing_server.py b/src/nx_mcp/documentation_editing_server.py index 4bd5dbe..0318338 100644 --- a/src/nx_mcp/documentation_editing_server.py +++ b/src/nx_mcp/documentation_editing_server.py @@ -1,6 +1,11 @@ """Drawing inspection and precise native documentation edits.""" -from typing import Literal +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"} @@ -20,11 +25,11 @@ def nx_list_annotations(offset: int = 0, limit: int = 100): def nx_edit_annotation( annotation: str, - position: list[float] | None = None, + 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 uses the annotation's existing sheet/work-part frame. Preserves callout text and native associations; does not convert balloons to plain text. Delete cannot be combined with other changes.""" + """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( @@ -72,22 +77,45 @@ 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=1, 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=1, 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=1, le=7)] | None = None + rendering: Literal["wireframe", "fully_shaded", "partially_shaded"] | None = None + + def nx_edit_drawing_view( - view: str, position: list[float] | None = None, scale: float | None = None + 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.""" + """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. Fonts: 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 restores or erases those 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: list[float], - arrow_direction: 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 vectors must be 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.""" + """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( @@ -105,7 +133,7 @@ def nx_drawing_table( 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 also creates a native NX title-block definition; revision creates 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.""" + """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): @@ -125,3 +153,25 @@ def nx_update_assembly_documentation(): 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..d1060fe --- /dev/null +++ b/src/nx_mcp/drawing_preferences.py @@ -0,0 +1,267 @@ +"""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): + part = self._work_part() + values = list(part.Curves) + 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) + ): + 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 1 <= value <= 7: + raise NXToolError("NX_INVALID_ARGUMENT", "Line font must be 1..7") + 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 index 3d084b7..2c2a5ab 100644 --- a/src/nx_mcp/engineering.py +++ b/src/nx_mcp/engineering.py @@ -2,6 +2,8 @@ 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 @@ -1473,17 +1475,37 @@ def _create_drawing(self, name="Sheet1", size="A3", scale=1.0, units="mm"): 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": scale, + "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", @@ -1512,11 +1534,13 @@ def _add_base_view(self, drawing, body, view, position=None): 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"), @@ -1532,7 +1556,11 @@ def _export_drawing_pdf(self, path): file = self.workspace.ensure_inside(path) if file.suffix.lower() != ".pdf" or file.exists(): - raise NXToolError("NX_INVALID_ARGUMENT", "Choose a new .pdf path") + raise NXToolError( + "NX_INVALID_ARGUMENT", + "Choose a new .pdf path; existing files are never overwritten", + details={"mutation_outcome": "not_started"}, + ) sheets = list(self._work_part().DrawingSheets) if not sheets: raise NXToolError("NX_NO_DRAWING", "Create a drawing sheet before PDF export") diff --git a/src/nx_mcp/exploded_views.py b/src/nx_mcp/exploded_views.py index 4575cb6..fadf699 100644 --- a/src/nx_mcp/exploded_views.py +++ b/src/nx_mcp/exploded_views.py @@ -440,11 +440,13 @@ def _add_base_view( 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]) diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 405eba8..a54fb83 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -24,6 +24,7 @@ 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 @@ -32,6 +33,7 @@ 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 @@ -153,6 +155,8 @@ def add(a, b): class HardenedExecutor( LegacyRepairsMixin, + PlanarDxfMixin, + DrawingPreferencesMixin, ReleaseEngineeringMixin, ReferenceGeometryMixin, DocumentationEditingMixin, @@ -1346,6 +1350,76 @@ def _pattern(self, features, pattern_type="linear", direction="X", spacing=10, c } 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 @@ -1392,7 +1466,7 @@ def _import_geometry(self, path, flatten=False, target="work_part", output_path= # 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=self._units()) + 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)} @@ -1402,15 +1476,16 @@ def _import_geometry(self, path, flatten=False, target="work_part", output_path= shutil.copyfile(source, staged) builder = self.session.DexManager.CreateStep214Importer() try: - builder.SettingsFile = str( + settings_file = str( Path( __import__("os").environ.get( "UGII_BASE_DIR", r"C:\Program Files\Siemens\Designcenter2606" ) ) / "STEP214UG" - / "ugstep214.def" + / "step214ug.def" ) + builder.SettingsFile = settings_file builder.InputFile = str(staged) builder.ImportTo = self.nxopen.Step214Importer.ImportToOption.WorkPart builder.FileOpenFlag = False @@ -1420,7 +1495,15 @@ def _import_geometry(self, path, flatten=False, target="work_part", output_path= builder.ObjectTypes.Surfaces = True builder.ObjectTypes.Curves = True builder.ProcessHoldFlag = True - builder.Commit() + 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 = [ @@ -1434,11 +1517,14 @@ def _import_geometry(self, path, flatten=False, target="work_part", output_path= ] if not bodies and not added_components: raise NXToolError( - "NX_IMPORT_NO_OUTPUT", "Translator returned without imported bodies or occurrences" + "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, diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index df9382b..68f498d 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -9,7 +9,7 @@ import os import struct import uuid -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations from pydantic import BaseModel, ConfigDict, Field @@ -81,7 +81,7 @@ def nx_boolean( pass -def nx_display_info(objects: list[str]): +def nx_display_info(objects: list[str], count_only: bool = False): pass @@ -310,7 +310,114 @@ def nx_set_component_transform( pass -def nx_batch(operations: list[dict[str, Any]]): +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 @@ -327,6 +434,8 @@ def nx_revolve( 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 @@ -364,7 +473,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of 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; custom axis origins are not exposed. Angles are degrees, lengths in work-part units; boolean is none/unite/subtract/intersect. Inspect nx_capabilities(tool='nx_revolve') for tested scope.", + "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.", @@ -374,7 +483,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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. Occurrence overrides do not recolor prototypes. Returns restore_id; restore in reverse order. Changes can persist on save.", + "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.", @@ -445,6 +554,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "nx_operation_status", "nx_workspace_info", "nx_workspace_list", + "nx_read_result", "nx_download_file", } READ_ONLY.update( @@ -474,12 +584,14 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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", @@ -714,6 +826,10 @@ async def uniform_call(name, arguments): 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": diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index c9a1e04..6e25d94 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -216,7 +216,11 @@ def __init__(self, workspace, descriptor_path): self.executor._handlers["nx_ui_control"] = self.control token = secrets.token_hex(32) self.dispatcher = MainThreadDispatcher(self.execute) - self.server = BridgeServer(self.dispatcher.call, token=token) + self.server = BridgeServer( + self.dispatcher.call, + token=token, + result_directory=Path(self.root) / ".nx-mcp" / "bridge-results", + ) self.descriptor_path = Path(descriptor_path) self.server.start() self.descriptor = BridgeDescriptor.create( diff --git a/src/nx_mcp/manufacturing_server.py b/src/nx_mcp/manufacturing_server.py index 99cc38f..e49f858 100644 --- a/src/nx_mcp/manufacturing_server.py +++ b/src/nx_mcp/manufacturing_server.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import Literal +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() @@ -103,3 +105,19 @@ def nx_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 453302a..8fd3d2d 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -481,6 +481,8 @@ def _revolve( 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") @@ -497,6 +499,24 @@ def _revolve( 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, @@ -525,8 +545,8 @@ def _revolve( self.nxopen.Section.Mode.Create, False, ) - vector = self.nxopen.Vector3d(*vectors[axis_key]) - origin = part.Points.CreatePoint(self.nxopen.Point3d(0.0, 0.0, 0.0)) + 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, @@ -551,7 +571,9 @@ def _revolve( return { "feature": self._reference(feature, "feature", part, "Revolve"), "angle": angle, - "axis": axis_key, + "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", } @@ -992,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 index 491f628..45b6b34 100644 --- a/src/nx_mcp/output_schemas.py +++ b/src/nx_mcp/output_schemas.py @@ -522,6 +522,55 @@ def obj(properties: dict, required: list[str] | None = None) -> dict: 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) @@ -533,6 +582,16 @@ def output_schema(name: str, common: dict) -> dict: ) 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, @@ -551,7 +610,10 @@ def output_schema(name: str, common: dict) -> dict: if name in PAYLOADS: schema["allOf"].append( { - "if": {"properties": {"status": {"const": "success"}}}, + "if": { + "properties": {"status": {"const": "success"}}, + "not": {"required": ["full_result"]}, + }, "then": deepcopy(PAYLOADS[name]), } ) 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/release_engineering.py b/src/nx_mcp/release_engineering.py index d3e4ba3..0b9babc 100644 --- a/src/nx_mcp/release_engineering.py +++ b/src/nx_mcp/release_engineering.py @@ -1,6 +1,7 @@ """Native drawing authoring and persistent imported-geometry references.""" import math +import uuid from nx_mcp.authoring import finite from nx_mcp.runtime import NXToolError @@ -41,6 +42,7 @@ def _drawing_view_info(self, view): "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": self._view_style(view), "position": xyz(obj.GetDrawingReferencePoint())[:2], "scale": uf.AskViewScale(obj.Tag)[1], "bounds": bounds, @@ -53,11 +55,12 @@ def _drawing_view_info(self, view): "bounds_semantics": "native drafting view border, excluding separately placed annotations", } - def _edit_drawing_view(self, view, position=None, scale=None): + def _edit_drawing_view(self, view, position=None, scale=None, style=None): import NXOpen.UF as U - if position is None and scale is None: - raise NXToolError("NX_INVALID_ARGUMENT", "Supply position or scale") + 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") @@ -68,6 +71,8 @@ def _edit_drawing_view(self, view, position=None, scale=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) @@ -98,7 +103,10 @@ def _add_section_drawing_view( point = sheet_point(position) scale = finite(scale, "scale", True) - step, arrow = unit_normal(step_direction), unit_normal(arrow_direction) + 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", @@ -287,15 +295,52 @@ def _drawing_table(self, drawing, kind, rows, widths, position, table=None, row_ part = self._work_part() section = self._engineering_owned(table, "annotation") if table else None attr = "NX_MCP_DRAWING_TABLE_V1" - if section is not None and ( - not section.HasUserAttribute(attr, self.nxopen.NXObject.AttributeType.String, -1) - or section.GetStringAttribute(attr) != kind - or section.GetStringAttribute("NX_MCP_TABLE_SHEET_V1") - != U.UFSession.GetUFSession().Tag.AskHandleFromTag(sheet.Tag) - ): - raise NXToolError( - "NX_OBJECT_OWNER_MISMATCH", "Edit a managed table of the same kind on this sheet" + 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) @@ -312,6 +357,7 @@ def _drawing_table(self, drawing, kind, rows, widths, position, table=None, row_ 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( @@ -358,6 +404,7 @@ def _drawing_table(self, drawing, kind, rows, widths, position, table=None, row_ 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 = [ @@ -373,9 +420,7 @@ def _drawing_table(self, drawing, kind, rows, widths, position, table=None, row_ "table": ref, "kind": kind, "position": point, - "position_anchor": "native title-block annotation origin" - if kind == "title_block" - else "native table-section annotation origin", + "position_anchor": "lower_right" if kind == "title_block" else "upper_left", "rows": actual, "row_count": len(actual), "column_count": len(widths), 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/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/visual_tools.py b/src/nx_mcp/visual_tools.py index 1571953..4f4b866 100644 --- a/src/nx_mcp/visual_tools.py +++ b/src/nx_mcp/visual_tools.py @@ -15,13 +15,13 @@ def enum_name(value, enum): return "unknown_" + str(value) -def unit_normal(value): +def unit_normal(value, argument="normal"): if not isinstance(value, (list, tuple)) or len(value) != 3: - raise NXToolError("NX_INVALID_ARGUMENT", "normal requires three finite numbers") + 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", "normal must be finite and nonzero") + raise NXToolError("NX_INVALID_ARGUMENT", f"{argument} must be finite and nonzero") return [x / length for x in v] @@ -115,8 +115,26 @@ def _save_display_snapshot(self, records): } return token - def _display_info(self, objects): + 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, diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py index 542f878..07180d1 100644 --- a/tests/fakes/__init__.py +++ b/tests/fakes/__init__.py @@ -231,6 +231,8 @@ def __init__(self, session, path): self.Bodies = Collection() self.Features = Collection() self.Curves = Collection() + self.Datums = Collection() + self.CoordinateSystems = Collection() self.Sketches = Collection() self.DynamicSections = Collection() self.Notes = Collection() @@ -338,6 +340,25 @@ def object_for(tag): ), ) 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( diff --git a/tests/test_agent_surface.py b/tests/test_agent_surface.py index 4d1b417..0702490 100644 --- a/tests/test_agent_surface.py +++ b/tests/test_agent_surface.py @@ -211,7 +211,7 @@ def test_dual_http_profiles(tmp_path, monkeypatch): 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", 185), ("/agent/mcp", 13)]: + for path, count in [("/mcp", 189), ("/agent/mcp", 13)]: response = client.post( path, headers={"Accept": "application/json, text/event-stream"}, 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_exploded_views.py b/tests/test_exploded_views.py index 5e89ae4..6b0ad68 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -480,15 +480,21 @@ 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() + 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 diff --git a/tests/test_manufacturing_report.py b/tests/test_manufacturing_report.py new file mode 100644 index 0000000..8747ab1 --- /dev/null +++ b/tests/test_manufacturing_report.py @@ -0,0 +1,327 @@ +"""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() diff --git a/tests/test_native_release_runner.py b/tests/test_native_release_runner.py index b8fab7a..6e10c60 100644 --- a/tests/test_native_release_runner.py +++ b/tests/test_native_release_runner.py @@ -68,5 +68,5 @@ def test_native_examples_accept_runner_profile_count(): "visual_tools", ]: source = (examples / f"validate_{name}.py").read_text() - assert 'os.environ.get("NX_EXPECTED_TOOL_COUNT", "185")' in source + assert 'os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")' in source assert "== 179" not in source diff --git a/tests/test_release_engineering.py b/tests/test_release_engineering.py index 365b007..c9ea3cb 100644 --- a/tests/test_release_engineering.py +++ b/tests/test_release_engineering.py @@ -131,6 +131,12 @@ def drawing(ff, monkeypatch): 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 @@ -220,6 +226,11 @@ def test_table_edits_preserve_native_identity_and_evaluated_cells(drawing): 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 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_visual_tools.py b/tests/test_visual_tools.py index 6595033..c979fa2 100644 --- a/tests/test_visual_tools.py +++ b/tests/test_visual_tools.py @@ -59,7 +59,7 @@ async def test_visual_tools_publish_enums_and_native_capture_description(tmp_pat server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True) tools = {t.name: t for t in await server.list_tools()} - assert len(tools) == 185 + assert len(tools) == 189 assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [ "show", "hide", From 337b81f64e8fe16c703641836e0272f6674a306e Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 13:23:39 +0200 Subject: [PATCH 59/69] Save only the explicitly selected work part --- README.md | 2 +- docs/capability-matrix.md | 4 ++-- docs/real-nx-validation.md | 6 ++++++ docs/tools.md | 4 ++++ src/nx_mcp/agent_guidance.py | 6 +++++- src/nx_mcp/capability_manifest.json | 2 +- src/nx_mcp/hardened.py | 3 +-- src/nx_mcp/nx_bridge.py | 2 +- src/nx_mcp/tools/file_ops.py | 4 ++-- tests/test_manufacturing_report.py | 20 ++++++++++++++++++++ tests/test_nx_executor.py | 2 +- tests/test_tools/test_file_ops.py | 1 + 12 files changed, 45 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a262e5a..8924594 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ correctness. Native runners under `examples/validate_*.py` use disposable fixtur 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 906 automated passes +The [validation guide](docs/real-nx-validation.md) records 907 automated passes and scoped dev20 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). diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 79a2765..4be897c 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -4,7 +4,7 @@ Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by 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: `0bc6f76bf3845c3f10b0b60faba9fa2c666cc20f1a8a7448ef19d252dc41e286`. +Canonical manifest SHA-256: `8b6a59455a55d3093947de8de6dbfdddba3e9f1d40136d2240448b4bf54dc775`. 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. @@ -149,7 +149,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Save with documented native mark expiration | +| nx_save_part | Native-tested | tested | real_NX_v2606 | Saves only the work part, preserving drawing-preview context. Native parent/child fixture verified the modified child remained unsaved and its disk bytes unchanged. 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 | diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index 9307c38..af55e1c 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -163,3 +163,9 @@ faces; diagnostics identified both faces and error 875315. Healing on a disposab 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. diff --git a/docs/tools.md b/docs/tools.md index fa99206..cf45d8f 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -498,3 +498,7 @@ Drawing creation assigns and verifies the sheet scale. New body and assembly bas `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. diff --git a/src/nx_mcp/agent_guidance.py b/src/nx_mcp/agent_guidance.py index 9f2bfeb..5926db7 100644 --- a/src/nx_mcp/agent_guidance.py +++ b/src/nx_mcp/agent_guidance.py @@ -45,7 +45,11 @@ ["component"], None, ), - "nx_save_part": (["Active work part with writable path"], ["part"], {}), + "nx_save_part": ( + ["Active work part with writable path; saves only this part, never component files"], + ["part"], + {}, + ), "nx_close_part": ( ["Current part reference; explicitly choose whether to save"], ["part"], diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 4408e30..b2a46a0 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -201,7 +201,7 @@ "nx_save_part": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Save with documented native mark expiration" + "scope": "Saves only the work part, preserving drawing-preview context. Native parent/child fixture verified the modified child remained unsaved and its disk bytes unchanged. Activate and save each component explicitly." }, "nx_set_view": { "status": "tested", diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index a54fb83..9d719b2 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -653,10 +653,9 @@ def _activate_part(self, part, work=True, display=True): def _save_part(self): part = self._work_part() - self._save_component_drawing_previews(part) with self._drawing_save_context(part): status = part.Save( - self.nxopen.BasePart.SaveComponents.TrueValue, + self.nxopen.BasePart.SaveComponents.FalseValue, self.nxopen.BasePart.CloseAfterSave.FalseValue, ) if status and hasattr(status, "Dispose"): diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py index 8fd3d2d..479c5c3 100644 --- a/src/nx_mcp/nx_bridge.py +++ b/src/nx_mcp/nx_bridge.py @@ -231,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() diff --git a/src/nx_mcp/tools/file_ops.py b/src/nx_mcp/tools/file_ops.py index eb2e3e0..701ca9c 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.", 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/tests/test_manufacturing_report.py b/tests/test_manufacturing_report.py index 8747ab1..b4c8641 100644 --- a/tests/test_manufacturing_report.py +++ b/tests/test_manufacturing_report.py @@ -325,3 +325,23 @@ def test_planar_export_preflights_before_writing(planar_fixture, tmp_path, param 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_nx_executor.py b/tests/test_nx_executor.py index d60da0c..b9d5940 100644 --- a/tests/test_nx_executor.py +++ b/tests/test_nx_executor.py @@ -284,7 +284,7 @@ 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"), 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() From 2498994c5cedb231d97cf73240349aaca6549a41 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 13:34:24 +0200 Subject: [PATCH 60/69] Record deployed manufacturing regression evidence --- docs/real-nx-validation.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index af55e1c..c37ab8a 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -169,3 +169,13 @@ saving. It now saves only the active work part and does not run component-previe 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. From e9bb14f0b0e56b31d13e4b57dd1779013ee84501 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 14:39:55 +0200 Subject: [PATCH 61/69] Audit work-part saves and document native follow-up limits --- README.md | 4 +- docs/capability-matrix.md | 4 +- docs/real-nx-validation.md | 29 ++++++++++ docs/tools.md | 8 +++ pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/agent_guidance.py | 4 +- src/nx_mcp/capability_manifest.json | 2 +- src/nx_mcp/hardened.py | 73 ++++++++++++++++++++--- src/nx_mcp/output_schemas.py | 19 +++++- src/nx_mcp/save_audit.py | 90 +++++++++++++++++++++++++++++ src/nx_mcp/tools/file_ops.py | 2 +- tests/fakes/__init__.py | 8 ++- tests/test_exploded_views.py | 3 +- tests/test_save_audit.py | 80 +++++++++++++++++++++++++ 15 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 src/nx_mcp/save_audit.py create mode 100644 tests/test_save_audit.py diff --git a/README.md b/README.md index 8924594..8b2dddd 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ correctness. Native runners under `examples/validate_*.py` use disposable fixtur 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 907 automated passes -and scoped dev20 live checks. Historical receipts identify their runtime commits and are +The [validation guide](docs/real-nx-validation.md) records 912 automated passes +and scoped dev20/dev21 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). diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 4be897c..7ee08f0 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -4,7 +4,7 @@ Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by 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: `8b6a59455a55d3093947de8de6dbfdddba3e9f1d40136d2240448b4bf54dc775`. +Canonical manifest SHA-256: `32cf859b81ee1dfb0bcf147ced5f63f60075c05b9fb38b071359988b3cdbf246`. 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. @@ -149,7 +149,7 @@ These labels report manifest evidence, not certification or independent verifica | 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. Native parent/child fixture verified the modified child remained unsaved and its disk bytes unchanged. Activate and save each component explicitly. | +| 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 | diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index c37ab8a..c84dc25 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -179,3 +179,32 @@ 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. diff --git a/docs/tools.md b/docs/tools.md index cf45d8f..e25984b 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -502,3 +502,11 @@ Drawing creation assigns and verifies the sheet scale. New body and assembly bas `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. diff --git a/pyproject.toml b/pyproject.toml index e4d4155..dd5be0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev20" +version = "0.2.0.dev21" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index a9b537d..6f6a034 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.dev20" +__version__ = "0.2.0.dev21" diff --git a/src/nx_mcp/agent_guidance.py b/src/nx_mcp/agent_guidance.py index 5926db7..dfb2209 100644 --- a/src/nx_mcp/agent_guidance.py +++ b/src/nx_mcp/agent_guidance.py @@ -46,7 +46,9 @@ None, ), "nx_save_part": ( - ["Active work part with writable path; saves only this part, never component files"], + [ + "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"], {}, ), diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index b2a46a0..46bc0f7 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -201,7 +201,7 @@ "nx_save_part": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Saves only the work part, preserving drawing-preview context. Native parent/child fixture verified the modified child remained unsaved and its disk bytes unchanged. Activate and save each component explicitly." + "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", diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index 9d719b2..c3ed04c 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -652,21 +652,76 @@ def _activate_part(self, part, work=True, display=True): } def _save_part(self): + from nx_mcp.save_audit import snapshot, verify + part = self._work_part() - with self._drawing_save_context(part): - status = part.Save( - self.nxopen.BasePart.SaveComponents.FalseValue, - self.nxopen.BasePart.CloseAfterSave.FalseValue, - ) - if status and hasattr(status, "Dispose"): - status.Dispose() + 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 part; native NX save may invalidate undo marks", + "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." + "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.", ], } diff --git a/src/nx_mcp/output_schemas.py b/src/nx_mcp/output_schemas.py index 45b6b34..7fab7a7 100644 --- a/src/nx_mcp/output_schemas.py +++ b/src/nx_mcp/output_schemas.py @@ -264,7 +264,24 @@ def obj(properties: dict, required: list[str] | None = None) -> dict: ), "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"]}), + "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), 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/tools/file_ops.py b/src/nx_mcp/tools/file_ops.py index 701ca9c..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 only the active work part; component files are not saved. Activate and save each component explicitly.", + 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: diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py index 07180d1..bd8a991 100644 --- a/tests/fakes/__init__.py +++ b/tests/fakes/__init__.py @@ -262,13 +262,17 @@ def __init__(self, session, path): 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()) + return NS(Dispose=Mock(), NumberUnsavedParts=0, NumberUnsavedObjects=0) def SaveAs(self, path): self.FullPath = path - return NS(Dispose=Mock()) + return NS(Dispose=Mock(), NumberUnsavedParts=0, NumberUnsavedObjects=0) def Close(self, *_): self.session.Parts.remove(self) diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py index 6b0ad68..947fe5a 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -393,8 +393,9 @@ def save(*args): 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(RuntimeError, match="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 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() From fc74c9b9499d4dcaeebd5aae635b2833f430a57b Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 14:49:41 +0200 Subject: [PATCH 62/69] Record deployed save audit verification --- docs/real-nx-validation.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index c84dc25..d0b891e 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -208,3 +208,10 @@ 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. From 4c815221de90ffa9a37a6f5b5b6f4a843c1f720a Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 16:57:19 +0200 Subject: [PATCH 63/69] Fix section curves, shaded PDFs and unloaded assembly prototypes --- README.md | 4 +- docs/capability-matrix.md | 12 +-- docs/real-nx-validation.md | 23 +++++ docs/tools.md | 23 +++++ pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/assembly_loading.py | 101 +++++++++++++++++++++ src/nx_mcp/authoring_server.py | 2 +- src/nx_mcp/capability_manifest.json | 10 +- src/nx_mcp/documentation_editing_server.py | 8 +- src/nx_mcp/drawing_preferences.py | 16 +++- src/nx_mcp/engineering.py | 18 ++++ src/nx_mcp/hardened.py | 19 +++- src/nx_mcp/integration_server.py | 8 +- src/nx_mcp/output_schemas.py | 3 + src/nx_mcp/release_engineering.py | 10 +- tests/test_assembly_loading.py | 91 +++++++++++++++++++ tests/test_engineering.py | 11 +++ tests/test_exploded_views.py | 1 + 19 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 src/nx_mcp/assembly_loading.py create mode 100644 tests/test_assembly_loading.py diff --git a/README.md b/README.md index 8b2dddd..fb4890a 100644 --- a/README.md +++ b/README.md @@ -103,8 +103,8 @@ correctness. Native runners under `examples/validate_*.py` use disposable fixtur 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 912 automated passes -and scoped dev20/dev21 live checks. Historical receipts identify their runtime commits and are +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). diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 7ee08f0..bb8dc71 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -4,7 +4,7 @@ Generated from `src/nx_mcp/capability_manifest.json`; do not edit this table by 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: `32cf859b81ee1dfb0bcf147ced5f63f60075c05b9fb38b071359988b3cdbf246`. +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. @@ -43,7 +43,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Saved part closure; NX may unload unused prototypes. Closed-part reporting invalidates all unloaded part references. | +| 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. | @@ -68,7 +68,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Absolute base-view placement and scale with native readback; circular detail boundary refresh. Native hidden/visible font, width and rendering readback; per-view construction erasure preserves model visibility. PDF distinguishes dashed blind pocket from solid through-hole. Centerlines are read-only because native setters did not persist. | +| 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. | @@ -76,7 +76,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 PDF plot export with A3 page size, two views and 10mm dimension; file parsed and visually reviewed. | +| 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. | @@ -101,7 +101,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Two-level transforms and STEP round-trip pose equality Compact inventory, no-pose projection and pagination checked against 116 occurrences. | +| 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. | @@ -124,7 +124,7 @@ These labels report manifest evidence, not certification or independent verifica | 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 | Already-loaded paths reused without close/recreation | +| 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. | diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index d0b891e..ad3365f 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -215,3 +215,26 @@ agent gateway. Both returned committed audit receipts with the parent as the onl 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. diff --git a/docs/tools.md b/docs/tools.md index e25984b..4831a3c 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -510,3 +510,26 @@ 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/pyproject.toml b/pyproject.toml index dd5be0d..334badd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nx-mcp" -version = "0.2.0.dev21" +version = "0.2.0.dev22" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index 6f6a034..bbb5eba 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.dev21" +__version__ = "0.2.0.dev22" 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_server.py b/src/nx_mcp/authoring_server.py index acf2a9b..c176fa2 100644 --- a/src/nx_mcp/authoring_server.py +++ b/src/nx_mcp/authoring_server.py @@ -82,7 +82,7 @@ def nx_component_action( name: str | None = None, part_path: str | None = None, ): - """Edit 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.""" + """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): diff --git a/src/nx_mcp/capability_manifest.json b/src/nx_mcp/capability_manifest.json index 46bc0f7..5067901 100644 --- a/src/nx_mcp/capability_manifest.json +++ b/src/nx_mcp/capability_manifest.json @@ -11,7 +11,7 @@ "nx_export_drawing_pdf": { "status": "tested", "evidence_type": "real_NX_v2606_scoped", - "scope": "Native PDF plot export with A3 page size, two views and 10mm dimension; file parsed and visually reviewed." + "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", @@ -66,7 +66,7 @@ "nx_close_part": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Saved part closure; NX may unload unused prototypes. Closed-part reporting invalidates all unloaded part references." + "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", @@ -161,7 +161,7 @@ "nx_open_part": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Already-loaded paths reused without close/recreation" + "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", @@ -306,7 +306,7 @@ "nx_list_components": { "status": "tested", "evidence_type": "real_NX_v2606", - "scope": "Two-level transforms and STEP round-trip pose equality Compact inventory, no-pose projection and pagination checked against 116 occurrences." + "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", @@ -851,7 +851,7 @@ "nx_edit_drawing_view": { "status": "tested", "evidence_type": "real_NX_v2606_scoped", - "scope": "Absolute base-view placement and scale with native readback; circular detail boundary refresh. Native hidden/visible font, width and rendering readback; per-view construction erasure preserves model visibility. PDF distinguishes dashed blind pocket from solid through-hole. Centerlines are read-only because native setters did not persist." + "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", diff --git a/src/nx_mcp/documentation_editing_server.py b/src/nx_mcp/documentation_editing_server.py index 0318338..63e878e 100644 --- a/src/nx_mcp/documentation_editing_server.py +++ b/src/nx_mcp/documentation_editing_server.py @@ -81,19 +81,19 @@ class ViewStyle(BaseModel): construction_geometry: bool | None = None model_config = ConfigDict(extra="forbid") hidden_lines: bool | None = None - hidden_font: Annotated[int, Field(ge=1, le=7)] | 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=1, le=7)] | 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=1, le=7)] | None = None + smooth_font: Annotated[int, Field(ge=0, le=7)] | None = None rendering: Literal["wireframe", "fully_shaded", "partially_shaded"] | None = None @@ -103,7 +103,7 @@ def nx_edit_drawing_view( 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. Fonts: 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 restores or erases those 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.""" + """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( diff --git a/src/nx_mcp/drawing_preferences.py b/src/nx_mcp/drawing_preferences.py index d1060fe..d9b66f2 100644 --- a/src/nx_mcp/drawing_preferences.py +++ b/src/nx_mcp/drawing_preferences.py @@ -169,8 +169,14 @@ def _edit_dimension_format( return result def _drawing_construction_visibility(self, view, visible): + import NXOpen.UF as U + part = self._work_part() - values = list(part.Curves) + self._datum_objects() + 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 @@ -180,6 +186,8 @@ def _drawing_construction_visibility(self, view, visible): 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) @@ -220,8 +228,10 @@ def _view_style(self, view, updates=None): ) group, prop = STYLE_PROPERTIES[key] if key.endswith("font"): - if type(value) is not int or not 1 <= value <= 7: - raise NXToolError("NX_INVALID_ARGUMENT", "Line font must be 1..7") + 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: diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py index 2c2a5ab..ad03cea 100644 --- a/src/nx_mcp/engineering.py +++ b/src/nx_mcp/engineering.py @@ -1561,12 +1561,19 @@ def _export_drawing_pdf(self, path): "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. + self._update_model() + 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() @@ -1576,6 +1583,10 @@ def _export_drawing_pdf(self, path): 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() @@ -1595,6 +1606,13 @@ def _export_drawing_pdf(self, path): "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": [], } diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py index c3ed04c..72234d2 100644 --- a/src/nx_mcp/hardened.py +++ b/src/nx_mcp/hardened.py @@ -599,7 +599,7 @@ def _resolve_body(self, body, part): def _find_sketch(self, name): return self._resolve(name, {"sketch"}) - def _open_part(self, path, work=True, display=True): + def _open_part(self, path, work=True, display=True, load_components=False): source = self.workspace.ensure_inside(path) loaded = next( ( @@ -619,6 +619,10 @@ def _open_part(self, path, work=True, display=True): 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), @@ -742,7 +746,16 @@ def _save_as(self, path): } 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} @@ -1119,6 +1132,8 @@ def _list_components( offset=0, limit=None, ): + from nx_mcp.assembly_loading import component_info + page([], offset, limit) part = self._work_part() candidates = [ @@ -1135,7 +1150,7 @@ def _list_components( row = { "object": compact_reference(ref) if compact else ref, "name": c.Name, - "part_path": c.Prototype.FullPath, + **component_info(c), "depth": len(path) - 1, "suppressed": bool(c.IsSuppressed), "reference_set": c.ReferenceSet, diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 68f498d..6029247 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -47,7 +47,7 @@ def nx_list_components( offset: int = 0, limit: int | None = None, ): - """List recursive loaded component occurrences. 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.""" + """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): @@ -178,7 +178,7 @@ def nx_create_sketch( pass -def nx_open_part(path: str, work: bool = True, display: bool = True): +def nx_open_part(path: str, work: bool = True, display: bool = True, load_components: bool = False): pass @@ -505,8 +505,8 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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": "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": "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_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.", diff --git a/src/nx_mcp/output_schemas.py b/src/nx_mcp/output_schemas.py index 7fab7a7..360bd1d 100644 --- a/src/nx_mcp/output_schemas.py +++ b/src/nx_mcp/output_schemas.py @@ -430,6 +430,9 @@ def obj(properties: dict, required: list[str] | None = None) -> dict: 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"] diff --git a/src/nx_mcp/release_engineering.py b/src/nx_mcp/release_engineering.py index 0b9babc..33da7f4 100644 --- a/src/nx_mcp/release_engineering.py +++ b/src/nx_mcp/release_engineering.py @@ -38,11 +38,19 @@ def _drawing_view_info(self, 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": self._view_style(view), + "style": style, + "warnings": warnings, "position": xyz(obj.GetDrawingReferencePoint())[:2], "scale": uf.AskViewScale(obj.Tag)[1], "bounds": bounds, 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_engineering.py b/tests/test_engineering.py index fb30a27..ef4fa22 100644 --- a/tests/test_engineering.py +++ b/tests/test_engineering.py @@ -539,21 +539,28 @@ def test_pdf_export_reports_actual_artifact_and_refuses_overwrite(eng): 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.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)) @@ -570,17 +577,21 @@ def test_invalid_pdf_output_is_removed(eng): 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.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)) diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py index 947fe5a..381893d 100644 --- a/tests/test_exploded_views.py +++ b/tests/test_exploded_views.py @@ -32,6 +32,7 @@ def explosions(rig): 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) From 6125b7dac2c85bc0df2c974bc032690bab4e3de0 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 17:06:00 +0200 Subject: [PATCH 64/69] Update PDF drafting views without a modeling undo mark --- src/nx_mcp/engineering.py | 2 +- tests/test_engineering.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py index ad03cea..17e52ac 100644 --- a/src/nx_mcp/engineering.py +++ b/src/nx_mcp/engineering.py @@ -1570,7 +1570,7 @@ def _export_drawing_pdf(self, path): 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. - self._update_model() + # 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) diff --git a/tests/test_engineering.py b/tests/test_engineering.py index ef4fa22..7bdc6d5 100644 --- a/tests/test_engineering.py +++ b/tests/test_engineering.py @@ -552,6 +552,8 @@ def test_pdf_export_reports_actual_artifact_and_refuses_overwrite(eng): 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" @@ -590,6 +592,8 @@ def test_invalid_pdf_output_is_removed(eng): 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" From 35f875243efd5a418dfe4b5dbcdee1c3db7ec627 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 17:14:14 +0200 Subject: [PATCH 65/69] Record deployed A02 regression verification --- docs/real-nx-validation.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index ad3365f..ea34d43 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -238,3 +238,12 @@ 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. From 3fd33c7846dc627857cb6f292bb99781cd5586d1 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 17:23:43 +0200 Subject: [PATCH 66/69] Expose UI activity during native work and clarify input reservation --- docs/architecture.md | 18 ++++- pyproject.toml | 2 +- src/nx_mcp/__init__.py | 2 +- src/nx_mcp/integration_server.py | 2 +- src/nx_mcp/interactive.py | 117 ++++++++++++++++++++++++++----- tests/test_interactive.py | 1 + tests/test_ui_recovery.py | 52 ++++++++++++++ 7 files changed, 172 insertions(+), 22 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 40931ae..adffb77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,7 +42,23 @@ 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. A long native call can block the UI. Cooperative batch cancellation +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. 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 diff --git a/pyproject.toml b/pyproject.toml index 334badd..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.dev22" +version = "0.2.0.dev23" description = "MCP server for Siemens NX (UG) CAD operations" readme = "README.md" requires-python = ">=3.10" diff --git a/src/nx_mcp/__init__.py b/src/nx_mcp/__init__.py index bbb5eba..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.dev22" +__version__ = "0.2.0.dev23" diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py index 6029247..6abe993 100644 --- a/src/nx_mcp/integration_server.py +++ b/src/nx_mcp/integration_server.py @@ -492,7 +492,7 @@ def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, of "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 the interactive NX host or switch between agent control and manual editing. Finish NX dialogs before resuming.", + "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.", diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index 6e25d94..b0604c6 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -88,6 +88,7 @@ class WNDCLASS(ctypes.Structure): ] 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)] @@ -106,8 +107,8 @@ class WNDCLASS(ctypes.Structure): 0x10C80000, 40, 60, - 440, - 145, + 560, + 195, None, None, None, @@ -122,8 +123,8 @@ class WNDCLASS(ctypes.Structure): 0x50000000, 12, 10, - 410, - 36, + 530, + 85, self.hwnd, None, None, @@ -135,7 +136,7 @@ class WNDCLASS(ctypes.Structure): ("Stop bridge", 292, 103), ]: self.user.CreateWindowExW( - 0, "BUTTON", caption, 0x50010000, x, 55, 130, 30, self.hwnd, ident, None, None + 0, "BUTTON", caption, 0x50010000, x, 110, 130, 30, self.hwnd, ident, None, None ) def _message(self, hwnd, msg, wp, lp): @@ -157,7 +158,14 @@ def _message(self, hwnd, msg, wp, lp): 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) @@ -196,6 +204,10 @@ def __init__(self, workspace, descriptor_path): 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() @@ -217,7 +229,7 @@ def __init__(self, workspace, descriptor_path): token = secrets.token_hex(32) self.dispatcher = MainThreadDispatcher(self.execute) self.server = BridgeServer( - self.dispatcher.call, + self.dispatch, token=token, result_directory=Path(self.root) / ".nx-mcp" / "bridge-results", ) @@ -250,11 +262,71 @@ def __init__(self, workspace, descriptor_path): 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()), @@ -334,7 +406,14 @@ def execute(self, method, params): # 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 @@ -347,7 +426,13 @@ def execute(self, method, params): 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: @@ -355,6 +440,12 @@ def execute(self, method, params): 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: @@ -378,19 +469,9 @@ def tick(self, *args): except NXToolError: pass self.dispatcher.drain(timeout=0, limit=1) - self.panel.update( - ( - "Agent control — model edits serialized" - if self.mode == "agent" - else "Paused — manual NX editing enabled" - ) - + "\n" - + (self.last_error or self.last_method or "Waiting for MCP requests") - ) + self.panel.update(self.panel_text()) if self.ticks % 10 == 0: - temp = self.state_dir / "ui-state.tmp" - temp.write_text(json.dumps(self.status())) - temp.replace(self.state_dir / "ui-state.json") + self.publish() except BaseException as exc: self.last_error = str(exc) try: # noqa: SIM105 - Last-resort native callback cleanup must not escape. diff --git a/tests/test_interactive.py b/tests/test_interactive.py index bbc5e0d..116077b 100644 --- a/tests/test_interactive.py +++ b/tests/test_interactive.py @@ -144,6 +144,7 @@ def __iter__(self): host.session = SimpleNamespace(Parts=Parts()) host.completed = 0 host.status = lambda: {} + host.publish = lambda: None def execute(method, params): if method == "nx_create_part": diff --git a/tests/test_ui_recovery.py b/tests/test_ui_recovery.py index c157793..f19e389 100644 --- a/tests/test_ui_recovery.py +++ b/tests/test_ui_recovery.py @@ -43,6 +43,9 @@ def host(rig, tmp_path, monkeypatch): 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(), @@ -164,6 +167,7 @@ def test_panel_commands_and_cleanup(host): panel.user = NS( DefWindowProcW=Mock(return_value=9), SetWindowTextW=Mock(), + UpdateWindow=Mock(), DestroyWindow=Mock(), UnregisterClassW=Mock(), ) @@ -229,3 +233,51 @@ def fail(self, *_): 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 From bc446b0afb62199dd71f1ae4455de64feba78493 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 17:25:06 +0200 Subject: [PATCH 67/69] Keep interactive health requests independent of the NX queue --- src/nx_mcp/bridge.py | 19 +++++++++++++++++-- src/nx_mcp/interactive.py | 1 + tests/test_interactive.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/nx_mcp/bridge.py b/src/nx_mcp/bridge.py index c7643d2..7103802 100644 --- a/src/nx_mcp/bridge.py +++ b/src/nx_mcp/bridge.py @@ -150,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, result_directory: Path | None = None) -> None: - self._server = _BridgeTCPServer(executor, token, result_directory) + 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 diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index b0604c6..2d94d27 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -231,6 +231,7 @@ def __init__(self, workspace, descriptor_path): 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) diff --git a/tests/test_interactive.py b/tests/test_interactive.py index 116077b..294aabf 100644 --- a/tests/test_interactive.py +++ b/tests/test_interactive.py @@ -159,3 +159,33 @@ def execute(method, params): 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() From 1e26fb247dac4fe720ecfcf708118ba343366219 Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 17:30:30 +0200 Subject: [PATCH 68/69] Keep the control panel above its NX owner window --- docs/architecture.md | 3 ++- src/nx_mcp/interactive.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index adffb77..100f82a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -46,7 +46,8 @@ inspection. Agent mode intentionally disables the NX main window even while idle `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. It does not continuously repaint an +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 diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py index 2d94d27..9406cbd 100644 --- a/src/nx_mcp/interactive.py +++ b/src/nx_mcp/interactive.py @@ -109,7 +109,7 @@ class WNDCLASS(ctypes.Structure): 60, 560, 195, - None, + self.host.main_hwnd, None, None, None, From 6f73a051acad8c58199e2afe96cd65098974e9ee Mon Sep 17 00:00:00 2001 From: Moritz Hoffmann Date: Mon, 7 Sep 2026 17:35:36 +0200 Subject: [PATCH 69/69] Record native UI responsiveness and handoff validation --- docs/real-nx-validation.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/real-nx-validation.md b/docs/real-nx-validation.md index ea34d43..9882ac9 100644 --- a/docs/real-nx-validation.md +++ b/docs/real-nx-validation.md @@ -247,3 +247,18 @@ 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.