; inspect existing columns first. Appends general columns; existing callout/quantity semantics are preserved. Remove requires index and rejects other properties. Returns actual evaluated rows and column preferences."""
+
+
+def nx_edit_explosion_trace(
+ traceline: str,
+ start_percent: float | None = None,
+ end_percent: float | None = None,
+ start_offset: float | None = None,
+ end_offset: float | None = None,
+):
+ """Edit a managed native explosion trace's anchored edge arc-length percentages (0..100) and native endpoint offsets in assembly units. Persistent edge/component anchors and the named explosion are retained. Recomputes endpoints and returns readback. Use nx_edit_annotation(delete=true) to remove a trace."""
+
+
+def nx_bend_table(
+ view: str,
+ position: list[float],
+ table: str | None = None,
+ automatic: bool = True,
+ columns: list[Literal["BendID", "BendName", "BendAngle", "BendDirection", "BendRadius"]]
+ | None = None,
+):
+ """Create/edit a native associative bend table for an actual flat-pattern drafting view. Position uses sheet units. Select ordered unique native columns and native automatic updating. table edits an existing native bend table. Geometry and bend data remain associated with the flat pattern."""
+
+
+def nx_refresh_annotations():
+ """Refresh persistent managed sheet-metal PMI from current native measurements. These annotations also refresh transactionally after MCP model mutations. Call explicitly after manual NX edits. Missing sources reject the operation rather than retaining silently incorrect values. Rebuilds native bend tables with automatic updating enabled; their flag alone may leave stale rows after reopening."""
+
+
+READ_ONLY.update({"nx_drawing_view_info", "nx_geometry_anchor", "nx_resolve_geometry_anchor"})
+
+
+def nx_drawing_view_info(view: str):
+ """Inspect actual native view scale, absolute sheet position, view border and sheet containment. Returns each sheet's actual mm/in units. Borders exclude separately placed annotations. Read-only."""
+
+
+class ViewStyle(BaseModel):
+ construction_geometry: bool | None = None
+ model_config = ConfigDict(extra="forbid")
+ hidden_lines: bool | None = None
+ hidden_font: Annotated[int, Field(ge=0, le=7)] | None = None
+ hidden_width: (
+ Literal["original", "thin", "normal", "thick", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
+ | None
+ ) = None
+ self_hidden: bool | None = None
+ visible_font: Annotated[int, Field(ge=0, le=7)] | None = None
+ visible_width: (
+ Literal["original", "thin", "normal", "thick", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
+ | None
+ ) = None
+ smooth_edges: bool | None = None
+ smooth_font: Annotated[int, Field(ge=0, le=7)] | None = None
+ rendering: Literal["wireframe", "fully_shaded", "partially_shaded"] | None = None
+
+
+def nx_edit_drawing_view(
+ view: str,
+ position: Point2 | None = None,
+ scale: float | None = None,
+ style: ViewStyle | None = None,
+):
+ """Assign absolute drawing-view position [x,y] in sheet units and/or positive model-to-sheet scale. Native aligned views may constrain movement; verifies readback and rolls back mismatches. Updates the view, retaining its native associations. style sets native hidden/visible/smooth (tangent) edges and rendering with readback. hidden_lines and self_hidden are native hidden-line processing toggles, not visibility switches: false can draw occluded edges as visible. To hide obscured edges use hidden_lines=true, self_hidden=true, hidden_font=0. Fonts: 0 invisible, 1 solid, 2 dashed; widths are named thin/normal/thick or native width names 1..9. Default new base views use dashed hidden edges and exclude model curves/datums through per-view erasures. construction_geometry excludes sheet-owned section/detail curves and restores or erases model objects in this view without changing model visibility. Centerline visibility is read-only: the NX v2606 builder did not persist its setter in native tests."""
+
+
+def nx_add_section_drawing_view(
+ parent_view: str,
+ cut_object: str,
+ position: list[float],
+ step_direction: Point3,
+ arrow_direction: Point3,
+ scale: float = 1.0,
+ cut_association: Literal["start", "end", "arc_center"] = "start",
+):
+ """Create a native simple section drawing view anchored to an owned model edge endpoint (start/end) or arc_center for circular edges. Step and arrow are three-component [x,y,0] vectors perpendicular in the sheet XY plane. position is [x,y] in sheet units. Positive scale is model-to-sheet. Creates a native section line and cut view; requires drafting license."""
+
+
+def nx_add_detail_drawing_view(
+ parent_view: str, center: list[float], radius: float, position: list[float], scale: float = 2.0
+):
+ """Create a native circular detail view. center=[x,y,z] and positive radius use work-part coordinates/units; choose a circle lying in the parent view plane. position=[x,y] uses sheet units. The native view remains associated with the parent; boundary points retain their explicit model coordinates."""
+
+
+def nx_drawing_table(
+ drawing: str,
+ kind: Literal["title_block", "revision"],
+ rows: list[list[str]],
+ widths: list[float],
+ position: list[float],
+ table: str | None = None,
+ row_height: float = 7.0,
+):
+ """Create/edit an owned native drawing table with explicit rectangular rows and per-column widths in sheet units. title_block uses the lower-right anchor and creates a native NX title-block definition; revision uses the upper-left anchor of an editable tabular history. Editing requires the returned table ID and retains column count; row count may change. Maximum 100 rows/20 columns/1024 characters per cell. No dates, approval, or revision content is inferred."""
+
+
+def nx_geometry_anchor(object: str):
+ """Capture a persistent native handle for an owned body/face/edge in a saved part. Anchor survives save/reopen while the native entity survives. Store the returned anchor rather than session-scoped object IDs. Occurrence geometry must be anchored in its prototype work part."""
+
+
+def nx_resolve_geometry_anchor(anchor: dict):
+ """Resolve an exact native geometry anchor into a current typed ID. Verifies owner path and object kind. Deleted or replaced topology explicitly returns NX_STALE_REFERENCE; never silently substitutes the nearest face."""
+
+
+def nx_update_assembly_documentation():
+ """Explicitly propagate loaded prototype changes in the active assembly. Solve existing mates, refresh managed explosion traces, native BOMs, measured annotations and drafting views. Returns constraint states, evaluated BOM rows, view bounds, retained annotations/dimensions, documentation_complete and assembly health. Retained values require explicit reassociation or recreation. Requires work/display match and no active sketch. Unsatisfied mates or stale managed anchors roll back this update; earlier prototype edits remain separate operations."""
+
+
+READ_ONLY.add("nx_list_dimensions")
+
+
+def nx_list_dimensions():
+ """List actual computed native dimension values, native retention and measurement_valid flags, typed IDs and annotation origins in the work part. Includes drafting and PMI dimensions, identified by native subtype. Values/origins use native work-part units; inspect view association separately. Read-only, no regeneration."""
+
+
+READ_ONLY.add("nx_dimension_format")
+
+
+def nx_dimension_format(dimension: str):
+ """Read native associative dimension precision, display units, decimal separator and tolerances. Computed value uses work-part units; formatting does not override measured text."""
+
+
+def nx_edit_dimension_format(
+ dimension: str,
+ decimal_places: Annotated[int, Field(ge=0, le=8)] | None = None,
+ trailing_zeros: bool | None = None,
+ units: Literal["mm", "in", "m", "um"] | None = None,
+ decimal_separator: Literal["period", "comma"] | None = None,
+ tolerance_type: Literal["none", "bilateral", "symmetric", "limits", "basic", "reference"]
+ | None = None,
+ upper_tolerance: float | None = None,
+ lower_tolerance: float | None = None,
+ tolerance_decimal_places: Annotated[int, Field(ge=0, le=8)] | None = None,
+):
+ """Edit native dimension preferences while preserving measured value and associations. Tolerance values use native work-part units. No text override. Omitted properties remain unchanged. Returns actual native formatting; save to persist."""
diff --git a/src/nx_mcp/drawing_preferences.py b/src/nx_mcp/drawing_preferences.py
new file mode 100644
index 0000000..d9b66f2
--- /dev/null
+++ b/src/nx_mcp/drawing_preferences.py
@@ -0,0 +1,277 @@
+"""Associative dimension formatting and native drafting view presentation."""
+
+from nx_mcp.runtime import NXToolError
+from nx_mcp.visual_tools import enum_name
+
+UNITS = {"mm": "Millimeters", "in": "Inches", "m": "Meters", "um": "Micrometers"}
+TOLERANCES = {
+ "none": "NotSet",
+ "bilateral": "BilateralTwoLines",
+ "symmetric": "BilateralOneLine",
+ "limits": "LimitTwoLines",
+ "basic": "Basic",
+ "reference": "Reference",
+}
+WIDTHS = {
+ "original": "Original",
+ "thin": "Thin",
+ "normal": "Normal",
+ "thick": "Thick",
+ **{
+ str(i): name
+ for i, name in enumerate(
+ ["One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"], 1
+ )
+ },
+}
+RENDERING = {
+ "fully_shaded": "FullyShaded",
+ "partially_shaded": "PartiallyShaded",
+ "wireframe": "Wireframe",
+}
+STYLE_PROPERTIES = {
+ "hidden_lines": ("ViewStyleHiddenLines", "HiddenLine"),
+ "hidden_font": ("ViewStyleHiddenLines", "Font"),
+ "hidden_width": ("ViewStyleHiddenLines", "Width"),
+ "self_hidden": ("ViewStyleHiddenLines", "SelfHidden"),
+ "visible_font": ("ViewStyleVisibleLines", "VisibleFont"),
+ "visible_width": ("ViewStyleVisibleLines", "VisibleWidth"),
+ "smooth_edges": ("ViewStyleSmoothEdges", "SmoothEdge"),
+ "smooth_font": ("ViewStyleSmoothEdges", "Font"),
+ "centerlines": ("ViewStyleGeneral", "Centerlines"),
+ "rendering": ("ViewStyleShading", "RenderingStyle"),
+}
+
+
+class DrawingPreferencesMixin:
+ def _dimension_format(self, dimension):
+ import NXOpen.Annotations as A
+
+ d = self._engineering_owned(dimension, "dimension")
+ prefs = d.GetDimensionPreferences()
+ units = prefs.GetUnitsFormatPreferences()
+ try:
+ return {
+ "object": self._reference(d, "dimension", self._work_part(), "Dimension"),
+ "computed_value": d.ComputedSize,
+ "measurement_units": self._units(),
+ "measurement_valid": not bool(d.IsRetained),
+ "decimal_places": d.NominalDecimalPlaces,
+ "tolerance_decimal_places": d.ToleranceDecimalPlaces,
+ "upper_tolerance": d.UpperMetricToleranceValue
+ if self._units() == "mm"
+ else d.UpperToleranceValue,
+ "lower_tolerance": d.LowerMetricToleranceValue
+ if self._units() == "mm"
+ else d.LowerToleranceValue,
+ "tolerance_type": enum_name(d.ToleranceType, A.ToleranceType),
+ "trailing_zeros": bool(units.DisplayTrailingZeros),
+ "display_units": enum_name(units.PrimaryDimensionUnit, A.DimensionUnit),
+ "decimal_separator": enum_name(
+ units.DecimalPointCharacter, A.DecimalPointCharacter
+ ),
+ "association_count": d.NumberOfAssociativities,
+ }
+ finally:
+ units.Dispose()
+ prefs.Dispose()
+
+ def _edit_dimension_format(
+ self,
+ dimension,
+ decimal_places=None,
+ trailing_zeros=None,
+ units=None,
+ decimal_separator=None,
+ tolerance_type=None,
+ upper_tolerance=None,
+ lower_tolerance=None,
+ tolerance_decimal_places=None,
+ ):
+ import NXOpen.Annotations as A
+
+ from nx_mcp.authoring import finite
+
+ values = locals().copy()
+ if all(
+ values[k] is None
+ for k in (
+ "decimal_places",
+ "trailing_zeros",
+ "units",
+ "decimal_separator",
+ "tolerance_type",
+ "upper_tolerance",
+ "lower_tolerance",
+ "tolerance_decimal_places",
+ )
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Supply at least one formatting property")
+ for key in ["decimal_places", "tolerance_decimal_places"]:
+ if values[key] is not None and (
+ type(values[key]) is not int or not 0 <= values[key] <= 8
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", f"{key} must be 0..8")
+ if (
+ units is not None
+ and units not in UNITS
+ or tolerance_type is not None
+ and tolerance_type not in TOLERANCES
+ or decimal_separator is not None
+ and decimal_separator not in {"period", "comma"}
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported formatting enum")
+ for key in ["upper_tolerance", "lower_tolerance"]:
+ if values[key] is not None:
+ values[key] = finite(values[key], key)
+ d = self._engineering_owned(dimension, "dimension")
+ before = (d.ComputedSize, d.NumberOfAssociativities, bool(d.IsRetained))
+ prefs = d.GetDimensionPreferences()
+ formatting = prefs.GetUnitsFormatPreferences()
+ try:
+ if trailing_zeros is not None:
+ formatting.DisplayTrailingZeros = trailing_zeros
+ if units is not None:
+ formatting.PrimaryDimensionUnit = getattr(A.DimensionUnit, UNITS[units])
+ if decimal_separator is not None:
+ formatting.DecimalPointCharacter = getattr(
+ A.DecimalPointCharacter, "Period" if decimal_separator == "period" else "Comma"
+ )
+ prefs.SetUnitsFormatPreferences(formatting)
+ d.SetDimensionPreferences(prefs)
+ finally:
+ formatting.Dispose()
+ prefs.Dispose()
+ if tolerance_type is not None:
+ d.ToleranceType = getattr(A.ToleranceType, TOLERANCES[tolerance_type])
+ for key, native, metric in [
+ ("decimal_places", "NominalDecimalPlaces", "MetricNominalDecimalPlaces"),
+ ("tolerance_decimal_places", "ToleranceDecimalPlaces", "MetricToleranceDecimalPlaces"),
+ ]:
+ if values[key] is not None:
+ setattr(d, native, values[key])
+ setattr(d, metric, values[key])
+ for key, native, metric in [
+ ("upper_tolerance", "UpperToleranceValue", "UpperMetricToleranceValue"),
+ ("lower_tolerance", "LowerToleranceValue", "LowerMetricToleranceValue"),
+ ]:
+ if values[key] is not None:
+ metric_value = values[key] * (25.4 if self._units() == "inch" else 1.0)
+ setattr(d, native, metric_value / 25.4)
+ setattr(d, metric, metric_value)
+ d.RedisplayObject()
+ if before != (d.ComputedSize, d.NumberOfAssociativities, bool(d.IsRetained)):
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED", "Formatting changed measured value or association state"
+ )
+ result = self._dimension_format(dimension)
+ result["modified"] = [result["object"]]
+ return result
+
+ def _drawing_construction_visibility(self, view, visible):
+ import NXOpen.UF as U
+
+ part = self._work_part()
+ uf_view = U.UFSession.GetUFSession().View
+ # Part.Curves also includes section lines and other sheet-owned curves.
+ # They are annotations, not model construction geometry.
+ values = [c for c in part.Curves if not uf_view.AskViewDependentStatus(c.Tag)[0]]
+ values += self._datum_objects()
+ for component, _ in self._walk_components(part):
+ if component.IsSuppressed or component.Prototype is None:
+ continue
+ prototype = component.Prototype
+ if not hasattr(prototype, "Curves"):
+ continue
+ for value in (
+ list(prototype.Curves) + list(prototype.Datums) + list(prototype.CoordinateSystems)
+ ):
+ if uf_view.AskViewDependentStatus(value.Tag)[0]:
+ continue
+ occurrence = component.FindOccurrence(value)
+ if occurrence is not None:
+ values.append(occurrence)
+ values = list({int(x.Tag): x for x in values}.values())
+ if len(values) > 20000:
+ raise NXToolError(
+ "NX_OBJECT_LIMIT", "Drawing construction selection exceeds 20000 objects"
+ )
+ if values:
+ if visible:
+ view.DependentDisplay.RemoveErasureOnObjectAndSubobjects(values, False)
+ else:
+ view.DependentDisplay.Erase(values)
+ view.SetAttribute("NX_MCP_DRAWING_CONSTRUCTION_V1", "show" if visible else "hide")
+ return len(values)
+
+ def _view_style(self, view, updates=None):
+ import NXOpen.Preferences as P
+
+ if updates and "centerlines" in updates:
+ raise NXToolError(
+ "NX_UNSUPPORTED_ARGUMENT",
+ "NX v2606 does not persist the tested centerline preference setter; centerlines is read-only",
+ details={"mutation_outcome": "not_started"},
+ )
+ obj = self._drawing_object(view, "drawing_view")
+ b = self._work_part().SettingsManager.CreateDrawingEditViewSettingsBuilder([obj])
+ try:
+ b.InheritSettingsFromSelectedObjects(obj)
+ if updates:
+ for key, value in updates.items():
+ if key == "construction_geometry":
+ self._drawing_construction_visibility(obj, value)
+ continue
+ if key not in STYLE_PROPERTIES:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", f"Unsupported view-style property: {key}"
+ )
+ group, prop = STYLE_PROPERTIES[key]
+ if key.endswith("font"):
+ if type(value) is not int or not 0 <= value <= 7:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Line font must be 0..7 (0 invisible)"
+ )
+ value = P.Font.ValueOf(value)
+ elif key.endswith("width"):
+ if value not in WIDTHS:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported line width")
+ value = getattr(P.Width, WIDTHS[value])
+ elif key == "rendering":
+ if value not in RENDERING:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported rendering style")
+ value = getattr(P.ShadingRenderingStyleOption, RENDERING[value])
+ setattr(getattr(b.ViewStyle, group), prop, value)
+ b.Commit()
+ result = {
+ key: getattr(getattr(b.ViewStyle, group), prop)
+ for key, (group, prop) in STYLE_PROPERTIES.items()
+ }
+ for key, value in result.items():
+ if key.endswith("font"):
+ result[key] = getattr(value, "value", value)
+ elif key.endswith("width"):
+ native = enum_name(value, P.Width)
+ result[key] = next((k for k, v in WIDTHS.items() if v == native), native)
+ elif key == "rendering":
+ native = enum_name(value, P.ShadingRenderingStyleOption)
+ result[key] = next((k for k, v in RENDERING.items() if v == native), native)
+ finally:
+ b.Destroy()
+ attribute = "NX_MCP_DRAWING_CONSTRUCTION_V1"
+ result["construction_geometry"] = (
+ obj.GetStringAttribute(attribute) == "show"
+ if obj.HasUserAttribute(attribute, self.nxopen.NXObject.AttributeType.String, -1)
+ else None
+ )
+ if updates:
+ self._update_model()
+ self._work_part().DraftingViews.UpdateViews([obj])
+ result = self._view_style(view)
+ if any(result[k] != v for k, v in updates.items()):
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED",
+ "Native drafting style did not match requested settings",
+ details={"requested": updates, "actual": result},
+ )
+ return result
diff --git a/src/nx_mcp/engineering.py b/src/nx_mcp/engineering.py
new file mode 100644
index 0000000..17e52ac
--- /dev/null
+++ b/src/nx_mcp/engineering.py
@@ -0,0 +1,1701 @@
+"""Native engineering builders; all calls run in the existing NX transaction dispatcher."""
+
+from __future__ import annotations
+
+import math
+
+from nx_mcp.authoring import finite
+from nx_mcp.runtime import NXToolError
+from nx_mcp.visual_tools import unit_normal
+
+
+class EngineeringMixin:
+ def _engineering_owned(self, ref, kind):
+ obj = self._resolve(ref, {kind})
+ if obj.IsOccurrence or obj.OwningPart != self._work_part():
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select geometry owned by the work part")
+ return obj
+
+ def _engineering_section(self, sketch):
+ part = self._work_part()
+ section = part.Sections.CreateSection()
+ options = part.ScRuleFactory.CreateRuleOptions()
+ try:
+ rule = part.ScRuleFactory.CreateRuleCurveFeature([sketch.Feature], None, options)
+ finally:
+ options.Dispose()
+ section.AddToSection(
+ [rule], None, None, None, sketch.Origin, self.nxopen.Section.Mode.Create, False
+ )
+ return section
+
+ def _engineering_collector(self, objects, kind):
+ part = self._work_part()
+ factory = getattr(part.ScRuleFactory, "CreateRule" + kind + "Dumb")
+ rule = factory(objects)
+ collector = part.ScCollectors.CreateCollector()
+ collector.ReplaceRules([rule], False)
+ return collector
+
+ def _engineering_result(self, feature, modified=()):
+ part = self._work_part()
+ bodies = [self._reference(b, "body", part, "Body") for b in feature.GetBodies()]
+ ref = self._reference(feature, "feature", part, "Feature")
+ return {
+ "feature": ref,
+ "bodies": bodies,
+ "body_count": len(bodies),
+ "body": bodies[0] if bodies else None,
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ "created": [ref],
+ "modified": [self._reference(b, "body", part, "Body") for b in modified],
+ }
+
+ def _engineering_direction(self, direction, origin=(0, 0, 0)):
+ return self._work_part().Directions.CreateDirection(
+ self.nxopen.Point3d(*map(float, origin)),
+ self.nxopen.Vector3d(*unit_normal(direction)),
+ self.nxopen.SmartObject.UpdateOption.WithinModeling,
+ )
+
+ def _extrude(
+ self,
+ sketch_id,
+ distance=None,
+ reverse=False,
+ start=0.0,
+ end_type="distance",
+ symmetric=False,
+ direction=None,
+ target_face=None,
+ boolean="none",
+ targets=None,
+ ):
+ if (
+ start == 0
+ and end_type == "distance"
+ and not symmetric
+ and direction is None
+ and target_face is None
+ and boolean == "none"
+ and not targets
+ ):
+ if distance is None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "distance is required")
+ return self._simple_extrude(sketch_id, distance, reverse)
+ import NXOpen.GeometricUtilities as G
+
+ start = finite(start, "start")
+ if end_type not in {"distance", "through_all", "up_to_face"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported end_type")
+ if boolean not in {"none", "unite", "subtract", "intersect"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported boolean")
+ if symmetric and (end_type != "distance" or start != 0):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Symmetric uses distance with start=0")
+ if (end_type == "up_to_face") != (target_face is not None):
+ raise NXToolError("NX_INVALID_ARGUMENT", "target_face is required only for up_to_face")
+ if end_type == "distance":
+ distance = finite(distance, "distance", True)
+ if not symmetric and distance <= start:
+ raise NXToolError("NX_INVALID_ARGUMENT", "End distance must exceed start")
+ elif distance is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "distance is ignored by non-distance limits")
+ bodies = [self._engineering_owned(r, "body") for r in targets or []]
+ if (boolean == "none") == bool(bodies):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Booleans require explicit targets; none forbids them"
+ )
+ if end_type == "through_all" and not bodies:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Through-all requires boolean targets")
+ sketch = self._engineering_owned(sketch_id, "sketch")
+ face = self._engineering_owned(target_face, "face") if target_face else None
+ axis = unit_normal(direction or self._sketch_frame(sketch)["normal"])
+ if reverse:
+ axis = [-v for v in axis]
+ part = self._work_part()
+ b = part.Features.CreateExtrudeBuilder(None)
+ try:
+ b.Section = self._engineering_section(sketch)
+ b.Direction = self._engineering_direction(
+ axis, [sketch.Origin.X, sketch.Origin.Y, sketch.Origin.Z]
+ )
+ b.Limits.StartExtend.TrimType = G.Extend.ExtendType.Value
+ b.Limits.StartExtend.Value.RightHandSide = str(-distance / 2 if symmetric else start)
+ end = b.Limits.EndExtend
+ end.TrimType = {
+ "distance": G.Extend.ExtendType.Value,
+ "through_all": G.Extend.ExtendType.ThroughAll,
+ "up_to_face": G.Extend.ExtendType.UntilSelected,
+ }[end_type]
+ if end_type == "distance":
+ end.Value.RightHandSide = str(distance / 2 if symmetric else distance)
+ if face:
+ end.Target = face
+ b.BooleanOperation.Type = getattr(
+ G.BooleanOperation.BooleanType,
+ {
+ "none": "Create",
+ "unite": "Unite",
+ "subtract": "Subtract",
+ "intersect": "Intersect",
+ }[boolean],
+ )
+ if bodies:
+ b.BooleanOperation.SetTargetBodies(bodies)
+ feature = b.CommitFeature()
+ finally:
+ b.Destroy()
+ result = self._engineering_result(feature, bodies)
+ result["limits"] = {
+ "start": start,
+ "end_type": end_type,
+ "distance": distance,
+ "symmetric": symmetric,
+ "direction": axis,
+ }
+ result["boolean"] = boolean
+ return result
+
+ def _shell(self, body, thickness, remove_faces=None, outward=False):
+ target = self._engineering_owned(body, "body")
+ thickness = finite(thickness, "thickness", True)
+ faces = [self._engineering_owned(ref, "face") for ref in remove_faces or []]
+ if any(f.GetBody() != target for f in faces):
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Removed faces must belong to body")
+ b = self._work_part().Features.CreateShellBuilder(None)
+ try:
+ b.Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4
+ b.Body = target
+ b.DefaultThickness.RightHandSide = str(thickness)
+ b.DefaultThicknessFlip = not outward
+ if faces:
+ b.RemovedFacesCollector = self._engineering_collector(faces, "Face")
+ feature = b.CommitFeature()
+ finally:
+ b.Destroy()
+ return self._engineering_result(feature, [target])
+
+ def _loft(self, sketches, solid=True):
+ if not 2 <= len(sketches) <= 20 or len(set(sketches)) != len(sketches):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use 2–20 distinct ordered sketches")
+ sections = [self._engineering_owned(s, "sketch") for s in sketches]
+ b = self._work_part().Features.CreateThroughCurvesBuilder(None)
+ try:
+ b.BodyPreference = b.BodyPreferenceTypes.Solid if solid else b.BodyPreferenceTypes.Sheet
+ for sketch in sections:
+ b.SectionsList.Append(self._engineering_section(sketch))
+ feature = b.CommitFeature()
+ finally:
+ b.Destroy()
+ return self._engineering_result(feature)
+
+ def _sketch_primitive(self, sketch_id, primitive, center, width=None, height=None, radius=None):
+ sketch = self._engineering_owned(sketch_id, "sketch")
+ if primitive not in {"circle", "slot", "rounded_rectangle"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown primitive")
+ cx, cy = [finite(x, "center") for x in center]
+ curves = []
+
+ def line(a, b):
+ curves.append(
+ self._create_sketch_line(
+ sketch, self._work_part(), {"x": a[0], "y": a[1]}, {"x": b[0], "y": b[1]}
+ )
+ )
+
+ def arc(x, y, r, a, b):
+ self._sketch_arc_legacy(x, y, r, a, b, sketch_id)
+
+ before = {int(c.Tag) for c in sketch.GetAllGeometry()}
+ if primitive == "circle":
+ if width is not None or height is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Circle accepts radius only")
+ radius = finite(radius, "radius", True)
+ with self._editing_sketch(sketch):
+ arc(cx, cy, radius, 0, 360)
+ else:
+ width, height = finite(width, "width", True), finite(height, "height", True)
+ if primitive == "slot":
+ if radius is not None or width <= height:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Horizontal slot needs width>height and no radius"
+ )
+ radius = height / 2
+ else:
+ radius = finite(radius, "radius", True)
+ if radius >= min(width, height) / 2:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Corner radius must be less than half the shortest side",
+ )
+ left, r, b, t = cx - width / 2, cx + width / 2, cy - height / 2, cy + height / 2
+ with self._editing_sketch(sketch):
+ line((left + radius, b), (r - radius, b))
+ line((r - radius, t), (left + radius, t))
+ if primitive == "slot":
+ arc(r - radius, cy, radius, -90, 90)
+ arc(left + radius, cy, radius, 90, 270)
+ else:
+ line((r, b + radius), (r, t - radius))
+ line((left, t - radius), (left, b + radius))
+ for x, y, a in [
+ (r - radius, b + radius, 270),
+ (r - radius, t - radius, 0),
+ (left + radius, t - radius, 90),
+ (left + radius, b + radius, 180),
+ ]:
+ arc(x, y, radius, a, a + 90)
+ created = [
+ self._reference(c, "curve", self._work_part(), "Curve")
+ for c in sketch.GetAllGeometry()
+ if int(c.Tag) not in before
+ ]
+ return {
+ "curves": created,
+ "curve_count": len(created),
+ "created": created,
+ "diagnostics": self._check_sketch_result(sketch_id),
+ }
+
+ def _copy_project(self, path, prefix, activate=False):
+ import hashlib
+ import json
+ import os
+ import shutil
+ from pathlib import Path
+
+ import NXOpen.UF
+
+ destination = self.workspace.ensure_inside(path)
+ if destination.exists():
+ raise NXToolError("NX_FILE_EXISTS", "Project destination must be a new directory")
+ if not prefix or any(
+ c not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
+ for c in prefix
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Use a nonempty simple filename prefix for the new project"
+ )
+ part = self._work_part()
+ sources = {Path(part.FullPath).resolve()}
+ for component, _ in self._walk_components(part):
+ prototype = component.Prototype
+ if prototype is None or not hasattr(prototype, "FullPath"):
+ raise NXToolError(
+ "NX_UNLOADED_COMPONENT", "Load all project prototypes before copying"
+ )
+ sources.add(Path(prototype.FullPath).resolve())
+ for loaded in self.session.Parts:
+ if Path(loaded.FullPath).resolve() in sources and loaded.IsModified:
+ raise NXToolError("NX_UNSAVED_PART", "Save all project prototypes before copying")
+ for source in sources:
+ self.workspace.ensure_inside(source)
+ if not source.is_file():
+ raise NXToolError("NX_FILE_NOT_FOUND", str(source))
+ base = Path(os.path.commonpath([str(p.parent) for p in sources]))
+ if destination.is_relative_to(base) and any(destination == p.parent for p in sources):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Choose a separate project directory")
+ mapping = {p: destination / p.relative_to(base).parent / (prefix + p.name) for p in sources}
+ loaded_names = {Path(p.FullPath).name.casefold() for p in self.session.Parts}
+ if any(p.name.casefold() in loaded_names for p in mapping.values()):
+ raise NXToolError(
+ "NX_FILE_EXISTS", "Choose a prefix that does not collide with loaded part basenames"
+ )
+ originals = {int(p.Tag) for p in self.session.Parts}
+ hashes = {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in sources}
+ clone = NXOpen.UF.UFSession.GetUFSession().Clone
+ self._require_api(
+ clone, "Initialise", "AddAssembly", "SetNaming", "PerformClone", "Terminate"
+ )
+ created = False
+ try:
+ destination.mkdir(parents=True, exist_ok=False)
+ created = True
+ for p in mapping.values():
+ p.parent.mkdir(parents=True, exist_ok=True)
+ clone.Initialise(clone.OperationClass.CLONE_OPERATION)
+ try:
+ clone.SetDefAction(clone.Action.CLONE)
+ clone.AddAssembly(str(Path(part.FullPath).resolve()))
+ for source, target in mapping.items():
+ clone.SetNaming(str(source), clone.NamingTechnique.USER_NAME, str(target))
+ clone.PerformClone(clone.InitNamingFailures())
+ finally:
+ clone.Terminate()
+ for source, target in mapping.items():
+ if not target.is_file():
+ raise NXToolError(
+ "NX_CLONE_FAILED", "Native clone did not produce every mapped part"
+ )
+ if hashlib.sha256(source.read_bytes()).hexdigest() != hashes[str(source)]:
+ raise NXToolError("NX_SOURCE_CHANGED", "Source changed during project copy")
+ new_top = mapping[Path(part.FullPath).resolve()]
+ self._open_part(str(new_top))
+ copied = self._work_part()
+ resolved = {
+ Path(c.Prototype.FullPath).resolve() for c, _ in self._walk_components(copied)
+ }
+ expected = set(mapping.values()) - {new_top}
+ if resolved != expected:
+ raise NXToolError(
+ "NX_CLONE_REFERENCE_MISMATCH",
+ "Copied assembly does not resolve to mapped prototypes",
+ )
+ manifest = {
+ "source_root": str(base),
+ "destination": str(destination),
+ "top_part": str(new_top),
+ "files": [
+ {
+ "source": str(s),
+ "path": str(t),
+ "sha256": hashlib.sha256(t.read_bytes()).hexdigest(),
+ }
+ for s, t in sorted(mapping.items())
+ ],
+ "dependency_count": len(expected),
+ "references_verified": True,
+ "originals_preserved": True,
+ }
+ (destination / "nx-project-manifest.json").write_text(json.dumps(manifest, indent=2))
+ if not activate:
+ for p in list(self.session.Parts):
+ if int(p.Tag) not in originals and Path(p.FullPath).resolve().is_relative_to(
+ destination
+ ):
+ p.Close(
+ self.nxopen.BasePart.CloseWholeTree.FalseValue,
+ self.nxopen.BasePart.CloseModified.CloseModified,
+ None,
+ )
+ self._activate_part(self._reference(part, "part", part, "Part")["id"])
+ return manifest
+ except Exception:
+ for p in list(self.session.Parts):
+ if int(p.Tag) not in originals and Path(p.FullPath).resolve().is_relative_to(
+ destination
+ ):
+ p.Close(
+ self.nxopen.BasePart.CloseWholeTree.FalseValue,
+ self.nxopen.BasePart.CloseModified.CloseModified,
+ None,
+ )
+ self._activate_part(self._reference(part, "part", part, "Part")["id"])
+ if created:
+ shutil.rmtree(destination)
+ raise
+
+ def _mass_properties(self, body=None, scope="auto"):
+ import NXOpen.UF
+
+ from nx_mcp.hardened import rows, xyz
+
+ bodies = self._geometry(body, scope)
+ if not bodies or any(not b.IsSolidBody for b in bodies):
+ raise NXToolError("NX_NOT_SOLID", "Mass properties require solid bodies")
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ mass, accuracy = uf.Modeling.AskMassProps3d(
+ [b.Tag for b in bodies], len(bodies), 1, 4, 0.0, 1, [0.999] + [0.0] * 10
+ )
+ frame = self._work_part().WCS.CoordinateSystem
+ return {
+ "mass_kg": mass[2],
+ "volume_m3": mass[1],
+ "area_m2": mass[0],
+ "center_of_gravity_m": list(mass[3:6]),
+ "inertia_tensor_centroid_kg_m2": [
+ [mass[12], -mass[19], -mass[21]],
+ [-mass[19], mass[13], -mass[20]],
+ [-mass[21], -mass[20], mass[14]],
+ ],
+ "principal_moments_kg_m2": list(mass[31:34]),
+ "density_kg_m3": mass[46],
+ "body_count": len(bodies),
+ "bodies": [self._reference(b, "body", self._work_part(), "Body") for b in bodies],
+ "coordinate_frame": "work_part_wcs",
+ "wcs_origin_in_part_units": xyz(frame.Origin),
+ "wcs_rotation": rows(frame.Orientation.Element),
+ "native_error_estimates": list(accuracy),
+ "semantics": "sum_of_included_bodies_with_assigned_densities",
+ "warnings": [
+ "Overlapping solids are counted separately. Review assigned densities before using mass results."
+ ],
+ }
+
+ def _draft(self, faces, stationary_face, direction, angle):
+ angle = finite(angle, "angle")
+ if not 0 < abs(angle) < 89:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Draft angle must have magnitude between 0 and 89 degrees"
+ )
+ selected = [self._engineering_owned(f, "face") for f in faces]
+ fixed = self._engineering_owned(stationary_face, "face")
+ if (
+ not selected
+ or fixed in selected
+ or any(f.GetBody() != fixed.GetBody() for f in selected)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Draft faces must share the stationary face's body and exclude that face",
+ )
+ part = self._work_part()
+ b = part.Features.CreateDraftBuilder(None)
+ try:
+ b.AngleTolerance = 0.1
+ b.DistanceTolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4
+ b.TypeOfDraft = b.Type.Face
+ b.Direction = self._engineering_direction(direction)
+ stationary = self._engineering_collector([fixed], "Face")
+ b.StationaryReference.ReplaceRules(stationary.GetRules(), False)
+ group = part.CreateExpressionCollectorSet(
+ self._engineering_collector(selected, "Face"), str(angle), "Angle", 0
+ )
+ b.FaceSetAngleExpressionList.Append(group)
+ feature = b.CommitFeature()
+ finally:
+ b.Destroy()
+ return self._engineering_result(feature, [fixed.GetBody()])
+
+ def _transform_bodies(
+ self, translation, rotation_matrix, bodies=None, copy=False, feature=None
+ ):
+ from nx_mcp.hardened import IDENTITY, vector
+
+ rotation = self._validate_rotation(rotation_matrix)
+ translation = vector(translation, "translation")
+ if feature is not None and (bodies is not None or copy):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "When editing a motion feature, omit bodies and copy"
+ )
+ selected = [self._engineering_owned(r, "body") for r in bodies or []]
+ if feature is None and not selected:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select bodies for a new motion feature")
+ existing = self._engineering_owned(feature, "feature") if feature else None
+ part = self._work_part()
+ copied_feature = None
+ if copy:
+ extract = part.Features.CreateExtractFaceBuilder(None)
+ try:
+ extract.Type = extract.ExtractType.Body
+ extract.Associative = True
+ extract.HideOriginal = False
+ extract.InheritDisplayProperties = True
+ collector = self._engineering_collector(selected, "Body")
+ extract.ExtractBodyCollector.ReplaceRules(collector.GetRules(), False)
+ copied_feature = extract.CommitFeature()
+ selected = list(copied_feature.GetBodies())
+ finally:
+ extract.Destroy()
+ before_features = {int(f.Tag) for f in part.Features}
+ b = part.BaseFeatures.CreateMoveObjectBuilder(existing)
+ try:
+ b.Associative = True
+ b.MoveParents = False
+ if existing is None:
+ b.ObjectToMoveObject.Add(selected)
+ b.MoveObjectResult = b.MoveObjectResultOptions.MoveOriginal
+ b.TransformMotion.Option = b.TransformMotion.Options.CsysToCsys
+ b.TransformMotion.FromCsys = part.CoordinateSystems.CreateCoordinateSystem(
+ self.nxopen.Point3d(0.0, 0.0, 0.0), self._nx_matrix(IDENTITY), False
+ )
+ b.TransformMotion.ToCsys = part.CoordinateSystems.CreateCoordinateSystem(
+ self.nxopen.Point3d(*translation), self._nx_matrix(rotation), False
+ )
+ b.MoveParents = False
+ b.Associative = True
+ result = b.Commit()
+ if result is None:
+ created = [f for f in part.Features if int(f.Tag) not in before_features]
+ result = existing or (created[0] if len(created) == 1 else None)
+ if result is None:
+ raise NXToolError(
+ "NX_MOTION_RESULT_MISSING",
+ "Native motion did not expose one associative feature",
+ )
+ finally:
+ b.Destroy()
+ if not isinstance(result, self.nxopen.Features.MoveObject):
+ raise NXToolError(
+ "NX_MOTION_NOT_ASSOCIATIVE",
+ "NX did not produce an editable MoveObject feature; rolling back",
+ )
+ out = self._engineering_result(result)
+ out.update(
+ copy_feature=self._reference(copied_feature, "feature", part, "Associative copy")
+ if copied_feature
+ else None,
+ translation=translation,
+ rotation_matrix=rotation,
+ transform_semantics="absolute mapping from feature input coordinates; edit feature to replace transform",
+ )
+ return out
+
+ def _component_array(
+ self,
+ component,
+ pattern_type,
+ count,
+ spacing=None,
+ direction=None,
+ count_y=1,
+ spacing_y=None,
+ direction_y=None,
+ center=None,
+ axis=None,
+ angle=None,
+ ):
+ from nx_mcp.hardened import cross, dot, vector
+
+ if (
+ type(count) is not int
+ or not 2 <= count <= 100
+ or type(count_y) is not int
+ or not 1 <= count_y <= 100
+ or count * count_y > 100
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Array requires 2–100 total instances including the seed"
+ )
+ seed = self._resolve(component, {"component"})
+ part = self._work_part()
+ if seed.Parent != part.ComponentAssembly.RootComponent or seed.IsSuppressed:
+ raise NXToolError(
+ "NX_UNSUPPORTED_SCOPE", "Select an unsuppressed immediate child occurrence"
+ )
+ if pattern_type == "rectangular":
+ if center is not None or axis is not None or angle is not None:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Rectangular arrays forbid circular parameters"
+ )
+ dx = unit_normal(direction)
+ spacing = finite(spacing, "spacing", True)
+ if count_y > 1:
+ dy = unit_normal(direction_y)
+ spacing_y = finite(spacing_y, "spacing_y", True)
+ if dot(cross(dx, dy), cross(dx, dy)) < 1e-10:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Array directions must not be parallel"
+ )
+ elif direction_y is not None or spacing_y is not None:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Second direction parameters require count_y>1"
+ )
+ elif pattern_type == "circular":
+ if (
+ spacing is not None
+ or direction is not None
+ or count_y != 1
+ or spacing_y is not None
+ or direction_y is not None
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Circular arrays forbid rectangular parameters"
+ )
+ center = vector(center, "center")
+ axis = unit_normal(axis)
+ angle = finite(angle, "angle", True)
+ if (count - 1) * angle >= 360:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Pitch must not repeat an angular position"
+ )
+ else:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown pattern_type")
+ b = part.ComponentAssembly.CreateComponentPatternBuilder(None)
+ try:
+ b.Associative = True
+ b.ComponentPatternSet.Add(seed)
+ if pattern_type == "rectangular":
+ b.PatternService.PatternType = b.PatternService.PatternEnum.Linear
+ d = b.PatternService.RectangularDefinition
+ d.XDirection = self._engineering_direction(dx)
+ d.XSpacing.NCopies.RightHandSide = str(count)
+ d.XSpacing.PitchDistance.RightHandSide = str(spacing)
+ d.UseYDirectionToggle = count_y > 1
+ d.YSpacing.NCopies.RightHandSide = str(count_y)
+ if count_y > 1:
+ d.YDirection = self._engineering_direction(dy)
+ d.YSpacing.PitchDistance.RightHandSide = str(spacing_y)
+ else:
+ b.PatternService.PatternType = b.PatternService.PatternEnum.Circular
+ d = b.PatternService.CircularDefinition
+ point = part.Points.CreatePoint(self.nxopen.Point3d(*center))
+ d.RotationAxis = part.Axes.CreateAxis(
+ point,
+ self._engineering_direction(axis),
+ self.nxopen.SmartObject.UpdateOption.WithinModeling,
+ )
+ d.AngularSpacing.NCopies.RightHandSide = str(count)
+ d.AngularSpacing.PitchAngle.RightHandSide = str(angle)
+ d.RadialSpacing.NCopies.RightHandSide = "1"
+ pattern = b.Commit()
+ finally:
+ b.Destroy()
+ self._update_model()
+ result = self._component_pattern_record(pattern)
+ if result["total_instances"] != count * count_y:
+ raise NXToolError(
+ "NX_PATTERN_VERIFICATION_FAILED", "Native array count differs; rolling back"
+ )
+ result.update(
+ pattern_type=pattern_type,
+ count=count,
+ count_y=count_y,
+ spacing=spacing,
+ spacing_y=spacing_y,
+ angle_degrees=angle,
+ )
+ return result
+
+ def _set_material(self, bodies, name, density):
+ import NXOpen.UF
+
+ density = finite(density, "density", True)
+ if not isinstance(name, str) or not name.strip() or len(name) > 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Provide a material name of 1–100 characters")
+ selected = [self._engineering_owned(r, "body") for r in bodies]
+ if not selected or any(not b.IsSolidBody for b in selected):
+ raise NXToolError("NX_NOT_SOLID", "Material assignment requires owned solid bodies")
+ part = self._work_part()
+ materials = part.MaterialManager.PhysicalMaterials
+ if any(m.Name.casefold() == name.casefold() for m in materials):
+ raise NXToolError(
+ "NX_MATERIAL_EXISTS",
+ "Choose a new material name to avoid changing other bodies implicitly",
+ )
+ b = materials.CreatePhysicalMaterialBuilder(self.nxopen.PhysicalMaterial.Type.Isotropic)
+ try:
+ b.Name = name
+ b.PropertyTable.SetBaseScalarWithDataPropertyValue(
+ "MassDensity", density, part.UnitCollection.FindObject("KilogramPerCubicMeter")
+ )
+ material = b.Commit()
+ finally:
+ b.Destroy()
+ material.AssignObjects(selected)
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ actual = [
+ uf.Modeling.AskBodyDensity(body.Tag, NXOpen.UF.Modl.DensityUnits.KILOGRAMS_METERS)
+ for body in selected
+ ]
+ if any(abs(v - density) > max(1e-8, density * 1e-8) for v in actual):
+ raise NXToolError(
+ "NX_MATERIAL_VERIFICATION_FAILED",
+ "Assigned native body densities differ; rolling back",
+ )
+ return {
+ "material_name": material.Name,
+ "density_kg_m3": density,
+ "bodies": [self._reference(body, "body", part, "Body") for body in selected],
+ "verified_densities_kg_m3": actual,
+ "properties_defined": ["mass_density"],
+ "warnings": [
+ "This local material defines density only; elastic, thermal and appearance properties are not inferred."
+ ],
+ }
+
+ def _material_info(self, body=None, scope="auto"):
+ import NXOpen.UF
+
+ part = self._work_part()
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ rows = []
+ for obj in self._geometry(body, scope):
+ prototype = obj.Prototype if obj.IsOccurrence else obj
+ material = prototype.OwningPart.MaterialManager.PhysicalMaterials.AskMaterialOfObject(
+ prototype
+ )
+ rows.append(
+ {
+ "body": self._reference(obj, "body", part, "Body"),
+ "material_name": material.Name if material else None,
+ "density_kg_m3": uf.Modeling.AskBodyDensity(
+ prototype.Tag, NXOpen.UF.Modl.DensityUnits.KILOGRAMS_METERS
+ ),
+ }
+ )
+ return {"bodies": rows, "body_count": len(rows)}
+
+ def _sketch_angle(self, sketch_id, line1, line2, value, origin):
+ sketch = self._engineering_owned(sketch_id, "sketch")
+ one, two = self._owned_curve(sketch, line1), self._owned_curve(sketch, line2)
+ if one == two or not all(isinstance(c, self.nxopen.Line) for c in [one, two]):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select two different sketch lines")
+ value = finite(value, "value", True)
+ if value >= 180:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Angle must be between 0 and 180 degrees")
+ a, b = self.nxopen.Sketch.DimensionGeometry(), self.nxopen.Sketch.DimensionGeometry()
+ a.Geometry = one
+ b.Geometry = two
+ with self._editing_sketch(sketch):
+ constraint = sketch.CreateDimension(
+ self.nxopen.Sketch.ConstraintType.AngularDim,
+ a,
+ b,
+ self._sketch_local_point(sketch, origin),
+ None,
+ self.nxopen.Sketch.DimensionOption.CreateAsDriving,
+ )
+ exp = constraint.AssociatedExpression
+ self._work_part().Expressions.EditExpression(exp, str(value))
+ sketch.Update()
+ diagnostics = self._check_sketch_result(sketch_id)
+ return {
+ "constraint": self._reference(
+ constraint, "constraint", self._work_part(), "Angular dimension"
+ ),
+ "expression": self._expression_record(exp),
+ "diagnostics": diagnostics,
+ }
+
+ def _sketch_tangent(self, sketch_id, curve1, curve2):
+ from nx_mcp.hardened import cross, dot, xyz
+
+ sketch = self._engineering_owned(sketch_id, "sketch")
+ a, b = self._owned_curve(sketch, curve1), self._owned_curve(sketch, curve2)
+ if (
+ a == b
+ or not all(isinstance(c, (self.nxopen.Line, self.nxopen.Arc)) for c in [a, b])
+ or all(isinstance(c, self.nxopen.Line) for c in [a, b])
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select a line and arc, or two arcs")
+ with self._editing_sketch(sketch):
+ before = {
+ int(c.Tag)
+ for c in sketch.GetAllConstraintsOfType(
+ self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon
+ )
+ }
+ builder = self._work_part().Sketches.CreateSketchMakeTangentBuilder()
+ try:
+ builder.StationaryObject.Value = a
+ builder.MotionObjects.Add(b)
+ builder.SetCreateConstraints(True)
+ builder.FindRelations()
+ builder.Commit()
+ sketch.Update()
+ constraints = [
+ c
+ for c in sketch.GetAllConstraintsOfType(
+ self.nxopen.Sketch.ConstraintClass.Any,
+ self.nxopen.Sketch.ConstraintType.NoCon,
+ )
+ if int(c.Tag) not in before
+ ]
+ finally:
+ builder.Destroy()
+ diagnostics = self._check_sketch_result(sketch_id)
+ if isinstance(a, self.nxopen.Line) or isinstance(b, self.nxopen.Line):
+ line, circle = (a, b) if isinstance(a, self.nxopen.Line) else (b, a)
+ v = [x - y for x, y in zip(xyz(line.EndPoint), xyz(line.StartPoint), strict=True)]
+ d = [
+ x - y
+ for x, y in zip(xyz(circle.CenterPoint), xyz(line.StartPoint), strict=True)
+ ]
+ normal = cross(v, d)
+ residual = abs((dot(normal, normal) / dot(v, v)) ** 0.5 - circle.Radius)
+ else:
+ distance = (
+ sum(
+ (x - y) ** 2
+ for x, y in zip(xyz(a.CenterPoint), xyz(b.CenterPoint), strict=True)
+ )
+ ** 0.5
+ )
+ residual = min(
+ abs(distance - a.Radius - b.Radius), abs(distance - abs(a.Radius - b.Radius))
+ )
+ if residual > 1e-6 or not constraints:
+ raise NXToolError(
+ "NX_CONSTRAINT_UNSATISFIED",
+ "Native tangent relation was not satisfied persistently",
+ details={"residual": residual, "created_constraints": len(constraints)},
+ )
+ return {
+ "constraints": [
+ self._reference(c, "constraint", self._work_part(), "Tangent") for c in constraints
+ ],
+ "residual": residual,
+ "diagnostics": diagnostics,
+ }
+
+ def _sketch_symmetry(self, sketch_id, curve1, curve2, centerline):
+ from nx_mcp.hardened import dot, xyz
+
+ sketch = self._engineering_owned(sketch_id, "sketch")
+ a, b, c = [self._owned_curve(sketch, r) for r in [curve1, curve2, centerline]]
+ if len({int(o.Tag) for o in [a, b, c]}) != 3 or not all(
+ isinstance(o, self.nxopen.Line) for o in [a, b, c]
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Select two lines and a distinct straight centerline"
+ )
+
+ def constraints():
+ return list(
+ sketch.GetAllConstraintsOfType(
+ self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon
+ )
+ )
+
+ with self._editing_sketch(sketch):
+ before = {int(o.Tag) for o in constraints()}
+ builder = self._work_part().Sketches.CreateSketchSymmetricBuilder()
+ try:
+ builder.StationaryObject.Value = a
+ builder.MotionObjects.Add(b)
+ builder.CenterLine.Value = c
+ builder.ConvertCenterlineToReference = False
+ builder.SetCreateConstraints(True)
+ builder.FindRelations()
+ builder.Commit()
+ finally:
+ builder.Destroy()
+ sketch.Update()
+ origin = xyz(c.StartPoint)
+ axis = [x - y for x, y in zip(xyz(c.EndPoint), origin, strict=True)]
+
+ def reflect(point):
+ delta = [x - y for x, y in zip(xyz(point), origin, strict=True)]
+ t = dot(delta, axis) / dot(axis, axis)
+ return [o + 2 * t * d - v for o, d, v in zip(origin, axis, delta, strict=True)]
+
+ def error(points):
+ return max(
+ sum((x - y) ** 2 for x, y in zip(reflect(p), xyz(q), strict=True)) ** 0.5
+ for p, q in zip([a.StartPoint, a.EndPoint], points, strict=True)
+ )
+
+ residual = min(error([b.StartPoint, b.EndPoint]), error([b.EndPoint, b.StartPoint]))
+ after = constraints()
+ created = [o for o in after if int(o.Tag) not in before]
+ if residual > 1e-6 or not created or not before <= {int(o.Tag) for o in after}:
+ raise NXToolError(
+ "NX_CONSTRAINT_UNSATISFIED",
+ "Native symmetry was not satisfied persistently without deleting constraints",
+ details={"residual": residual, "created_constraints": len(created)},
+ )
+ diagnostics = self._check_sketch_result(sketch_id)
+ return {
+ "constraints": [
+ self._reference(o, "constraint", self._work_part(), "Symmetry") for o in created
+ ],
+ "residual": residual,
+ "diagnostics": diagnostics,
+ }
+
+ def _sketch_trim_extend(self, sketch_id, curve, boundaries, pick, action):
+ sketch = self._engineering_owned(sketch_id, "sketch")
+ selected = self._owned_curve(sketch, curve)
+ limits = [self._owned_curve(sketch, r) for r in boundaries]
+ if not limits or selected in limits or action not in {"trim", "extend"}:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Select distinct boundary curves and trim/extend action"
+ )
+ point = self._sketch_local_point(sketch, pick)
+ with self._editing_sketch(sketch):
+ fn = (
+ self._work_part().Sketches.CreateQuickTrimBuilder
+ if action == "trim"
+ else self._work_part().Sketches.CreateQuickExtendBuilder
+ )
+ builder = fn()
+ try:
+ builder.BoundaryObjects.Add(limits)
+ builder.ExtendBound = False
+ curves = builder.TrimmedCurves if action == "trim" else builder.ExtendedCurves
+ curves.Add(selected, self._work_part().ModelingViews.WorkView, point)
+ builder.Commit()
+ finally:
+ builder.Destroy()
+ diagnostics = self._check_sketch_result(sketch_id)
+ return {"sketch": self._sketch_info(sketch_id), "diagnostics": diagnostics}
+
+ def _render_view(
+ self,
+ path=None,
+ width=1600,
+ height=1000,
+ background="white",
+ color=None,
+ style="studio",
+ lighting=None,
+ ):
+ import hashlib
+ import struct
+ import uuid
+
+ if self.session.IsBatch:
+ raise NXToolError(
+ "NX_VIEWPORT_UNAVAILABLE", "Rendering requires the interactive NX bridge"
+ )
+ if any(type(n) is not int or not 128 <= n <= 4096 for n in [width, height]):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Resolution must be 128–4096 pixels per dimension"
+ )
+ if background not in {"white", "original", "transparent", "color"} or style not in {
+ "studio",
+ "shaded",
+ "shaded_with_edges",
+ }:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported rendering options")
+ if background == "color":
+ if (
+ not isinstance(color, list)
+ or len(color) != 3
+ or any(not 0 <= finite(v, "color") <= 1 for v in color)
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "color requires three RGB values in [0,1]")
+ elif color is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "color requires background=color")
+ if lighting is not None and (type(lighting) is not int or not 1 <= lighting <= 5):
+ raise NXToolError("NX_INVALID_ARGUMENT", "lighting must be a native preset number 1–5")
+ file = (
+ self.workspace.ensure_inside(path)
+ if path
+ else self.workspace.root / "captures" / ("render-" + uuid.uuid4().hex + ".png")
+ )
+ if file.suffix.lower() != ".png" or file.exists():
+ raise NXToolError("NX_INVALID_ARGUMENT", "Choose a new .png path")
+ part = self.session.Parts.Display
+ if part is None:
+ raise NXToolError("NX_NO_DISPLAY_PART", "Open a display part first")
+ file.parent.mkdir(parents=True, exist_ok=True)
+ view = part.ModelingViews.WorkView
+ original_style = view.RenderingStyle
+ lights = builder = None
+ previous_lighting = None
+ try:
+ view.RenderingStyle = getattr(
+ self.nxopen.View.RenderingStyleType,
+ {"studio": "Studio", "shaded": "Shaded", "shaded_with_edges": "ShadedWithEdges"}[
+ style
+ ],
+ )
+ if lighting is not None:
+ lights = part.Views.CreateLighting(None)
+ previous_lighting = lights.LightsShadedViewsLightingCollection
+ lights.LightsShadedViewsLightingCollection = getattr(
+ lights.LightingCollectionType, "Lighting" + str(lighting)
+ )
+ lights.Commit()
+ view.UpdateDisplay()
+ camera = self._view_info()
+ builder = part.Views.CreateStudioImageCaptureBuilder()
+ builder.Source = builder.SourceType.WorkView
+ builder.UnitsEnum = builder.UnitsEnumType.Pixels
+ builder.SetImageDimensionsInteger([height, width])
+ builder.NativeFileBrowser = str(file)
+ builder.BackgroundOption = getattr(
+ builder.BackgroundOptions,
+ {
+ "white": "CustomColor",
+ "color": "CustomColor",
+ "original": "Original",
+ "transparent": "Transparent",
+ }[background],
+ )
+ if background in {"white", "color"}:
+ builder.SetCustomBackgroundColor(
+ [1.0, 1.0, 1.0] if background == "white" else color
+ )
+ builder.Commit()
+ data = file.read_bytes()
+ if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
+ raise NXToolError("NX_CAPTURE_FAILED", "NX did not produce a valid PNG")
+ resolution = list(struct.unpack(">II", data[16:24]))
+ if resolution != [width, height]:
+ raise NXToolError(
+ "NX_CAPTURE_RESOLUTION_MISMATCH",
+ "Native rendering did not honor the requested resolution",
+ )
+ except Exception:
+ file.unlink(missing_ok=True)
+ raise
+ finally:
+ try:
+ try:
+ if builder is not None:
+ builder.Destroy()
+ finally:
+ try:
+ if lights is not None:
+ try:
+ if previous_lighting is not None:
+ lights.LightsShadedViewsLightingCollection = previous_lighting
+ lights.Commit()
+ finally:
+ lights.Destroy()
+ finally:
+ view.RenderingStyle = original_style
+ view.UpdateDisplay()
+ except Exception as error:
+ file.unlink(missing_ok=True)
+ raise NXToolError(
+ "NX_RENDER_RESTORE_FAILED",
+ "Native rendering cleanup failed",
+ details={"mutation_outcome": "partial", "cleanup_error": str(error)},
+ ) from error
+ return {
+ "path": str(file),
+ "artifact_path": str(file.relative_to(self.workspace.root)),
+ "capture_kind": "nx_native_render",
+ "model_preview": True,
+ "resolution": resolution,
+ "requested_resolution": [width, height],
+ "camera": camera,
+ "background": background,
+ "color": color,
+ "style": style,
+ "lighting_preset": lighting,
+ "rendering": "NX StudioImageCaptureBuilder",
+ "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "warnings": [],
+ }
+
+ def _assembly_constraints(self, part):
+ positioner = getattr(getattr(part, "ComponentAssembly", None), "Positioner", None)
+ return list(positioner.Constraints) if positioner is not None else []
+
+ def _assembly_constraint_record(self, constraint):
+ import NXOpen.Positioning
+
+ from nx_mcp.visual_tools import enum_name
+
+ part = self._work_part()
+ cls = NXOpen.Positioning.Constraint
+ references = []
+ for ref in constraint.GetReferences():
+ obj, geom = ref.GetMovableObject(), ref.GetGeometry()
+ kind = next(
+ (
+ name
+ for name, typ in [
+ ("face", self.nxopen.Face),
+ ("edge", self.nxopen.Edge),
+ ("body", self.nxopen.Body),
+ ("component", self.nxopen.Assemblies.Component),
+ ]
+ if isinstance(geom, typ)
+ ),
+ None,
+ )
+ references.append(
+ {
+ "component": self._reference(obj, "component", part, "Component")
+ if isinstance(obj, self.nxopen.Assemblies.Component)
+ else None,
+ "geometry": self._reference(geom, kind, part, kind.title()) if kind else None,
+ "native_geometry_type": type(geom).__name__,
+ }
+ )
+ expression = (
+ constraint.Expression
+ if constraint.ConstraintType in {cls.Type.Distance, cls.Type.Angle}
+ else None
+ )
+ return {
+ "object": self._reference(
+ constraint, "assembly_constraint", part, "Assembly constraint"
+ ),
+ "constraint_type": enum_name(constraint.ConstraintType, cls.Type),
+ "alignment": enum_name(constraint.ConstraintAlignment, cls.Alignment),
+ "solver_status": enum_name(constraint.GetConstraintStatus(), cls.SolverStatus),
+ "suppressed": constraint.Suppressed,
+ "expression": self._expression_record(expression) if expression else None,
+ "references": references,
+ }
+
+ def _list_assembly_constraints(self):
+ rows = [
+ self._assembly_constraint_record(c)
+ for c in self._assembly_constraints(self._work_part())
+ ]
+ return {
+ "constraints": rows,
+ "constraint_count": len(rows),
+ "coordinate_frame": "work_part",
+ "units": self._units(),
+ }
+
+ def _assembly_constraint(
+ self,
+ constraint_type,
+ component,
+ geometry=None,
+ target_component=None,
+ target_geometry=None,
+ value=None,
+ alignment="infer",
+ ):
+ import NXOpen.Positioning
+
+ cls = NXOpen.Positioning.Constraint
+ types = {
+ "fix": "Fix",
+ "touch": "Touch",
+ "distance": "Distance",
+ "parallel": "Parallel",
+ "perpendicular": "Perpendicular",
+ "angle": "Angle",
+ "concentric": "Concentric",
+ }
+ alignments = {"infer": "InferAlign", "same": "CoAlign", "opposite": "ContraAlign"}
+ if constraint_type not in types or alignment not in alignments:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported constraint type or alignment")
+ moving = self._resolve(component, {"component"})
+ if (
+ moving.Parent != self._work_part().ComponentAssembly.RootComponent
+ or moving.IsSuppressed
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Select an unsuppressed immediate child component"
+ )
+ refs = []
+ if constraint_type == "fix":
+ if (
+ any(v is not None for v in [geometry, target_component, target_geometry, value])
+ or alignment != "infer"
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Fix takes only component")
+ refs = [(moving, moving)]
+ else:
+ if any(
+ not isinstance(v, str) or not v
+ for v in [geometry, target_component, target_geometry]
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Provide moving/target geometry and target_component"
+ )
+ target = self._resolve(target_component, {"component"})
+ if target == moving or target.Parent != moving.Parent or target.IsSuppressed:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Target must be a distinct unsuppressed sibling"
+ )
+ for component_obj, geometry_ref in [(moving, geometry), (target, target_geometry)]:
+ geom = self._resolve(geometry_ref, {"face", "edge"})
+ if not geom.IsOccurrence or geom.OwningComponent != component_obj:
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH",
+ "Select occurrence geometry belonging to the specified component",
+ )
+ refs.append((component_obj, geom))
+ if constraint_type in {"distance", "angle"}:
+ value = finite(value, "value")
+ if value < 0 or (constraint_type == "angle" and value > 180):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Distance must be nonnegative; angle must be 0–180 degrees",
+ )
+ elif value is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "value applies only to distance/angle")
+ positioner = self._work_part().ComponentAssembly.Positioner
+ positioner.BeginAssemblyConstraints()
+ try:
+ network = positioner.EstablishNetwork()
+ network.MoveObjectsState = True
+ constraint = positioner.CreateConstraint(True)
+ constraint.ConstraintType = getattr(cls.Type, types[constraint_type])
+ constraint.ConstraintAlignment = getattr(cls.Alignment, alignments[alignment])
+ for component_obj, geom in refs:
+ constraint.CreateConstraintReference(component_obj, geom, False, False)
+ if value is not None:
+ constraint.SetExpression(str(value))
+ network.AddConstraint(constraint)
+ network.Solve()
+ network.ApplyToModel()
+ self._update_model()
+ if constraint.GetConstraintStatus() != cls.SolverStatus.Solved:
+ raise NXToolError(
+ "NX_CONSTRAINT_UNSATISFIED",
+ "Native assembly solver did not satisfy the constraint",
+ details=self._assembly_constraint_record(constraint),
+ )
+ return self._assembly_constraint_record(constraint)
+ finally:
+ try:
+ positioner.ClearNetwork()
+ finally:
+ positioner.EndAssemblyConstraints()
+
+ def _edit_assembly_constraint(self, constraint, value=None, suppressed=None, alignment=None):
+ import NXOpen.Positioning
+
+ obj = self._resolve(constraint, {"assembly_constraint"})
+ cls = NXOpen.Positioning.Constraint
+ if all(v is None for v in [value, suppressed, alignment]):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Provide value, suppressed, or alignment")
+ if value is not None:
+ value = finite(value, "value")
+ if (
+ obj.ConstraintType not in {cls.Type.Distance, cls.Type.Angle}
+ or value < 0
+ or (obj.ConstraintType == cls.Type.Angle and value > 180)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "value requires distance>=0 or angle 0–180 degrees"
+ )
+ if suppressed is not None and type(suppressed) is not bool:
+ raise NXToolError("NX_INVALID_ARGUMENT", "suppressed must be boolean")
+ names = {"infer": "InferAlign", "same": "CoAlign", "opposite": "ContraAlign"}
+ if alignment is not None and alignment not in names:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported alignment")
+ positioner = self._work_part().ComponentAssembly.Positioner
+ positioner.BeginAssemblyConstraints()
+ try:
+ if value is not None:
+ obj.SetExpression(str(value))
+ if suppressed is not None:
+ obj.Suppressed = suppressed
+ if alignment is not None:
+ obj.ConstraintAlignment = getattr(cls.Alignment, names[alignment])
+ network = positioner.EstablishNetwork()
+ network.MoveObjectsState = True
+ network.AddConstraint(obj)
+ network.Solve()
+ network.ApplyToModel()
+ self._update_model()
+ if not obj.Suppressed and obj.GetConstraintStatus() != cls.SolverStatus.Solved:
+ raise NXToolError(
+ "NX_CONSTRAINT_UNSATISFIED", "Edited assembly constraint was not solved"
+ )
+ return self._assembly_constraint_record(obj)
+ finally:
+ try:
+ positioner.ClearNetwork()
+ finally:
+ positioner.EndAssemblyConstraints()
+
+ def _blend(self, edges, radius):
+ radius = finite(radius, "radius", True)
+ selected = [self._engineering_owned(r, "edge") for r in edges]
+ if not selected or len({int(e.GetBody().Tag) for e in selected}) != 1:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select edges of one owned body")
+ target = selected[0].GetBody()
+ b = self._work_part().Features.CreateEdgeBlendBuilder(None)
+ try:
+ b.Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4
+ b.AddChainset(self._engineering_collector(selected, "Edge"), str(radius))
+ result = b.CommitFeature()
+ finally:
+ b.Destroy()
+ return self._engineering_result(result, [target])
+
+ def _chamfer(self, edges, offset):
+ offset = finite(offset, "offset", True)
+ selected = [self._engineering_owned(r, "edge") for r in edges]
+ if not selected or len({int(e.GetBody().Tag) for e in selected}) != 1:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select edges of one owned body")
+ target = selected[0].GetBody()
+ b = self._work_part().Features.CreateChamferBuilder(None)
+ try:
+ b.Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4
+ b.SmartCollector = self._engineering_collector(selected, "Edge")
+ b.Option = b.ChamferOption.SymmetricOffsets
+ b.FirstOffsetExp.RightHandSide = str(offset)
+ result = b.CommitFeature()
+ finally:
+ b.Destroy()
+ return self._engineering_result(result, [target])
+
+ def _hole(self, diameter, depth, x, y, z, body=None, direction=None):
+ import NXOpen.GeometricUtilities
+
+ diameter, depth = finite(diameter, "diameter", True), finite(depth, "depth", True)
+ location = [finite(v, "location") for v in [x, y, z]]
+ targets = (
+ [self._engineering_owned(body, "body")] if body else list(self._work_part().Bodies)
+ )
+ if len(targets) != 1 or not targets[0].IsSolidBody:
+ raise NXToolError("NX_AMBIGUOUS_TARGET", "Specify one owned solid body")
+ axis = unit_normal(direction if direction is not None else [0, 0, 1])
+ b = self._work_part().Features.CreateCylinderBuilder(None)
+ try:
+ b.Origin = self.nxopen.Point3d(*location)
+ b.Direction = self.nxopen.Vector3d(*axis)
+ b.Diameter.RightHandSide = str(diameter)
+ b.Height.RightHandSide = str(depth)
+ b.BooleanOption.Type = NXOpen.GeometricUtilities.BooleanOperation.BooleanType.Subtract
+ b.BooleanOption.SetTargetBodies(targets)
+ result = b.CommitFeature()
+ finally:
+ b.Destroy()
+ out = self._engineering_result(result, targets)
+ out.update(
+ diameter=diameter,
+ depth=depth,
+ location=location,
+ direction=axis,
+ native_feature="cylindrical subtract",
+ )
+ return out
+
+ def _sweep(self, section, guide, boolean="none", targets=None):
+ if boolean not in {"none", "unite", "subtract", "intersect"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported boolean operation")
+ if (boolean == "none") != (targets is None):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Boolean sweep requires explicit targets; none forbids targets",
+ )
+ selected = [self._engineering_owned(t, "body") for t in targets or []]
+ if boolean != "none" and len(selected) != 1:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select exactly one boolean target")
+ a, b = self._engineering_owned(section, "sketch"), self._engineering_owned(guide, "sketch")
+ if a == b:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Section and guide must be different sketches")
+ builder = self._work_part().Features.CreateSweptBuilder(None)
+ try:
+ builder.G0Tolerance = 0.001 if self._units() == "mm" else 0.001 / 25.4
+ builder.G1Tolerance = 0.1
+ builder.SectionList.Append(self._engineering_section(a))
+ builder.GuideList.Append(self._engineering_section(b))
+ feature = builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ out = self._engineering_result(feature)
+ if boolean != "none":
+ out = self._boolean(boolean, targets + [r["id"] for r in out["bodies"]])
+ return out
+
+ def _mate_component(self, component, mate_type, references=None, offset=0.0):
+ offset = finite(offset, "offset")
+ if mate_type not in {"touch", "align", "orient", "center", "align_angle"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported mate type")
+ if not references or len(references) != 2:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Provide moving and target occurrence face/edge references"
+ )
+ target_geom = self._resolve(references[1], {"face", "edge"})
+ if not target_geom.IsOccurrence:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Target geometry must be an occurrence")
+ target = self._reference(
+ target_geom.OwningComponent, "component", self._work_part(), "Component"
+ )["id"]
+ kind = {
+ "touch": "touch",
+ "align": "touch",
+ "orient": "parallel",
+ "center": "concentric",
+ "align_angle": "angle",
+ }[mate_type]
+ if offset and kind not in {"touch", "angle"}:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Offset applies only to touch/align or align_angle"
+ )
+ if kind == "touch" and offset:
+ kind = "distance"
+ return self._assembly_constraint(
+ kind,
+ component,
+ references[0],
+ target,
+ references[1],
+ offset if kind in {"distance", "angle"} else None,
+ "opposite" if mate_type == "touch" else "same" if mate_type == "align" else "infer",
+ )
+
+ def _mirror_body(self, body, plane):
+ from nx_mcp.hardened import IDENTITY
+
+ matrices = {
+ "XY": IDENTITY,
+ "XZ": [[1, 0, 0], [0, 0, -1], [0, 1, 0]],
+ "YZ": [[0, 0, 1], [1, 0, 0], [0, 1, 0]],
+ }
+ if plane not in matrices:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "plane must be XY, XZ or YZ through the work-part origin"
+ )
+ target = self._engineering_owned(body, "body")
+ part = self._work_part()
+ datum = part.Datums.CreateFixedDatumPlane(
+ self.nxopen.Point3d(0.0, 0.0, 0.0), self._nx_matrix(matrices[plane])
+ )
+ b = part.Features.CreateMirrorBodyBuilder(None)
+ try:
+ b.MirrorBodyCollector.ReplaceRules(
+ self._engineering_collector([target], "Body").GetRules(), False
+ )
+ b.Plane.Value = datum
+ b.DeleteSourceBody = False
+ result = b.CommitFeature()
+ finally:
+ b.Destroy()
+ return self._engineering_result(result)
+
+ def _drawing_object(self, ref, kind):
+ if ref.startswith("obj_"):
+ return self._resolve(ref, {kind})
+ pool = (
+ self._work_part().DrawingSheets
+ if kind == "drawing_sheet"
+ else self._work_part().DraftingViews
+ )
+ matches = [o for o in pool if o.Name.casefold() == ref.casefold()]
+ if len(matches) != 1:
+ raise NXToolError(
+ "NX_NOT_FOUND", "Use a typed reference to an existing drawing sheet/view"
+ )
+ return matches[0]
+
+ def _create_drawing(self, name="Sheet1", size="A3", scale=1.0, units="mm"):
+ if units not in {"mm", "in"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Drawing units must be mm or in")
+ dimensions = {
+ "A0": (1189, 841),
+ "A1": (841, 594),
+ "A2": (594, 420),
+ "A3": (420, 297),
+ "A4": (297, 210),
+ }
+ if (
+ size not in dimensions
+ or not name.strip()
+ or any(s.Name.casefold() == name.casefold() for s in self._work_part().DrawingSheets)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Choose A0–A4 and a unique nonempty sheet name"
+ )
+ scale = finite(scale, "scale", True)
+ b = self._work_part().DraftingDrawingSheets.CreateDraftingDrawingSheetBuilder(None)
+ try:
+ b.Option = b.SheetOption.CustomSize
+ b.Units = b.SheetUnits.Metric if units == "mm" else b.SheetUnits.English
+ b.Length, b.Height = [
+ float(v) / (25.4 if units == "in" else 1) for v in dimensions[size]
+ ]
+ b.Name = name
+ b.ScaleNumerator = scale
+ b.ScaleDenominator = 1.0
+ b.ProjectionAngle = b.SheetProjectionAngle.First
+ sheet = b.Commit()
+ finally:
+ b.Destroy()
+ sheet.Open()
+ sheet.SetParameters(
+ sheet.Height, sheet.Length, scale, 1.0, sheet.Units, sheet.ProjectionAngle
+ )
+ actual_scale = list(sheet.GetScale())
+ if not math.isclose(actual_scale[0] / actual_scale[1], scale, abs_tol=1e-10):
+ raise NXToolError("NX_VERIFICATION_FAILED", "Native sheet scale did not persist")
+ return {
+ "object": self._reference(sheet, "drawing_sheet", self._work_part(), "Drawing sheet"),
+ "sheet_name": sheet.Name,
+ "size": size,
+ "dimensions_mm": list(dimensions[size]),
+ "dimensions": [sheet.Length, sheet.Height],
+ "scale": actual_scale[0] / actual_scale[1],
+ "scale_ratio": actual_scale,
+ "projection": "first_angle",
+ "units": units,
+ }
+
+ def _configure_base_view(self, builder, sheet):
+ import NXOpen.Preferences as P
+
+ numerator, denominator = sheet.GetScale()
+ builder.Scale.ScaleType = builder.Scale.Type.Ratio
+ builder.Scale.Numerator = numerator
+ builder.Scale.Denominator = denominator
+ hidden = builder.Style.ViewStyleHiddenLines
+ hidden.HiddenLine = True
+ hidden.Font = P.Font.Dashed
+ hidden.SelfHidden = True
+ builder.Style.ViewStyleVisibleLines.VisibleFont = P.Font.Solid
+
+ def _add_base_view(self, drawing, body, view, position=None):
+ names = {
+ "top": "Top",
+ "front": "Front",
+ "back": "Back",
+ "right": "Right",
+ "left": "Left",
+ "bottom": "Bottom",
+ "isometric": "Isometric",
+ }
+ if view not in names:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported named model view")
+ target = self._engineering_owned(body, "body")
+ part = self._work_part()
+ if len(list(part.Bodies)) != 1 or list(self._walk_components(part)):
+ raise NXToolError(
+ "NX_UNSUPPORTED_SCOPE",
+ "Base-view body selection currently requires a single-body part",
+ )
+ sheet = self._drawing_object(drawing, "drawing_sheet")
+ point = [100.0, 100.0] if position is None else [finite(v, "position") for v in position]
+ if len(point) != 2:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "position must be two sheet coordinates in sheet units"
+ )
+ sheet.Open()
+ b = part.DraftingViews.CreateBaseViewBuilder(None)
+ try:
+ self._configure_base_view(b, sheet)
+ b.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view])
+ b.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point))
+ result = b.Commit()
+ finally:
+ b.Destroy()
+ self._drawing_construction_visibility(result, False)
+ self._place_drawing_view(result, sheet, point)
+ return {
+ "object": self._reference(result, "drawing_view", part, "Base view"),
+ "view_name": result.Name,
+ "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"),
+ "body": self._reference(target, "body", part, "Body"),
+ "orientation": view,
+ **self._drawing_coordinates(sheet, point),
+ }
+
+ def _export_drawing_pdf(self, path):
+ import hashlib
+
+ file = self.workspace.ensure_inside(path)
+ if file.suffix.lower() != ".pdf" or file.exists():
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Choose a new .pdf path; existing files are never overwritten",
+ details={"mutation_outcome": "not_started"},
+ )
+ from nx_mcp.assembly_loading import require_loaded
+
+ require_loaded(self, self._work_part())
+ sheets = list(self._work_part().DrawingSheets)
+ if not sheets:
+ raise NXToolError("NX_NO_DRAWING", "Create a drawing sheet before PDF export")
+ file.parent.mkdir(parents=True, exist_ok=True)
+ with self._drawing_save_context(self._work_part(), force_display=True):
+ # Opening each sheet refreshes its display/CGM presentation before plotting.
+ # PDF export is a non-model operation and has no active modeling undo mark.
+ views = [view for sheet in sheets for view in sheet.GetDraftingViews()]
+ if views:
+ self._work_part().DraftingViews.UpdateViews(views)
+ for sheet in sheets:
+ sheet.Open()
+ b = self._work_part().PlotManager.CreatePrintPdfbuilder()
+ try:
+ b.Filename = str(file)
+ b.Action = b.ActionOption.Native
+ b.Size = b.SizeOption.FullScale
+ b.Units = b.UnitsOption.Metric
+ b.OutputText = b.OutputTextOption.Text
+ b.RasterImages = True
+ b.ShadedGeometry = False
+ b.ImageResolution = b.ImageResolutionOption.High
+ b.Colors = b.Color.AsDisplayed
+ b.SourceBuilder.SetSheets(sheets)
+ b.Commit()
+ data = file.read_bytes()
+ if not data.startswith(b"%PDF-"):
+ raise NXToolError("NX_EXPORT_FAILED", "Native exporter did not produce a PDF")
+ except Exception:
+ file.unlink(missing_ok=True)
+ raise
+ finally:
+ b.Destroy()
+ return {
+ "path": str(file),
+ "artifact_path": str(file.relative_to(self.workspace.root)),
+ "sheet_count": len(sheets),
+ "sheets": [s.Name for s in sheets],
+ "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "units": "mm",
+ "scale": "full_sheet_scale",
+ "output_settings": {
+ "raster_images": True,
+ "shaded_as_wireframe": False,
+ "image_resolution": "high",
+ "colors": "as_displayed",
+ "views_updated": True,
+ },
+ "warnings": [],
+ }
+
+ def _add_projection_view(self, base_view, direction, spacing=60.0):
+ offsets = {"right": (1, 0), "left": (-1, 0), "top": (0, 1), "bottom": (0, -1)}
+ if direction not in offsets:
+ raise NXToolError("NX_INVALID_ARGUMENT", "direction must be right, left, top or bottom")
+ spacing = finite(spacing, "spacing", True)
+ view = self._drawing_object(base_view, "drawing_view")
+ center = view.GetDrawingReferencePoint()
+ dx, dy = offsets[direction]
+ sheet = self._view_sheet(view)
+ target = [center.X + dx * spacing, center.Y + dy * spacing]
+ point = self._sheet_point3d(sheet, target)
+ b = self._work_part().DraftingViews.CreateProjectedViewBuilder(None)
+ try:
+ b.Parent.View.Value = view
+ b.Placement.AlignmentMethod = (
+ b.Placement.Method.Horizontal if dx else b.Placement.Method.Vertical
+ )
+ b.Placement.AlignmentOption = b.Placement.Option.ToView
+ b.Placement.AlignmentView.Value = view
+ b.Placement.Associative = True
+ b.Placement.Placement.SetValue(None, None, point)
+ result = b.Commit()
+ finally:
+ b.Destroy()
+ self._place_drawing_view(result, sheet, target, axes=(0,) if dx else (1,))
+ return {
+ "object": self._reference(result, "drawing_view", self._work_part(), "Projected view"),
+ "view_name": result.Name,
+ "base_view": base_view,
+ **self._drawing_coordinates(
+ sheet, [result.GetDrawingReferencePoint().X, result.GetDrawingReferencePoint().Y]
+ ),
+ "alignment": "native associative alignment; the constrained reference coordinate can differ from the parent",
+ "direction": direction,
+ "spacing": spacing,
+ "spacing_mm": spacing
+ * (25.4 if self._sheet_units(self._view_sheet(view)) == "in" else 1),
+ "sheet_units": self._sheet_units(self._view_sheet(view)),
+ }
+
+ def _add_dimension(
+ self, view, object1, object2=None, dim_type="aligned", origin=None, dimension=None
+ ):
+ methods = {"aligned": "PointToPoint", "horizontal": "Horizontal", "vertical": "Vertical"}
+ if dim_type not in methods:
+ raise NXToolError(
+ "NX_UNSUPPORTED_ARGUMENT", "Use aligned, horizontal or vertical linear dimensions"
+ )
+ drawing_view = self._drawing_object(view, "drawing_view")
+ a = self._drawing_dimension_edge(object1)
+ b = self._drawing_dimension_edge(object2) if object2 else a
+ pa = a.GetVertices()[0]
+ pb = b.GetVertices()[1] if object2 is None else b.GetVertices()[0]
+ point = [100.0, 80.0] if origin is None else [finite(v, "origin") for v in origin]
+ if len(point) != 2:
+ raise NXToolError("NX_INVALID_ARGUMENT", "origin must be [x,y] in sheet units")
+ existing = self._engineering_owned(dimension, "dimension") if dimension else None
+ builder = self._work_part().Dimensions.CreateLinearDimensionBuilder(existing)
+ try:
+ snap = self.nxopen.InferSnapType.SnapType
+ empty = self.nxopen.Point3d(0.0, 0.0, 0.0)
+ builder.FirstAssociativity.SetValue(snap.Start, a, drawing_view, pa, None, None, empty)
+ builder.SecondAssociativity.SetValue(
+ snap.End if object2 is None else snap.Start, b, drawing_view, pb, None, None, empty
+ )
+ builder.Measurement.Method = getattr(
+ builder.Measurement.MeasurementMethod, methods[dim_type]
+ )
+ builder.Origin.OriginPoint = self._sheet_point3d(self._view_sheet(drawing_view), point)
+ result = builder.Commit()
+ finally:
+ builder.Destroy()
+ return {
+ "object": self._reference(result, "dimension", self._work_part(), "Drawing dimension"),
+ "dimension_name": result.Name,
+ "view": view,
+ "dim_type": dim_type,
+ "measured_value": result.ComputedSize,
+ **self._drawing_coordinates(self._view_sheet(drawing_view), point, "origin"),
+ "units": self._units(),
+ "association": "edge start/end" if object2 is None else "edge start points",
+ "edited": dimension is not None,
+ }
diff --git a/src/nx_mcp/experimental.py b/src/nx_mcp/experimental.py
index f0f5100..adf8224 100644
--- a/src/nx_mcp/experimental.py
+++ b/src/nx_mcp/experimental.py
@@ -2,17 +2,16 @@
from __future__ import annotations
-import asyncio
import importlib
import inspect
import json
import sys
-from collections.abc import Awaitable, Callable
+from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
-from typing import Any
+from typing import TYPE_CHECKING, Any
-from mcp.server.fastmcp import FastMCP
-from mcp.server.fastmcp.exceptions import ToolError as MCPToolError
+if TYPE_CHECKING:
+ from mcp.server.fastmcp import FastMCP
from nx_mcp.response import ToolError, ToolResult
from nx_mcp.runtime import NXToolError
@@ -41,8 +40,8 @@
}
-def load_legacy_handlers() -> dict[str, Callable[..., Awaitable[Any]]]:
- handlers: dict[str, Callable[..., Awaitable[Any]]] = {}
+def load_legacy_handlers() -> dict[str, Callable[..., Coroutine[Any, Any, Any]]]:
+ handlers: dict[str, Callable[..., Coroutine[Any, Any, Any]]] = {}
registry_snapshot = dict(ToolRegistry._tools)
newly_loaded: list[str] = []
try:
@@ -61,12 +60,6 @@ def load_legacy_handlers() -> dict[str, Callable[..., Awaitable[Any]]]:
return handlers
-async def _run_legacy_handler(
- handler: Callable[..., Awaitable[Any]], params: dict[str, Any]
-) -> Any:
- return await handler(**params)
-
-
def _secure_params(
method: str,
params: dict[str, Any],
@@ -110,6 +103,8 @@ def add_experimental_tools(
*,
enable_journal: bool,
) -> None:
+ from mcp.server.fastmcp.exceptions import ToolError as MCPToolError
+
for name, handler in load_legacy_handlers().items():
if name in certified_names or (name in JOURNAL_TOOL_NAMES and not enable_journal):
continue
@@ -158,7 +153,17 @@ def execute_legacy(
if handler is None:
raise NXToolError("NX_TOOL_NOT_FOUND", f"Unsupported bridge command: {method}")
secured = _secure_params(method, params, workspace, already_resolved=True)
- result: Any = asyncio.run(_run_legacy_handler(handler, secured))
+ coroutine = handler(**secured)
+ try:
+ coroutine.send(None)
+ except StopIteration as completed:
+ result: Any = completed.value
+ else:
+ coroutine.close()
+ raise NXToolError(
+ "NX_EXPERIMENTAL_ASYNC_UNSUPPORTED",
+ "Experimental NX handlers must complete without suspending.",
+ )
if isinstance(result, ToolError):
raise NXToolError(
result.error_code,
diff --git a/src/nx_mcp/exploded_views.py b/src/nx_mcp/exploded_views.py
new file mode 100644
index 0000000..fadf699
--- /dev/null
+++ b/src/nx_mcp/exploded_views.py
@@ -0,0 +1,481 @@
+"""Native named explosions and associative assembly drawing views on NX 2606."""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+
+from nx_mcp.authoring import finite, page
+from nx_mcp.engineering import EngineeringMixin
+from nx_mcp.runtime import NXToolError
+
+
+class ExplodedViewsMixin:
+ @contextmanager
+ def _drawing_save_context(self, part, force_display=False):
+ """Display native sheets for CGM-preserving saves, then restore the view."""
+ sheets = list(getattr(part, "DrawingSheets", []))
+ if not sheets or (not part.SaveOptions.DrawingCgmData and not force_display):
+ yield
+ return
+ work, display = self.session.Parts.Work, self.session.Parts.Display
+ if self.session.ActiveSketch:
+ raise NXToolError(
+ "NX_SKETCH_ACTIVE", "Finish the sketch before saving drawing preview data"
+ )
+ original_sheet = part.DrawingSheets.CurrentDrawingSheet
+ changed_part = work != part or display != part
+ try:
+ if changed_part:
+ self._activate_part(self._reference(part, "part", part, "Part")["id"], True, True)
+ (original_sheet or sheets[0]).Open()
+ yield
+ finally:
+ try:
+ if original_sheet is None:
+ part.Drafting.ExitDraftingApplication()
+ elif part.DrawingSheets.CurrentDrawingSheet != original_sheet:
+ original_sheet.Open()
+ if changed_part:
+ if display:
+ self._activate_part(
+ self._reference(display, "part", display, "Part")["id"], False, True
+ )
+ if work:
+ self._activate_part(
+ self._reference(work, "part", work, "Part")["id"], True, False
+ )
+ except Exception as error:
+ raise NXToolError(
+ "NX_SAVE_VIEW_RESTORE_FAILED",
+ "Save presentation could not be restored",
+ details={"mutation_outcome": "partial", "restore_error": str(error)},
+ ) from error
+
+ def _save_component_drawing_previews(self, part):
+ seen = set()
+ for component, _ in reversed(self._walk_components(part)):
+ prototype = component.Prototype
+ if int(prototype.Tag) in seen:
+ continue
+ seen.add(int(prototype.Tag))
+ if prototype.IsModified and list(getattr(prototype, "DrawingSheets", [])):
+ with self._drawing_save_context(prototype):
+ status = prototype.Save(
+ self.nxopen.BasePart.SaveComponents.FalseValue,
+ self.nxopen.BasePart.CloseAfterSave.FalseValue,
+ )
+ if status:
+ status.Dispose()
+
+ @staticmethod
+ def _modeling_views(part):
+ views = getattr(part, "ModelingViews", [])
+ return list(views) if hasattr(views, "__iter__") else []
+
+ def _explosions(self, part):
+ return list(getattr(part.ComponentAssembly, "Explosions", []))
+
+ def _explosion(self, reference):
+ part = self._work_part()
+ explosion = self._resolve(reference, {"explosion"})
+ if explosion.OwningPart != part:
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Explosion must belong to the work part")
+ return explosion
+
+ def _explosion_context(self):
+ part = self._work_part()
+ if self.session.Parts.Display != part:
+ raise NXToolError("NX_PART_CONTEXT", "Activate the same work and display part")
+ if self.session.ActiveSketch:
+ raise NXToolError("NX_SKETCH_ACTIVE", "Finish the active sketch first")
+ return part
+
+ @staticmethod
+ def _explosion_uf():
+ import NXOpen.UF
+
+ return NXOpen.UF.UFSession.GetUFSession().Assem
+
+ @staticmethod
+ def _exploded_tree(explosion):
+ result = {}
+
+ def walk(parent, path, suppressed=False):
+ for child in parent.GetChildren():
+ component = child.GetComponent()
+ child_path = path + [component.Name]
+ hidden = suppressed or bool(component.IsSuppressed)
+ result[int(component.Tag)] = (child, component, child_path, hidden)
+ walk(child, child_path, hidden)
+
+ walk(explosion.RootComponent, [])
+ return result
+
+ def _explosion_views(self, explosion):
+ uf = self._explosion_uf()
+ part = self._work_part()
+ result = []
+ for kind, views in [
+ ("modeling", self._modeling_views(part)),
+ ("drawing", part.DraftingViews),
+ ]:
+ for view in views:
+ if int(uf.AskViewExplosion(view.Tag) or 0) == int(explosion.Tag):
+ result.append(
+ {
+ "name": view.Name,
+ "kind": kind,
+ "object": self._reference(
+ view,
+ "drawing_view" if kind == "drawing" else "modeling_view",
+ part,
+ "View",
+ ),
+ }
+ )
+ return result
+
+ def _explosion_record(self, explosion):
+ return {
+ "object": self._reference(explosion, "explosion", self._work_part(), "Explosion"),
+ "name": explosion.Name,
+ "views": self._explosion_views(explosion),
+ }
+
+ def _exploded_record(self, entry):
+ from nx_mcp.hardened import rows, xyz
+
+ child, component, path, suppressed = entry
+ position, rotation = child.GetPosition()
+ assembled_position, assembled_rotation = component.GetPosition()
+ return {
+ "component": self._reference(component, "component", self._work_part(), "Component"),
+ "occurrence_path": path,
+ "translation": xyz(position),
+ "rotation_matrix": rows(rotation),
+ "assembled_translation": xyz(assembled_position),
+ "assembled_rotation_matrix": rows(assembled_rotation),
+ "suppressed": suppressed,
+ }
+
+ def _list_explosions(self):
+ return {
+ "explosions": [self._explosion_record(x) for x in self._explosions(self._work_part())],
+ "coordinate_frame": "assembly",
+ "units": self._units(),
+ }
+
+ def _explosion_info(self, explosion, offset=0, limit=50):
+ ex = self._explosion(explosion)
+ values = [self._exploded_record(e) for e in self._exploded_tree(ex).values()]
+ return {
+ **self._explosion_record(ex),
+ **page(values, offset, limit),
+ "coordinate_frame": "assembly",
+ "units": self._units(),
+ "matrix_convention": "row-major, p_assembly = R * p_component + translation",
+ "geometry_measurements": "ordinary body, clearance and mass tools use assembled geometry",
+ }
+
+ def _create_explosion(self, name):
+ part = self._explosion_context()
+ if (
+ not isinstance(name, str)
+ or not name.strip()
+ or name != name.strip()
+ or len(name) > 132
+ or any(ord(c) < 32 or c in "/\\" for c in name)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Use a nonempty NX name, at most 132 characters"
+ )
+ collection = part.ComponentAssembly.Explosions
+ self._require_api(collection, "Create")
+ if any(x.Name.casefold() == name.casefold() for x in collection):
+ raise NXToolError("NX_NAME_CONFLICT", "Explosion name already exists; use its typed ID")
+ if not self._walk_components(part):
+ raise NXToolError("NX_NO_COMPONENTS", "Create an explosion in an assembly part")
+ ex = collection.Create(name)
+ return {**self._explosion_record(ex), "component_count": len(self._exploded_tree(ex))}
+
+ def _edit_explosion(self, explosion, placements=None, reset_components=None):
+ from nx_mcp.hardened import matmul, matvec, rows, transpose, vector, xyz
+
+ self._explosion_context()
+ ex = self._explosion(explosion)
+ uf = self._explosion_uf()
+ self._require_api(uf, "UnexplodeComponent", "ExplodeComponent")
+ placements = [] if placements is None else placements
+ resets = [] if reset_components is None else reset_components
+ if (
+ not isinstance(placements, list)
+ or not isinstance(resets, list)
+ or not 1 <= len(placements) + len(resets) <= 1000
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Provide 1–1000 placements/resets")
+ tree = self._exploded_tree(ex)
+ selected = set()
+
+ def resolve(reference):
+ if not isinstance(reference, str):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Component references must be strings")
+ component = self._resolve(reference, {"component"})
+ tag = int(component.Tag)
+ if tag not in tree or tree[tag][3]:
+ raise NXToolError(
+ "NX_UNSUPPORTED_SCOPE", "Select an unsuppressed occurrence in this explosion"
+ )
+ if tag in selected:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Each component may appear only once per request"
+ )
+ selected.add(tag)
+ return tag
+
+ reset_tags = [resolve(r) for r in resets]
+ pending = []
+ for item in placements:
+ if (
+ not isinstance(item, dict)
+ or set(item) - {"component", "translation", "rotation_matrix"}
+ or not {"component", "translation"} <= set(item)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Placement requires component and translation; optional rotation_matrix only",
+ )
+ tag = resolve(item["component"])
+ raw_position = item["translation"]
+ raw_rotation = item.get("rotation_matrix")
+ if (
+ not isinstance(raw_position, list)
+ or len(raw_position) != 3
+ or any(type(v) not in (int, float) for v in raw_position)
+ or (
+ "rotation_matrix" in item
+ and (
+ not isinstance(raw_rotation, list)
+ or len(raw_rotation) != 3
+ or any(
+ not isinstance(row, list)
+ or len(row) != 3
+ or any(type(v) not in (int, float) for v in row)
+ for row in raw_rotation
+ )
+ )
+ )
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use numeric 3-vectors and 3x3 matrices")
+ position = vector(raw_position, "translation")
+ rotation = (
+ self._validate_rotation(item["rotation_matrix"])
+ if "rotation_matrix" in item
+ else rows(tree[tag][0].GetPosition()[1])
+ )
+ pending.append((tag, position, rotation))
+ before = {tag: self._exploded_record(entry) for tag, entry in tree.items()}
+ for tag in sorted(reset_tags, key=lambda t: len(tree[t][2])):
+ uf.UnexplodeComponent(ex.Tag, tree[tag][1].Tag)
+ for tag, position, rotation in sorted(pending, key=lambda p: len(tree[p[0]][2])):
+ component = tree[tag][1]
+ # NX stores a component-local post-transform. Reset before measuring
+ # the inherited parent pose so assigning an absolute pose is repeatable.
+ uf.UnexplodeComponent(ex.Tag, component.Tag)
+ child = self._exploded_tree(ex)[tag][0]
+ base_position, base_rotation = child.GetPosition()
+ inverse = transpose(rows(base_rotation))
+ delta_rotation = matmul(inverse, rotation)
+ delta_position = matvec(
+ inverse, [position[i] - xyz(base_position)[i] for i in range(3)]
+ )
+ matrix = [delta_rotation[i] + [delta_position[i]] for i in range(3)] + [
+ [0.0, 0.0, 0.0, 1.0]
+ ]
+ uf.ExplodeComponent(ex.Tag, component.Tag, matrix)
+ after = {
+ tag: self._exploded_record(entry) for tag, entry in self._exploded_tree(ex).items()
+ }
+ for tag, position, rotation in pending:
+ actual = after[tag]
+ if any(
+ abs(a - b) > 1e-7 for a, b in zip(actual["translation"], position, strict=True)
+ ) or any(
+ abs(actual["rotation_matrix"][i][j] - rotation[i][j]) > 1e-7
+ for i in range(3)
+ for j in range(3)
+ ):
+ raise NXToolError(
+ "NX_EXPLOSION_POSITION_MISMATCH",
+ "Native exploded pose differs from the requested absolute pose",
+ details={"actual": actual},
+ )
+ if set(before) != set(after) or any(
+ before[t][k] != after[t][k]
+ for t in before
+ for k in ("assembled_translation", "assembled_rotation_matrix")
+ ):
+ raise NXToolError(
+ "NX_EXPLOSION_ASSEMBLY_CHANGED",
+ "Explosion unexpectedly changed assembled placements",
+ )
+ self._refresh_explosion_traces(ex)
+ self._regenerate_explosion_display()
+ views = [
+ v
+ for v in self._work_part().DraftingViews
+ if int(uf.AskViewExplosion(v.Tag) or 0) == int(ex.Tag)
+ ]
+ if views:
+ self._work_part().DraftingViews.UpdateViews(views)
+ affected = sum(
+ before[t]["translation"] != after[t]["translation"]
+ or before[t]["rotation_matrix"] != after[t]["rotation_matrix"]
+ for t in before
+ )
+ return {
+ **self._explosion_record(ex),
+ "components": [after[t] for t in sorted(selected)],
+ "affected_component_count": affected,
+ "assembled_placements_unchanged": True,
+ "updated_drawing_views": [v.Name for v in views],
+ "coordinate_frame": "assembly",
+ "units": self._units(),
+ "modified": [self._reference(ex, "explosion", self._work_part(), "Explosion")],
+ }
+
+ def _regenerate_explosion_display(self):
+ if not self.session.IsBatch:
+ import NXOpen.UF as U
+
+ display = U.UFSession.GetUFSession().Disp
+ self._require_api(display, "RegenerateDisplay")
+ display.RegenerateDisplay()
+
+ def _show_explosion(self, explosion=None, drawing_view=None, model_view=None):
+ if drawing_view and model_view:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select drawing_view or model_view, not both")
+ part = self._explosion_context()
+ ex = self._explosion(explosion) if explosion is not None else None
+ uf = self._explosion_uf()
+ self._require_api(uf, "SetViewExplosion", "AskViewExplosion")
+ if drawing_view:
+ view = self._drawing_object(drawing_view, "drawing_view")
+ if view.OwningPart != part:
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH", "Drawing view must belong to work part"
+ )
+ elif model_view:
+ view = self._resolve(model_view, {"modeling_view"})
+ if view.OwningPart != part:
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Model view must belong to work part")
+ else:
+ if list(part.DrawingSheets):
+ part.Drafting.ExitDraftingApplication()
+ view = part.ModelingViews.WorkView
+ if ex is not None:
+ self._refresh_explosion_traces(ex)
+ uf.SetViewExplosion(view.Tag, ex.Tag if ex else 0)
+ self._regenerate_explosion_display()
+ if drawing_view:
+ part.DraftingViews.UpdateViews([view])
+ elif model_view is None:
+ view.Fit()
+ if int(uf.AskViewExplosion(view.Tag) or 0) != (int(ex.Tag) if ex else 0):
+ raise NXToolError(
+ "NX_EXPLOSION_VIEW_MISMATCH", "Native view did not accept the explosion"
+ )
+ return {
+ "explosion": self._reference(ex, "explosion", part, "Explosion") if ex else None,
+ "view_name": view.Name,
+ "view_kind": "drawing" if drawing_view else "modeling",
+ "assembled_placements_unchanged": True,
+ }
+
+ def _delete_explosion(self, explosion):
+ self._explosion_context()
+ ex = self._explosion(explosion)
+ views = self._explosion_views(ex)
+ if views:
+ raise NXToolError(
+ "NX_EXPLOSION_IN_USE",
+ "Detach this explosion from its views before deletion",
+ details={"views": views},
+ )
+ ref = self._reference(ex, "explosion", self._work_part(), "Explosion")
+ ex.Delete()
+ return {"deleted": [ref]}
+
+ def _add_base_view(
+ self, drawing, body=None, view="isometric", position=None, scope="body", explosion=None
+ ):
+ if scope == "body":
+ if body is None or explosion is not None:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Body scope requires body and forbids explosion"
+ )
+ return EngineeringMixin._add_base_view(self, drawing, body, view, position)
+ if scope != "assembly" or body is not None:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Use scope=assembly without body for an assembly view"
+ )
+ part = self._explosion_context()
+ ex = self._explosion(explosion) if explosion is not None else None
+ if not self._walk_components(part):
+ raise NXToolError("NX_NO_COMPONENTS", "Assembly view requires component geometry")
+ names = {
+ n: n.title() for n in ("top", "front", "back", "right", "left", "bottom", "isometric")
+ }
+ if view not in names:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported model view")
+ point = [100.0, 100.0] if position is None else [finite(v, "position") for v in position]
+ if len(point) != 2:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "position must contain two sheet coordinates in sheet units"
+ )
+ sheet = self._drawing_object(drawing, "drawing_sheet")
+ if sheet.OwningPart != part:
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Drawing sheet must belong to work part")
+ uf = self._explosion_uf()
+ self._require_api(uf, "SetViewExplosion", "AskViewExplosion")
+ sheet.Open()
+ builder = part.DraftingViews.CreateBaseViewBuilder(None)
+ try:
+ self._configure_base_view(builder, sheet)
+ builder.SelectModelView.SelectedView = part.ModelingViews.FindObject(names[view])
+ builder.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point))
+ result = builder.Commit()
+ finally:
+ builder.Destroy()
+ self._drawing_construction_visibility(result, False)
+ self._place_drawing_view(result, sheet, point)
+ uf.SetViewExplosion(result.Tag, ex.Tag if ex else 0)
+ part.DraftingViews.UpdateViews([result])
+ if int(uf.AskViewExplosion(result.Tag) or 0) != (int(ex.Tag) if ex else 0):
+ raise NXToolError(
+ "NX_EXPLOSION_VIEW_MISMATCH", "Drawing does not reference requested explosion"
+ )
+ return {
+ "object": self._reference(result, "drawing_view", part, "Assembly base view"),
+ "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"),
+ "scope": "assembly",
+ "orientation": view,
+ **self._drawing_coordinates(sheet, point),
+ "explosion": self._reference(ex, "explosion", part, "Explosion") if ex else None,
+ }
+
+ def _add_projection_view(self, base_view, direction, spacing=60.0):
+ if not self._explosions(self._work_part()):
+ return EngineeringMixin._add_projection_view(self, base_view, direction, spacing)
+ parent = self._drawing_object(base_view, "drawing_view")
+ uf = self._explosion_uf()
+ tag = uf.AskViewExplosion(parent.Tag) or 0
+ result = EngineeringMixin._add_projection_view(self, base_view, direction, spacing)
+ view = self._resolve(result["object"]["id"], {"drawing_view"})
+ uf.SetViewExplosion(view.Tag, tag)
+ self._work_part().DraftingViews.UpdateViews([view])
+ if int(uf.AskViewExplosion(view.Tag) or 0) != int(tag):
+ raise NXToolError(
+ "NX_EXPLOSION_VIEW_MISMATCH", "Projected view did not retain its parent's explosion"
+ )
+ result["exploded"] = bool(tag)
+ return result
diff --git a/src/nx_mcp/freeform.py b/src/nx_mcp/freeform.py
new file mode 100644
index 0000000..728b2cb
--- /dev/null
+++ b/src/nx_mcp/freeform.py
@@ -0,0 +1,468 @@
+"""Freeform and direct-face features using NX's associative native builders."""
+
+from __future__ import annotations
+
+from nx_mcp.authoring import finite
+from nx_mcp.runtime import NXToolError
+from nx_mcp.visual_tools import unit_normal
+
+
+def points3(values, minimum=1, maximum=1000):
+ if not isinstance(values, list) or not minimum <= len(values) <= maximum:
+ raise NXToolError("NX_INVALID_ARGUMENT", f"Expected {minimum}..{maximum} points")
+ result = []
+ for value in values:
+ if not isinstance(value, list) or len(value) != 3:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Each point must contain x, y, z")
+ result.append([finite(v, "coordinate") for v in value])
+ return result
+
+
+class FreeformMixin:
+ def _freeform_refs(self, refs, kind):
+ if not isinstance(refs, list) or not 1 <= len(refs) <= 1000 or len(set(refs)) != len(refs):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select 1..1000 distinct objects")
+ return [self._engineering_owned(r, kind) for r in refs]
+
+ def _freeform_builder(self, name, feature=None):
+ part = self._work_part()
+ self._require_api(part.Features, name)
+ factory = getattr(part.Features, name)
+ obj = self._engineering_owned(feature, "feature") if feature else None
+ return factory(obj)
+
+ def _freeform_commit(self, builder):
+ if not builder.Validate():
+ raise NXToolError("NX_INVALID_GEOMETRY", "NX rejected the feature inputs")
+ feature = builder.CommitFeature()
+ result = self._engineering_result(feature)
+ result["feature_type"] = feature.FeatureType
+ return result
+
+ def _spline(self, points, degree=3, method="through_points", periodic=False, feature=None):
+ import NXOpen.Features as F
+
+ values = points3(points, 2)
+ if isinstance(degree, bool) or not isinstance(degree, int) or not 1 <= degree <= 7:
+ raise NXToolError("NX_INVALID_ARGUMENT", "degree must be an integer from 1 to 7")
+ if len(values) <= degree or method not in {"through_points", "poles"}:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Need more points than degree and a supported method"
+ )
+ b = self._freeform_builder("CreateStudioSplineBuilderEx", feature)
+ try:
+ b.IsAssociative = True
+ b.HasPlaneConstraint = False
+ b.Degree = degree
+ b.IsPeriodic = periodic
+ b.Type = getattr(
+ F.StudioSplineBuilderEx.Types,
+ "ThroughPoints" if method == "through_points" else "ByPoles",
+ )
+ manager = b.ConstraintManager
+ manager.Clear()
+ for coords in values:
+ c = manager.CreateGeometricConstraintData()
+ c.Point = self._work_part().Points.CreatePoint(self.nxopen.Point3d(*coords))
+ manager.Append(c)
+ result = self._freeform_commit(b)
+ spline = b.Curve
+ result["curve"] = self._reference(spline, "curve", self._work_part(), "Spline")
+ result["degree"] = spline.Order - 1
+ result["periodic"] = spline.Periodic
+ return result
+ finally:
+ b.Destroy()
+
+ def _curve_section(self, refs):
+ part = self._work_part()
+ objects = self._freeform_refs(refs, "curve")
+ section = part.Sections.CreateSection(0.001, 0.001, 0.5)
+ section.SetAllowedEntityTypes(self.nxopen.Section.AllowTypes.OnlyCurves)
+ rule = part.ScRuleFactory.CreateRuleCurveDumb(objects)
+ section.AddToSection(
+ [rule],
+ objects[0],
+ None,
+ None,
+ self.nxopen.Point3d(0.0, 0.0, 0.0),
+ self.nxopen.Section.Mode.Create,
+ False,
+ )
+ return section
+
+ def _surface_mesh(self, primary, cross, tolerance=0.001):
+ import NXOpen.Features as F
+
+ tolerance = finite(tolerance, "tolerance", True)
+ for sections in (primary, cross):
+ if not isinstance(sections, list) or not 2 <= len(sections) <= 100:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Need 2..100 sections in each mesh direction"
+ )
+ for refs in sections:
+ self._freeform_refs(refs, "curve")
+ b = self._freeform_builder("CreateThroughCurveMeshBuilder")
+ try:
+ b.BodyPreference = F.ThroughCurveMeshBuilder.BodyPreferenceTypes.Sheet
+ b.PositionTolerance = tolerance
+ b.IntersectionTolerance = tolerance
+ for items, target in [(primary, b.PrimaryCurvesList), (cross, b.CrossCurvesList)]:
+ for refs in items:
+ target.Append(self._curve_section(refs))
+ return self._freeform_commit(b)
+ finally:
+ b.Destroy()
+
+ def _bridge_surface(self, first, second, continuity="G0", reverse_second=False):
+ import NXOpen.Features as F
+ import NXOpen.GeometricUtilities as G
+
+ if continuity not in {"G0", "G1", "G2"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "continuity must be G0, G1, or G2")
+ edges = [self._engineering_owned(r, "edge") for r in (first, second)]
+ b = self._freeform_builder("CreateBridgeSurfaceBuilder")
+ try:
+ b.FirstEndObjectType = F.BridgeSurfaceBuilder.EndObjectType.Edge
+ b.SecondEndObjectType = F.BridgeSurfaceBuilder.EndObjectType.Edge
+ b.FirstEdgeSelection.Value = edges[0]
+ b.SecondEdgeSelection.Value = edges[1]
+ b.FirstEdgeContinuity.ContinuityType = getattr(G.Continuity.ContinuityTypes, continuity)
+ b.SecondEdgeContinuity.ContinuityType = getattr(
+ G.Continuity.ContinuityTypes, continuity
+ )
+ b.IsSecondEdgeReversed = reverse_second
+ b.IsFirstEdgeLimitEndToEnd = True
+ b.IsSecondEdgeLimitEndToEnd = True
+ return self._freeform_commit(b)
+ finally:
+ b.Destroy()
+
+ def _sew(self, target, tools, tolerance=0.001, solid=False):
+ import NXOpen.Features as F
+
+ targets = self._freeform_refs([target], "body")
+ others = self._freeform_refs(tools, "body")
+ if target in tools:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Target cannot also be a tool")
+ tolerance = finite(tolerance, "tolerance", True)
+ b = self._freeform_builder("CreateSewBuilder")
+ try:
+ b.Type = F.SewBuilder.Types.Sheet
+ b.BodyPreference = getattr(
+ F.SewBuilder.BodyPreferenceTypes, "Solid" if solid else "Sheet"
+ )
+ b.Tolerance = tolerance
+ for collector, bodies in [
+ (b.TargetBodiesCollector, targets),
+ (b.ToolBodiesCollector, others),
+ ]:
+ collector.ReplaceRules(
+ [self._work_part().ScRuleFactory.CreateRuleBodyDumb(bodies)], False
+ )
+ result = self._freeform_commit(b)
+ unsewn = b.GetUnsewnBodies()
+ if unsewn:
+ raise NXToolError(
+ "NX_INCOMPLETE_SEW",
+ "Some input bodies could not be sewn; operation rolled back",
+ )
+ if solid and any(
+ not self._resolve(x["id"], {"body"}).IsSolidBody for x in result["bodies"]
+ ):
+ raise NXToolError(
+ "NX_INCOMPLETE_SEW", "NX produced a sheet instead of the requested solid"
+ )
+ return result
+ finally:
+ b.Destroy()
+
+ def _thicken(self, faces, first_offset, second_offset=0.0):
+ objects = self._freeform_refs(faces, "face")
+ first = finite(first_offset, "first_offset")
+ second = finite(second_offset, "second_offset")
+ if first == second:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Offsets must differ")
+ b = self._freeform_builder("CreateThickenBuilder")
+ try:
+ b.FaceCollector.ReplaceRules(
+ [self._work_part().ScRuleFactory.CreateRuleFaceDumb(objects)], False
+ )
+ b.Tolerance = 0.001
+ b.FirstOffset.RightHandSide = str(first)
+ b.SecondOffset.RightHandSide = str(second)
+ return self._freeform_commit(b)
+ finally:
+ b.Destroy()
+
+ def _edit_faces(self, faces, action, distance=None, direction=None, replacement=None):
+ import NXOpen.Features as F
+
+ objects = self._freeform_refs(faces, "face")
+ factories = {
+ "move": "CreateAdmMoveFaceBuilder",
+ "offset": "CreateOffsetFaceBuilder",
+ "replace": "CreateReplaceFaceBuilder",
+ "heal": "CreateDeleteFaceBuilder",
+ }
+ if action not in factories:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported face edit")
+ if (
+ (distance is not None) != (action in {"move", "offset"})
+ or (direction is not None) != (action == "move")
+ or (replacement is not None) != (action == "replace")
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "move requires distance/direction; offset distance; replace replacement; heal no extra parameters",
+ )
+ if distance is not None:
+ distance = finite(distance, "distance")
+ if direction is not None:
+ direction = unit_normal(direction)
+ replacing = self._engineering_owned(replacement, "face") if replacement else None
+ b = self._freeform_builder(factories[action])
+ try:
+ rule = self._work_part().ScRuleFactory.CreateRuleFaceDumb(objects)
+ if action == "move":
+ import NXOpen.GeometricUtilities as G
+
+ b.FaceToMove.FaceCollector.ReplaceRules([rule], False)
+ b.Motion.Option = G.ModlMotion.Options.Distance
+ b.Motion.DistanceVector = self._engineering_direction(direction)
+ b.Motion.DistanceValue.RightHandSide = str(distance)
+ elif action == "offset":
+ b.FaceCollector.ReplaceRules([rule], False)
+ b.Distance.RightHandSide = str(abs(distance))
+ b.Direction = distance < 0
+ elif action == "replace":
+ b.ReplaceFaces.ReplaceRules([rule], False)
+ b.ReplacementFaces.ReplaceRules(
+ [self._work_part().ScRuleFactory.CreateRuleFaceDumb([replacing])], False
+ )
+ else:
+ b.Type = F.DeleteFaceBuilder.SelectTypes.Face
+ b.FaceCollector.ReplaceRules([rule], False)
+ b.Heal = True
+ b.AllowPartialDelete = False
+ result = self._freeform_commit(b)
+ health = self._model_health()
+ if not health["healthy"]:
+ raise NXToolError(
+ "NX_INVALID_GEOMETRY",
+ "Face edit left native geometry errors",
+ details={"health": health},
+ )
+ result["repair_diagnostics"] = {
+ "action": action,
+ "source_faces": faces,
+ "health": health,
+ }
+ return result
+ except Exception as error:
+ if isinstance(error, NXToolError):
+ error.details.update(action=action, source_faces=faces)
+ raise
+ raise NXToolError(
+ "NX_FACE_EDIT_FAILED",
+ str(error),
+ nx_code=getattr(error, "ErrorCode", None),
+ details={"action": action, "source_faces": faces},
+ suggestion="Inspect native diagnostics and explicitly refine the face selection; reacquire IDs after rollback.",
+ ) from error
+ finally:
+ b.Destroy()
+
+ def _curve_analysis(self, curve, samples=21):
+ import math
+
+ import NXOpen.UF as U
+
+ from nx_mcp.hardened import cross, dot
+
+ if type(samples) is not int or not 2 <= samples <= 1000:
+ raise NXToolError("NX_INVALID_ARGUMENT", "samples must be 2..1000")
+ obj = self._resolve(curve, {"curve"})
+ if obj.IsOccurrence or obj.OwningPart != self._work_part():
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select owned work-part geometry")
+ uf = U.UFSession.GetUFSession()
+ values = []
+ for i in range(samples):
+ t = i / (samples - 1)
+ data = uf.ModlGeneral.EvaluateCurve(obj.Tag, t, 2)
+ point, first, second = data[:3], data[3:6], data[6:9]
+ speed = math.sqrt(dot(first, first))
+ if speed <= 1e-12:
+ values.append(
+ {
+ "parameter": t,
+ "point": point,
+ "singular": True,
+ "curvature": None,
+ "tangent": None,
+ }
+ )
+ continue
+ product = cross(first, second)
+ curvature = math.sqrt(dot(product, product)) / speed**3
+ values.append(
+ {
+ "parameter": t,
+ "point": point,
+ "singular": False,
+ "curvature": curvature,
+ "radius": 1 / curvature if curvature > 1e-12 else None,
+ "tangent": [v / speed for v in first],
+ }
+ )
+ result = {
+ "curve": self._reference(
+ obj,
+ "curve",
+ self._work_part(),
+ "Curve",
+ ),
+ "samples": values,
+ "sample_count": samples,
+ "parameterization": "normalized_native_parameter_0_to_1",
+ "units": self._units(),
+ "curvature_units": "1/" + self._units(),
+ "coordinate_frame": "work_part",
+ "method": "native first and second derivatives; sampled, not global extrema",
+ }
+ if hasattr(obj, "Get3DPoles"):
+ from nx_mcp.hardened import xyz
+
+ result["spline"] = {
+ "degree": obj.Order - 1,
+ "periodic": obj.Periodic,
+ "knots": list(obj.GetKnots()),
+ "poles": [xyz(p) for p in obj.Get3DPoles()],
+ }
+ return result
+
+ def _surface_continuity(
+ self,
+ first,
+ second,
+ position_tolerance=0.001,
+ angle_tolerance=0.1,
+ curvature_tolerance=0.01,
+ samples=21,
+ ):
+ import math
+
+ import NXOpen.UF as U
+
+ from nx_mcp.surface_math import continuity_difference, shape_operator
+
+ if type(samples) is not int or not 2 <= samples <= 200:
+ raise NXToolError("NX_INVALID_ARGUMENT", "samples must be 2..200 per edge")
+ edges = [self._engineering_owned(r, "edge") for r in (first, second)]
+ adjacent = [list(edge.GetFaces()) for edge in edges]
+ if any(len(faces) != 1 for faces in adjacent):
+ raise NXToolError(
+ "NX_AMBIGUOUS_GEOMETRY", "Select boundary edges with exactly one adjacent face"
+ )
+ tolerances = [
+ finite(v, "tolerance", True)
+ for v in [position_tolerance, angle_tolerance, curvature_tolerance]
+ ]
+ uf = U.UFSession.GetUFSession()
+ self._require_api(U.UFConstants, "UF_MODL_EVAL_DERIV2")
+ reports = []
+ for side in range(2):
+ source, target = edges[side], edges[1 - side]
+ face_a, face_b = adjacent[side][0], adjacent[1 - side][0]
+ evaluator = uf.Eval.Initialize2(source.Tag)
+ limits = uf.Eval.AskLimits(evaluator)
+ for i in range(samples):
+ t = i / (samples - 1)
+ point = uf.Eval.EvaluateUnitVectors(
+ evaluator, limits[0] + t * (limits[1] - limits[0])
+ )[0]
+ distance, _, closest, _ = uf.Modeling.AskMinimumDist3(
+ 2, 0, target.Tag, 1, point, 0, [0.0] * 3
+ )
+ record = {
+ "source_edge": side,
+ "parameter": t,
+ "point": list(point),
+ "closest_point": list(closest),
+ "gap": distance,
+ }
+ try:
+ operators = []
+ for face, coords in [(face_a, point), (face_b, closest)]:
+ uv, _ = uf.Modeling.AskFaceParm(face.Tag, coords)
+ data = uf.Modeling.EvaluateFace(
+ face.Tag, U.UFConstants.UF_MODL_EVAL_DERIV2, uv
+ )
+ operators.append(
+ shape_operator(
+ data.SrfDu, data.SrfDv, data.SrfD2u, data.SrfDudv, data.SrfD2v
+ )
+ )
+ angle, curvature = continuity_difference(*operators)
+ if not all(math.isfinite(v) for v in [distance, angle, curvature]):
+ raise NXToolError("NX_SINGULAR_SURFACE", "Nonfinite differential geometry")
+ record.update(
+ {
+ "normal_angle_degrees": angle,
+ "curvature_difference": curvature,
+ "status": "measured",
+ }
+ )
+ except NXToolError as error:
+ record.update({"status": "unresolved", "reason": str(error)})
+ reports.append(record)
+ valid = [r for r in reports if r["status"] == "measured"]
+ max_gap = max(r["gap"] for r in reports)
+ max_angle = max((r["normal_angle_degrees"] for r in valid), default=None)
+ max_curvature = max((r["curvature_difference"] for r in valid), default=None)
+ g0 = max_gap <= tolerances[0]
+ complete = len(valid) == len(reports)
+ g1 = g0 and max_angle <= tolerances[1] if complete else None
+ g2 = g1 and max_curvature <= tolerances[2] if complete else None
+ return {
+ "checks": {"G0": g0, "G1": g1, "G2": g2},
+ "maximum_gap": max_gap,
+ "maximum_normal_angle_degrees": max_angle,
+ "maximum_curvature_difference": max_curvature,
+ "samples": reports,
+ "sample_count": len(reports),
+ "unresolved_count": len(reports) - len(valid),
+ "tolerances": {
+ "position": tolerances[0],
+ "angle_degrees": tolerances[1],
+ "curvature": tolerances[2],
+ },
+ "units": self._units(),
+ "curvature_units": "1/" + self._units(),
+ "method": "Bidirectional normalized-parameter edge samples; native closest points and surface derivative tensors. G2 compares orientation-aligned 3D shape operators (Frobenius norm). Sampled checks, not a global continuity certificate.",
+ }
+
+ def _trim_sheet(self, body, boundaries, region_point, keep=True):
+ import NXOpen.Features as F
+
+ target = self._engineering_owned(body, "body")
+ if target.IsSolidBody:
+ raise NXToolError("NX_NOT_SHEET", "Select a sheet body")
+ self._freeform_refs(boundaries, "curve")
+ point = points3([region_point])[0]
+ b = self._freeform_builder("CreateTrimsheetBuilder")
+ try:
+ b.TargetBodies.Add(target)
+ b.BoundaryObjects.Add(self._curve_section(boundaries))
+ b.KeepDiscardMethod = (
+ F.TrimSheetBuilder.KeepDiscardOption.Keep
+ if keep
+ else F.TrimSheetBuilder.KeepDiscardOption.Discard
+ )
+ b.Tolerance = 0.001
+ b.OutputExactGeometry = True
+ p = self._work_part().Points.CreatePoint(self.nxopen.Point3d(*point))
+ b.Regions.Append(self._work_part().CreateRegionPoint(p, target))
+ return self._freeform_commit(b)
+ finally:
+ b.Destroy()
diff --git a/src/nx_mcp/freeform_server.py b/src/nx_mcp/freeform_server.py
new file mode 100644
index 0000000..a2bb758
--- /dev/null
+++ b/src/nx_mcp/freeform_server.py
@@ -0,0 +1,71 @@
+"""Intent-oriented contracts for freeform geometry and imported face editing."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+READ_ONLY: set[str] = set()
+NON_MODEL: set[str] = set()
+
+
+def nx_spline(
+ points: list[list[float]],
+ degree: int = 3,
+ method: Literal["through_points", "poles"] = "through_points",
+ periodic: bool = False,
+ feature: str | None = None,
+):
+ """Create or edit an associative native 3D Studio Spline using work-part coordinates and units. Supply 2..1000 points, more than degree (1..7). through_points interpolates the points; poles uses a control polygon. feature is an existing Studio Spline feature ID to replace its defining points. No sketch plane is imposed. Returns feature and curve IDs, actual degree and periodicity. Editing replaces defining-point constraints; downstream geometry updates under the operation rollback mark."""
+
+
+def nx_surface_mesh(primary: list[list[str]], cross: list[list[str]], tolerance: float = 0.001):
+ """Create a native associative Through Curve Mesh sheet from intersecting primary/cross curve sections. Each direction requires 2..100 ordered sections, each a list of work-part curve IDs. Curves must form a compatible intersecting network; tolerance uses part units. Returns every resulting body. Existing nx_loft remains available for through-sketch sections."""
+
+
+def nx_bridge_surface(
+ first: str,
+ second: str,
+ continuity: Literal["G0", "G1", "G2"] = "G0",
+ reverse_second: bool = False,
+):
+ """Bridge two work-part sheet edges with an associative native surface. G0 means position, G1 tangent and G2 curvature continuity against adjacent surfaces. Uses full edge ranges; reverse_second changes edge parameter alignment. Requested continuity is a builder constraint, not an independent quality certification."""
+
+
+def nx_sew(target: str, tools: list[str], tolerance: float = 0.001, solid: bool = False):
+ """Sew work-part sheet bodies associatively with native NX tolerances in part units. target and tools are distinct body IDs. Reject and roll back incomplete sewing. solid=True additionally requires an actual closed solid result; NX's fallback sheet is rejected. Inputs become feature parents; return all result bodies."""
+
+
+def nx_thicken(faces: list[str], first_offset: float, second_offset: float = 0.0):
+ """Thicken selected work-part sheet faces into a native solid feature between two different signed normal offsets in part units. Returns all result bodies. Face normals determine offset direction. Uses native exact offset behavior; failures roll back."""
+
+
+def nx_edit_faces(
+ faces: list[str],
+ action: Literal["move", "offset", "replace", "heal"],
+ distance: float | None = None,
+ direction: list[float] | None = None,
+ replacement: str | None = None,
+):
+ """Edit selected work-part faces, including imported solids, through native features. move requires signed distance and a work-part direction vector; offset requires signed normal distance; replace requires a replacement face ID; heal deletes selected faces and extends neighbors to close the gap. Other arguments are rejected. No partial delete is allowed. Reacquire topology IDs after edits and inspect model health. Lengths use part units."""
+
+
+def nx_trim_sheet(body: str, boundaries: list[str], region_point: list[float], keep: bool = True):
+ """Trim a work-part sheet with curve boundaries supplied as a native section. region_point=[x,y,z] identifies the region to keep (or discard when keep=False); all coordinates use work-part units. Curves must divide the target into valid regions. Exact native trim, associative feature, rollback on invalid boundaries."""
+
+
+def nx_curve_analysis(curve: str, samples: int = 21):
+ """Read native curve derivatives at 2..1000 equally spaced normalized parameters. Accepts a work-part curve ID. Returns positions, tangents, curvature (1/part-unit), radius, singular samples, and spline knots/poles when applicable. Sampling is not uniform arc length and does not certify global extrema."""
+
+
+def nx_surface_continuity(
+ first: str,
+ second: str,
+ position_tolerance: float = 0.001,
+ angle_tolerance: float = 0.1,
+ curvature_tolerance: float = 0.01,
+ samples: int = 21,
+):
+ """Sample G0/G1/G2 continuity between two work-part boundary edges with one adjacent face each. Returns bidirectional native closest-point gaps, normal angles and curvature-tensor differences. Position tolerance uses part units, angle degrees, curvature 1/part-unit. 2..200 samples per edge; G2 uses the orientation-aligned shape-operator Frobenius norm. Unresolved singular samples prevent a G1/G2 pass. Sampled checks are not a global continuity certificate."""
+
+
+READ_ONLY.update({"nx_curve_analysis", "nx_surface_continuity"})
diff --git a/src/nx_mcp/hardened.py b/src/nx_mcp/hardened.py
new file mode 100644
index 0000000..72234d2
--- /dev/null
+++ b/src/nx_mcp/hardened.py
@@ -0,0 +1,1923 @@
+"""NX 2606 integration: explicit frames, guarded mutations and inspectable results.
+
+NXOpen is accessed only by the existing main-thread dispatcher.
+"""
+
+from __future__ import annotations
+
+import inspect
+import math
+import uuid
+from pathlib import Path
+
+from nx_mcp import (
+ assembly_documentation_server,
+ documentation_editing_server,
+ freeform_server,
+ manufacturing_server,
+ sheet_metal_server,
+)
+from nx_mcp.advanced_authoring import AdvancedAuthoringMixin
+from nx_mcp.annotation_updates import AnnotationUpdatesMixin
+from nx_mcp.assembly_documentation import AssemblyDocumentationMixin
+from nx_mcp.authoring import AuthoringMixin
+from nx_mcp.authoring_server import NON_MODEL as AUTHORING_NON_MODEL
+from nx_mcp.authoring_server import READ_ONLY as AUTHORING_READ_ONLY
+from nx_mcp.documentation_editing import DocumentationEditingMixin
+from nx_mcp.drawing_preferences import DrawingPreferencesMixin
+from nx_mcp.engineering import EngineeringMixin
+from nx_mcp.exploded_views import ExplodedViewsMixin
+from nx_mcp.freeform import FreeformMixin
+from nx_mcp.inspection import InspectionMixin
+from nx_mcp.inventory import compact_reference, page
+from nx_mcp.legacy_repairs import LegacyRepairsMixin
+from nx_mcp.manufacturing import ManufacturingMixin
+from nx_mcp.nx_bridge import NXOpenExecutor
+from nx_mcp.planar_dxf import PlanarDxfMixin
+from nx_mcp.recovery import OperationStore, timestamp
+from nx_mcp.reference_geometry import ReferenceGeometryMixin
+from nx_mcp.release_engineering import ReleaseEngineeringMixin
+from nx_mcp.review_tools import ReviewToolsMixin
+from nx_mcp.runtime import NXToolError
+from nx_mcp.sheet_metal import SheetMetalMixin
+from nx_mcp.thread_standards import ThreadStandardsMixin
+from nx_mcp.visual_tools import VisualToolsMixin
+
+READ_ONLY = {
+ "nx_flat_pattern_orientation_edges",
+ "nx_list_reference_sets",
+ "nx_list_datums",
+ "nx_display_info",
+ "nx_list_sections",
+ "nx_sketch_diagnostics",
+ "nx_view_info",
+ "nx_check_interference",
+ "nx_check_clearance",
+ "nx_status",
+ "nx_list_sketches",
+ "nx_list_features",
+ "nx_list_bodies",
+ "nx_list_components",
+ "nx_list_open_parts",
+ "nx_get_bounding_box",
+ "nx_measure_volume",
+ "nx_measure_distance",
+ "nx_measure_angle",
+ "nx_get_feature_info",
+ "nx_sketch_info",
+ "nx_list_topology",
+ "nx_checkpoint_state",
+ "nx_capabilities",
+ "nx_operation_status",
+}
+# Files, session lifecycle, and undo itself cannot be reversed by a model undo mark.
+NON_MODEL = {
+ "nx_copy_project",
+ "nx_highlight_collisions",
+ "nx_clear_highlights",
+ "nx_create_part",
+ "nx_open_part",
+ "nx_activate_part",
+ "nx_close_part",
+ "nx_save_part",
+ "nx_save_as",
+ "nx_export_step",
+ "nx_screenshot",
+ "nx_render_view",
+ "nx_export_drawing_pdf",
+ "nx_undo",
+ "nx_checkpoint",
+ "nx_rollback",
+}
+
+
+def vector(value, name="vector"):
+ if not isinstance(value, (list, tuple)) or len(value) != 3:
+ raise NXToolError("NX_INVALID_ARGUMENT", name + " must contain three numbers")
+ result = [float(v) for v in value]
+ if not all(math.isfinite(v) for v in result):
+ raise NXToolError("NX_INVALID_ARGUMENT", name + " must be finite")
+ return result
+
+
+def dot(a, b):
+ return sum(x * y for x, y in zip(a, b, strict=False))
+
+
+def cross(a, b):
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
+
+
+def xyz(p):
+ return [p.X, p.Y, p.Z]
+
+
+def rows(m):
+ return [[m.Xx, m.Yx, m.Zx], [m.Xy, m.Yy, m.Zy], [m.Xz, m.Yz, m.Zz]]
+
+
+def matvec(m, p):
+ return [dot(row, p) for row in m]
+
+
+def matmul(a, b):
+ return [[sum(a[i][k] * b[k][j] for k in range(3)) for j in range(3)] for i in range(3)]
+
+
+def transpose(m):
+ return [list(v) for v in zip(*m, strict=False)]
+
+
+def add(a, b):
+ return [x + y for x, y in zip(a, b, strict=False)]
+
+
+IDENTITY = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
+
+
+READ_ONLY.update(
+ AUTHORING_READ_ONLY
+ | sheet_metal_server.READ_ONLY
+ | freeform_server.READ_ONLY
+ | manufacturing_server.READ_ONLY
+ | assembly_documentation_server.READ_ONLY
+ | documentation_editing_server.READ_ONLY
+)
+NON_MODEL.update(
+ AUTHORING_NON_MODEL
+ | sheet_metal_server.NON_MODEL
+ | freeform_server.NON_MODEL
+ | manufacturing_server.NON_MODEL
+ | assembly_documentation_server.NON_MODEL
+ | documentation_editing_server.NON_MODEL
+)
+
+
+class HardenedExecutor(
+ LegacyRepairsMixin,
+ PlanarDxfMixin,
+ DrawingPreferencesMixin,
+ ReleaseEngineeringMixin,
+ ReferenceGeometryMixin,
+ DocumentationEditingMixin,
+ AnnotationUpdatesMixin,
+ ThreadStandardsMixin,
+ FreeformMixin,
+ ManufacturingMixin,
+ AssemblyDocumentationMixin,
+ SheetMetalMixin,
+ ExplodedViewsMixin,
+ EngineeringMixin,
+ AdvancedAuthoringMixin,
+ AuthoringMixin,
+ ReviewToolsMixin,
+ VisualToolsMixin,
+ InspectionMixin,
+ NXOpenExecutor,
+):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.session_id = uuid.uuid4().hex
+ self.objects.session_id = self.session_id
+ self._part_generations = {}
+ self._history = []
+ self._checkpoints = {}
+ self.store = OperationStore(self.workspace.root)
+ self.store.recover(self.session_id)
+ self._current_operation = None
+ for module in [freeform_server, manufacturing_server, assembly_documentation_server]:
+ for name in vars(module):
+ if name.startswith("nx_"):
+ self._handlers[name] = getattr(self, "_" + name[3:])
+ for name, fn in vars(documentation_editing_server).items():
+ if name.startswith("nx_") and inspect.isfunction(fn):
+ self._handlers[name] = getattr(self, "_" + name[3:])
+ self._handlers.update(
+ {
+ "nx_flat_pattern_orientation_edges": self._flat_pattern_orientation_edges,
+ "nx_list_reference_sets": self._list_reference_sets,
+ "nx_create_reference_set": self._create_reference_set,
+ "nx_set_component_reference_set": self._set_component_reference_set,
+ "nx_list_datums": self._list_datums,
+ "nx_set_datum_visibility": self._set_datum_visibility,
+ "nx_resolve_geometry": self._resolve_geometry,
+ "nx_sheet_metal_schema": self._sheet_metal_schema,
+ "nx_create_path_sketch": self._create_path_sketch,
+ "nx_add_flat_pattern_view": self._add_flat_pattern_view,
+ "nx_sheet_metal_annotation": self._sheet_metal_annotation,
+ "nx_sheet_metal_context": self._sheet_metal_context,
+ "nx_sheet_metal_feature": self._sheet_metal_feature,
+ "nx_sheet_metal_info": self._sheet_metal_info,
+ "nx_sheet_metal_defaults": self._sheet_metal_defaults,
+ "nx_set_sheet_metal_defaults": self._set_sheet_metal_defaults,
+ "nx_export_flat_pattern": self._export_flat_pattern,
+ "nx_create_explosion": self._create_explosion,
+ "nx_list_explosions": self._list_explosions,
+ "nx_explosion_info": self._explosion_info,
+ "nx_edit_explosion": self._edit_explosion,
+ "nx_show_explosion": self._show_explosion,
+ "nx_delete_explosion": self._delete_explosion,
+ "nx_shell": self._shell,
+ "nx_set_material": self._set_material,
+ "nx_render_view": self._render_view,
+ "nx_mirror_body": self._mirror_body,
+ "nx_create_drawing": self._create_drawing,
+ "nx_add_base_view": self._add_base_view,
+ "nx_add_projection_view": self._add_projection_view,
+ "nx_add_dimension": self._add_dimension,
+ "nx_export_drawing_pdf": self._export_drawing_pdf,
+ "nx_blend": self._blend,
+ "nx_chamfer": self._chamfer,
+ "nx_sweep": self._sweep,
+ "nx_mate_component": self._mate_component,
+ "nx_list_assembly_constraints": self._list_assembly_constraints,
+ "nx_assembly_constraint": self._assembly_constraint,
+ "nx_edit_assembly_constraint": self._edit_assembly_constraint,
+ "nx_material_info": self._material_info,
+ "nx_sketch_angle": self._sketch_angle,
+ "nx_sketch_tangent": self._sketch_tangent,
+ "nx_sketch_symmetry": self._sketch_symmetry,
+ "nx_sketch_trim_extend": self._sketch_trim_extend,
+ "nx_component_array": self._component_array,
+ "nx_draft": self._draft,
+ "nx_transform_bodies": self._transform_bodies,
+ "nx_mass_properties": self._mass_properties,
+ "nx_copy_project": self._copy_project,
+ "nx_loft": self._loft,
+ "nx_sketch_primitive": self._sketch_primitive,
+ "nx_recognize_holes": self._recognize_holes,
+ "nx_native_component_pattern": self._native_component_pattern,
+ "nx_edit_component_pattern": self._edit_component_pattern,
+ "nx_list_component_patterns": self._list_component_patterns,
+ "nx_sketch_dimension": self._sketch_dimension,
+ "nx_sketch_relation": self._sketch_relation,
+ "nx_sketch_conflicts": self._sketch_conflicts,
+ "nx_feature_parameters": self._feature_parameters,
+ "nx_set_feature_parameters": self._set_feature_parameters,
+ "nx_view_info": self._view_info,
+ "nx_find_geometry": self._find_geometry,
+ "nx_highlight_objects": self._highlight_objects,
+ "nx_list_expressions": self._list_expressions,
+ "nx_set_expression": self._set_expression,
+ "nx_bind_parameter": self._bind_parameter,
+ "nx_model_health": self._model_health,
+ "nx_rebuild_model": self._rebuild_model,
+ "nx_edit_sketch": self._edit_sketch,
+ "nx_component_action": self._component_action,
+ "nx_pattern_components": self._pattern_components,
+ "nx_set_camera": self._set_camera,
+ "nx_save_presentation": self._save_presentation,
+ "nx_restore_presentation": self._restore_presentation,
+ "nx_inspection_report": self._inspection_report,
+ "nx_model_summary": self._model_summary,
+ "nx_preview_change": self._preview_change,
+ "nx_finish_preview": self._finish_preview,
+ "nx_display_info": self._display_info,
+ "nx_set_display": self._set_display,
+ "nx_set_visibility": self._set_visibility,
+ "nx_restore_display": self._restore_display,
+ "nx_highlight_collisions": self._highlight_collisions,
+ "nx_clear_highlights": self._clear_highlights,
+ "nx_list_sections": self._list_sections,
+ "nx_section_view": self._section_view,
+ "nx_section_control": self._section_control,
+ "nx_sketch_diagnostics": self._sketch_diagnostics,
+ "nx_check_interference": self._check_interference,
+ "nx_check_clearance": self._check_clearance,
+ "nx_activate_part": self._activate_part,
+ "nx_sketch_info": self._sketch_info,
+ "nx_edit_feature": self._edit_feature,
+ "nx_measure_distance": self._measure_distance,
+ "nx_pattern": self._pattern,
+ "nx_import_geometry": self._import_geometry,
+ "nx_checkpoint": self._checkpoint,
+ "nx_checkpoint_state": self._checkpoint_state,
+ "nx_rollback": self._rollback_checkpoint,
+ "nx_operation_status": lambda operation_id: self.store.get(operation_id),
+ "nx_list_topology": self._list_topology,
+ "nx_set_component_transform": self._set_component_transform,
+ "nx_batch": self._batch,
+ "nx_capabilities": self._capabilities,
+ "nx_save_as": self._save_as,
+ "nx_rename_object": self._rename_object,
+ "nx_delete_feature": self._delete_feature,
+ "nx_measure_angle": self._measure_angle,
+ "nx_sketch_constraint": self._sketch_constraint,
+ }
+ )
+
+ def _part_id(self, part):
+ tag = int(part.Tag)
+ if tag not in self._part_generations:
+ self._part_generations[tag] = uuid.uuid4().hex
+ return "part_" + self.session_id + "_" + self._part_generations[tag]
+
+ def _reference(self, value, kind, part, fallback):
+ ref = super()._reference(value, kind, part, fallback)
+ ref.update(
+ session_id=self.session_id,
+ generation_id=self._part_generations[int(part.Tag)],
+ owner_part_path=part.FullPath,
+ journal_id=str(getattr(value, "JournalIdentifier", "")),
+ display_name=str(getattr(value, "Name", "")),
+ )
+ return ref
+
+ @staticmethod
+ def _name(value, fallback):
+ return str(
+ getattr(value, "Name", "") or getattr(value, "JournalIdentifier", "") or fallback
+ )
+
+ def _units(self):
+ return (
+ "mm"
+ if self._work_part().PartUnits == self.nxopen.BasePart.Units.Millimeters
+ else "inch"
+ )
+
+ def execute(self, method, params):
+ params = dict(params)
+ supplied_id = params.pop("operation_id", None) if method != "nx_operation_status" else None
+ mutable = method not in READ_ONLY
+ op_id = supplied_id or ("op_" + uuid.uuid4().hex)
+ handler = self._handlers.get(method)
+ if handler is None:
+ if not self.enable_experimental:
+ raise NXToolError("NX_TOOL_NOT_FOUND", method)
+ from nx_mcp.experimental import execute_legacy, load_legacy_handlers
+
+ legacy = load_legacy_handlers().get(method)
+ if legacy is None:
+ raise NXToolError("NX_TOOL_NOT_FOUND", method)
+ inspect.signature(legacy).bind(**params)
+
+ def handler(**p):
+ return execute_legacy(method, p, self.workspace, enable_journal=self.enable_journal)
+ else:
+ try:
+ inspect.signature(handler).bind(**params)
+ except TypeError as e:
+ raise NXToolError("NX_INVALID_ARGUMENT", str(e)) from e
+ record = None
+ mark = None
+ part = self._work_part(required=False)
+ part_id = self._part_id(part) if part else None
+ if mutable:
+ try:
+ fingerprint = self.store.fingerprint(method, params)
+ except (ValueError, TypeError) as error:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Arguments must be finite JSON values"
+ ) from error
+ existing = self.store.get(op_id)
+ if "fingerprint" in existing:
+ if existing["fingerprint"] != fingerprint:
+ raise NXToolError(
+ "NX_IDEMPOTENCY_CONFLICT",
+ "operation_id was already used with different arguments",
+ details={"operation_id": op_id},
+ )
+ if existing["state"] == "committed":
+ result = dict(existing["result"])
+ result["replayed"] = True
+ if existing.get("reverted_by"):
+ result["warnings"] = result.get("warnings", []) + [
+ "This operation was subsequently reverted by "
+ + existing["reverted_by"]
+ + "; replay does not recreate geometry."
+ ]
+ if existing["session_id"] != self.session_id:
+ result["warnings"] = result.get("warnings", []) + [
+ "Receipt is from an earlier NX session; reacquire object references."
+ ]
+ return result
+ raise NXToolError(
+ "NX_OPERATION_" + existing["state"].upper(),
+ "Request was already recorded; inspect nx_operation_status before proceeding",
+ details=existing,
+ )
+ record = {
+ "operation_id": op_id,
+ "method": method,
+ "fingerprint": fingerprint,
+ "session_id": self.session_id,
+ "part_id": part_id,
+ "state": "running",
+ "mutation_outcome": "unknown",
+ "started_at": timestamp(),
+ }
+ self.store.put(record)
+ before = {}
+ previous = self._current_operation
+ self._current_operation = op_id
+ try:
+ if method == "nx_restore_display":
+ self._validate_display_restore(params["restore_id"])
+ if (
+ mutable
+ and method not in NON_MODEL
+ and not (method == "nx_import_geometry" and params.get("target") == "new_part")
+ and part
+ ):
+ before = self._snapshot(part)
+ if (
+ mutable
+ and method not in NON_MODEL
+ and not (method == "nx_import_geometry" and params.get("target") == "new_part")
+ ):
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Visible, "NX MCP: " + method
+ )
+ self._active_mark = mark
+ result = handler(**params)
+ if (
+ mark is not None
+ and part is not None
+ and method
+ not in {"nx_refresh_annotations", "nx_sheet_metal_annotation", "nx_bend_table"}
+ ):
+ refreshed = self._refresh_annotations()
+ if refreshed["updated_count"]:
+ result["refreshed_annotations"] = refreshed["updated"]
+ if result.get("status") == "error":
+ raise NXToolError(
+ result.get("code", result.get("error_code", "NX_OPERATION_FAILED")),
+ result.get("message", "Operation failed"),
+ )
+ receipt_metadata = (
+ {key: result.get(key) for key in ("operation_id", "session_id", "mutation_outcome")}
+ if method == "nx_operation_status"
+ else None
+ )
+ result = {
+ "status": "success",
+ **result,
+ "operation_id": op_id,
+ "session_id": self.session_id,
+ "mutation_outcome": "committed" if mutable else "not_applicable",
+ "warnings": result.get("warnings", []),
+ }
+ if receipt_metadata is not None:
+ result.update(receipt_metadata)
+ result.update(query_operation_id=op_id, query_session_id=self.session_id)
+ if part:
+ result.setdefault(
+ "units", self._units() if self._work_part(required=False) else None
+ )
+ if mark is not None:
+ after = self._snapshot(part) if part else {}
+ result["changes"] = {
+ "created": [v for k, v in after.items() if k not in before],
+ "deleted": [v for k, v in before.items() if k not in after],
+ "modified": result.get("modified"),
+ "modified_tracking": "explicit only; null means not fully tracked",
+ }
+ self._invalidate_deleted(
+ before,
+ after,
+ invalidate_topology=method
+ not in {
+ "nx_create_explosion",
+ "nx_edit_explosion",
+ "nx_show_explosion",
+ "nx_delete_explosion",
+ "nx_set_camera",
+ "nx_restore_presentation",
+ "nx_set_display",
+ "nx_set_visibility",
+ "nx_set_datum_visibility",
+ "nx_create_reference_set",
+ "nx_set_component_reference_set",
+ "nx_restore_display",
+ "nx_section_view",
+ "nx_section_control",
+ "nx_set_view",
+ "nx_fit_view",
+ },
+ )
+ self._history.append(
+ {"mark": mark, "part_id": part_id, "operation_id": op_id, "method": method}
+ )
+ except Exception as error:
+ if mutable:
+ self._review_epoch = getattr(self, "_review_epoch", 0) + 1
+ outcome = "not_started" if mark is None else "partial"
+ if mark is not None:
+ try:
+ self.session.UndoToMark(mark, None)
+ self.objects.invalidate_part(part_id)
+ self.session.DeleteUndoMark(mark, None)
+ outcome = "rolled_back"
+ except Exception as rollback_error:
+ error = NXToolError(
+ "NX_ROLLBACK_FAILED",
+ str(rollback_error),
+ details={"operation_error": str(error)},
+ )
+ elif mutable and (method in NON_MODEL or method == "nx_import_geometry"):
+ outcome = "unknown"
+ err = (
+ error
+ if isinstance(error, NXToolError)
+ else NXToolError(
+ "NX_API_ERROR", str(error), nx_code=getattr(error, "ErrorCode", None)
+ )
+ )
+ if mark is None:
+ # Inspections may create temporary native geometry. Preserve a
+ # handler's explicit cleanup outcome when no outer rollback ran.
+ outcome = err.details.get("mutation_outcome", outcome)
+ err.details.update(operation_id=op_id, mutation_outcome=outcome)
+ if record:
+ record.update(
+ state="failed",
+ mutation_outcome=outcome,
+ error=err.as_dict(),
+ finished_at=timestamp(),
+ )
+ self.store.put(record)
+ raise err from error
+ finally:
+ self._current_operation = previous
+ if mutable and method != "nx_preview_change":
+ self._review_epoch = getattr(self, "_review_epoch", 0) + 1
+ if record:
+ record.update(
+ state="committed",
+ mutation_outcome="committed",
+ result=result,
+ finished_at=timestamp(),
+ )
+ # Receipt persistence is outside the rollback block: a persistence failure must
+ # never undo an already reported commit. A running receipt becomes unknown on restart.
+ self.store.put(record)
+ return result
+
+ def _resolve(self, ref, kinds=None, part=None):
+ part = part or self._work_part()
+ if ref.startswith("obj_"):
+ try:
+ value = self.objects.resolve(ref, part_id=self._part_id(part))
+ except NXToolError as error:
+ if error.code == "NX_OBJECT_NOT_FOUND":
+ raise NXToolError(
+ "NX_OBJECT_STALE", "Reference is not live in this NX session"
+ ) from error
+ raise
+ entry = self.objects._objects[ref]
+ if kinds and entry.reference.kind not in kinds:
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Unsupported object kind")
+ return value
+ pools = {
+ "feature": list(part.Features),
+ "body": list(part.Bodies),
+ "sketch": list(part.Sketches),
+ "component": [c for c, _ in self._walk_components(part)],
+ "section": list(part.DynamicSections),
+ }
+ candidates = []
+ for kind, values in pools.items():
+ if kinds and kind not in kinds:
+ continue
+ for v in values:
+ if ref.casefold() in {
+ self._name(v, "").casefold(),
+ str(getattr(v, "JournalIdentifier", "")).casefold(),
+ } and all(int(c.Tag) != int(v.Tag) for c in candidates):
+ candidates.append(v)
+ if len(candidates) != 1:
+ raise NXToolError(
+ "NX_AMBIGUOUS_REFERENCE" if candidates else "NX_NOT_FOUND",
+ "Use an opaque ID; name did not resolve uniquely: " + ref,
+ )
+ return candidates[0]
+
+ def _resolve_body(self, body, part):
+ return self._resolve(body, {"body"}, part)
+
+ def _find_sketch(self, name):
+ return self._resolve(name, {"sketch"})
+
+ def _open_part(self, path, work=True, display=True, load_components=False):
+ source = self.workspace.ensure_inside(path)
+ loaded = next(
+ (
+ p
+ for p in self.session.Parts
+ if str(Path(p.FullPath).resolve()).casefold() == str(source).casefold()
+ ),
+ None,
+ )
+ already_loaded = loaded is not None
+ if loaded is None:
+ if not source.is_file():
+ raise NXToolError("NX_FILE_NOT_FOUND", str(source))
+ loaded, status = self.session.Parts.OpenBase(str(source))
+ if status:
+ status.Dispose()
+ result = self._activate_part(
+ self._reference(loaded, "part", loaded, "Part")["id"], work, display
+ )
+ if load_components:
+ from nx_mcp.assembly_loading import load_components as load
+
+ result["component_loading"] = load(self, loaded)
+ result.update(
+ already_loaded=already_loaded,
+ path=str(source),
+ message="Reused loaded part" if already_loaded else "Opened part",
+ )
+ return result
+
+ def _activate_part(self, part, work=True, display=True):
+ if part.startswith("obj_"):
+ target = self.objects.resolve(part, expected_kind="part")
+ else:
+ matches = [
+ p
+ for p in self.session.Parts
+ if part.casefold() in {p.FullPath.casefold(), p.Name.casefold()}
+ ]
+ if len(matches) != 1:
+ raise NXToolError("NX_NOT_FOUND", "Loaded part must resolve uniquely")
+ target = matches[0]
+ if display:
+ _, status = self.session.Parts.SetDisplay(target, False, False)
+ if status:
+ status.Dispose()
+ if work:
+ self.session.Parts.SetWork(target)
+ return {
+ "part": self._reference(target, "part", target, "Part"),
+ "work": self.session.Parts.Work == target,
+ "display": self.session.Parts.Display == target,
+ "message": "Activated loaded part" if work or display else "Resolved loaded part",
+ }
+
+ def _save_part(self):
+ from nx_mcp.save_audit import snapshot, verify
+
+ part = self._work_part()
+ try:
+ before = snapshot(self.session)
+ except Exception as error:
+ raise NXToolError(
+ "NX_SAVE_PREFLIGHT_FAILED", str(error), details={"mutation_outcome": "not_started"}
+ ) from error
+ native_errors = []
+ save_error = None
+ save_called = False
+ native_returned = False
+ try:
+ with self._drawing_save_context(part):
+ save_called = True
+ status = part.Save(
+ self.nxopen.BasePart.SaveComponents.FalseValue,
+ self.nxopen.BasePart.CloseAfterSave.FalseValue,
+ )
+ native_returned = True
+ try:
+ if status:
+ for i in range(status.NumberUnsavedParts):
+ native_errors.append(
+ {"path": status.GetPart(i).FullPath, "nx_code": status.GetStatus(i)}
+ )
+ for i in range(status.NumberUnsavedObjects):
+ native_errors.append(
+ {"object_index": i, "nx_code": status.GetObjectStatus(i)}
+ )
+ finally:
+ if status:
+ status.Dispose()
+ except Exception as error:
+ if not save_called:
+ if isinstance(error, NXToolError):
+ error.details["mutation_outcome"] = "not_started"
+ raise
+ save_error = error
+ if not native_returned:
+ native_errors.append({"message": str(error)})
+ try:
+ audit = verify(before, snapshot(self.session), int(part.Tag), native_errors)
+ except NXToolError as error:
+ if save_error:
+ error.details["cause_code"] = getattr(save_error, "code", None)
+ raise
+ except Exception as error:
+ raise NXToolError(
+ "NX_SAVE_VERIFICATION_FAILED",
+ str(error),
+ details={"mutation_outcome": "partial", "path": part.FullPath},
+ ) from error
+ if save_error:
+ if isinstance(save_error, NXToolError):
+ save_error.details.update(audit, mutation_outcome="partial")
+ raise save_error
+ raise NXToolError(
+ "NX_SAVE_FAILED", str(save_error), details={**audit, "mutation_outcome": "partial"}
+ ) from save_error
+ state = self._checkpoint_state()
+ return {
+ "message": "Saved and verified work-part file; native NX save may invalidate undo marks",
+ "path": part.FullPath,
+ "recovery": state,
+ **audit,
+ "warnings": [
+ "NX v2606 save invalidates native undo/checkpoints. Establish a new checkpoint before further edits.",
+ "Verification covers loaded part files and flags, not external linked files or concurrent external writers.",
+ ],
+ }
+
+ def _save_as(self, path):
+ part = self._work_part()
+ dest = self.workspace.ensure_inside(path)
+ if dest.exists():
+ raise NXToolError("NX_FILE_EXISTS", "Save-as does not overwrite existing files")
+ dest.parent.mkdir(parents=True, exist_ok=True)
+ with self._drawing_save_context(part):
+ status = part.SaveAs(str(dest))
+ if status and hasattr(status, "Dispose"):
+ status.Dispose()
+ return {
+ "path": str(dest),
+ "part": self._reference(part, "part", part, "Part"),
+ "message": "Saved as",
+ }
+
+ def _close_part(self, save=True, part=None):
+ from nx_mcp.assembly_loading import dependent_assemblies
+
+ target = self.objects.resolve(part, expected_kind="part") if part else self._work_part()
+ parents = dependent_assemblies(self, target)
+ if parents:
+ raise NXToolError(
+ "NX_PART_IN_USE",
+ "Close the loaded parent assemblies before closing this prototype",
+ details={"parent_assemblies": parents, "mutation_outcome": "not_started"},
+ )
+ # NX may unload unused prototypes even with CloseWholeTree.FalseValue.
+ # Capture references before Close; querying an unloaded NX proxy can fail.
+ loaded = {int(p.Tag): self._reference(p, "part", p, "Part") for p in self.session.Parts}
+ if save:
+ with self._drawing_save_context(target):
+ status = target.Save(
+ self.nxopen.BasePart.SaveComponents.FalseValue,
+ self.nxopen.BasePart.CloseAfterSave.FalseValue,
+ )
+ if status and hasattr(status, "Dispose"):
+ status.Dispose()
+ target.Close(
+ self.nxopen.BasePart.CloseWholeTree.FalseValue,
+ self.nxopen.BasePart.CloseModified.CloseModified,
+ None,
+ )
+ remaining = {int(p.Tag) for p in self.session.Parts}
+ closed = [ref for tag, ref in loaded.items() if tag not in remaining]
+ closed_ids = {ref["part_id"] for ref in closed}
+ for tag, ref in loaded.items():
+ if tag not in remaining:
+ self.objects.invalidate_part(ref["part_id"])
+ self._part_generations.pop(tag, None)
+ self._history = [h for h in self._history if h["part_id"] not in closed_ids]
+ self._checkpoints = {
+ k: v for k, v in self._checkpoints.items() if v["part_id"] not in closed_ids
+ }
+ return {
+ "message": "Closed part; NX may also unload unused assembly prototypes",
+ "closed_parts": closed,
+ "closed_count": len(closed),
+ "remaining_count": len(remaining),
+ "warnings": ["Re-list open parts before closing another assembly dependency."],
+ }
+
+ def _list_open_parts(
+ self,
+ compact=False,
+ path_prefix=None,
+ modified=None,
+ active_only=False,
+ offset=0,
+ limit=None,
+ ):
+ page([], offset, limit)
+ rows = []
+ for p in self.session.Parts:
+ active = p == self.session.Parts.Work or p == self.session.Parts.Display
+ if path_prefix is not None and not p.FullPath.casefold().replace("\\", "/").startswith(
+ path_prefix.casefold().replace("\\", "/")
+ ):
+ continue
+ if modified is not None and bool(p.IsModified) != modified:
+ continue
+ if active_only and not active:
+ continue
+ ref = self._reference(p, "part", p, "Part")
+ rows.append(
+ {
+ "part": compact_reference(ref) if compact else ref,
+ "name": p.Name,
+ "path": p.FullPath,
+ "work": p == self.session.Parts.Work,
+ "display": p == self.session.Parts.Display,
+ "modified": bool(p.IsModified),
+ }
+ )
+ selected, metadata = page(rows, offset, limit)
+ return {"parts": selected, **metadata}
+
+ def _sketch_frame(self, sketch):
+ m = sketch.Orientation.Element
+ return {
+ "origin": xyz(sketch.Origin),
+ "x_axis": [m.Xx, m.Xy, m.Xz],
+ "y_axis": [m.Yx, m.Yy, m.Yz],
+ "normal": [m.Zx, m.Zy, m.Zz],
+ "coordinate_frame": "part",
+ }
+
+ def _create_sketch(self, plane="XY", name=None, origin=None, x_axis=None, y_axis=None):
+ bases = {
+ "XY": ([1, 0, 0], [0, 1, 0]),
+ "XZ": ([1, 0, 0], [0, 0, 1]),
+ "YZ": ([0, 1, 0], [0, 0, 1]),
+ }
+ if plane not in bases:
+ raise NXToolError("NX_INVALID_ARGUMENT", "plane must be XY, XZ or YZ")
+ if (x_axis is None) != (y_axis is None):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Supply both basis axes")
+ x = vector(x_axis if x_axis is not None else bases[plane][0])
+ y = vector(y_axis if y_axis is not None else bases[plane][1])
+ o = vector(origin or [0, 0, 0])
+ if abs(dot(x, x) - 1) > 1e-8 or abs(dot(y, y) - 1) > 1e-8 or abs(dot(x, y)) > 1e-8:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Sketch basis must be orthonormal")
+ n = cross(x, y)
+ part = self._work_part()
+ matrix = self._nx_matrix(transpose([x, y, n]))
+ csys = part.CoordinateSystems.CreateCoordinateSystem(self.nxopen.Point3d(*o), matrix, False)
+ builder = part.Sketches.CreateSketchInPlaceBuilder2(self.nxopen.Sketch.Null)
+ try:
+ builder.Csystem = csys
+ sketch = builder.Commit()
+ finally:
+ builder.Destroy()
+ if name:
+ sketch.SetName(name)
+ actual = self._sketch_frame(sketch)
+ if any(
+ abs(a - b) > 1e-7
+ for k, v in [("origin", o), ("x_axis", x), ("y_axis", y), ("normal", n)]
+ for a, b in zip(actual[k], v, strict=False)
+ ):
+ raise NXToolError(
+ "NX_FRAME_MISMATCH",
+ "NX sketch frame differs from requested frame",
+ details={"actual": actual},
+ )
+ sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue)
+ return {
+ "object": self._reference(sketch, "sketch", part, "Sketch"),
+ "frame": actual,
+ "message": "Created sketch",
+ }
+
+ def _point_on_sketch(self, sketch, p):
+ f = self._sketch_frame(sketch)
+ return self.nxopen.Point3d(
+ *[
+ f["origin"][i] + float(p["x"]) * f["x_axis"][i] + float(p["y"]) * f["y_axis"][i]
+ for i in range(3)
+ ]
+ )
+
+ def _create_sketch_line(self, sketch, part, start, end):
+ if self.session.ActiveSketch != sketch:
+ raise NXToolError(
+ "NX_SKETCH_NOT_ACTIVE", "Activate the owning sketch before adding geometry"
+ )
+ curve = part.Curves.CreateLine(
+ self._point_on_sketch(sketch, start), self._point_on_sketch(sketch, end)
+ )
+ sketch.AddGeometry(curve, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints)
+ return self._reference(curve, "curve", part, "Line")
+
+ def _sketch_arc_legacy(self, cx, cy, radius, start_angle, end_angle, sketch_id=None):
+ sketch = self._resolve(sketch_id, {"sketch"}) if sketch_id else self.session.ActiveSketch
+ if sketch is None or self.session.ActiveSketch != sketch:
+ raise NXToolError("NX_SKETCH_NOT_ACTIVE", "An explicit active sketch is required")
+ if not math.isfinite(radius) or radius <= 0 or not 0 < end_angle - start_angle <= 360:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Require positive radius and 0 < end-start <= 360 degrees"
+ )
+ part = self._work_part()
+ f = self._sketch_frame(sketch)
+ arc = part.Curves.CreateArc(
+ self._point_on_sketch(sketch, {"x": cx, "y": cy}),
+ self.nxopen.Vector3d(*f["x_axis"]),
+ self.nxopen.Vector3d(*f["y_axis"]),
+ float(radius),
+ math.radians(start_angle),
+ math.radians(end_angle),
+ )
+ sketch.AddGeometry(arc, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints)
+ return {
+ "object": self._reference(arc, "curve", part, "Arc"),
+ "frame": f,
+ "message": "Created sketch arc",
+ }
+
+ def _sketch_info(self, sketch_id):
+ sketch = self._resolve(sketch_id, {"sketch"})
+ part = self._work_part()
+ geometry = []
+ for c in sketch.GetAllGeometry():
+ row = {"object": self._reference(c, "curve", part, "Curve"), "type": type(c).__name__}
+ if hasattr(c, "StartPoint"):
+ row.update(start=xyz(c.StartPoint), end=xyz(c.EndPoint))
+ if hasattr(c, "CenterPoint"):
+ row.update(center=xyz(c.CenterPoint), radius=c.Radius)
+ geometry.append(row)
+ return {
+ "object": self._reference(sketch, "sketch", part, "Sketch"),
+ "frame": self._sketch_frame(sketch),
+ "curves": geometry,
+ "curve_count": len(geometry),
+ }
+
+ def _simple_extrude(self, sketch_id, distance, reverse=False):
+ if not math.isfinite(distance):
+ raise NXToolError("NX_INVALID_ARGUMENT", "distance must be finite")
+ result = NXOpenExecutor._extrude(self, sketch_id, distance, reverse)
+ feature = self.objects.resolve(result["feature"]["id"])
+ bodies = list(feature.GetBodies())
+ result.update(
+ bodies=[self._reference(b, "body", self._work_part(), "Body") for b in bodies],
+ body_count=len(bodies),
+ )
+ result["created"] = [result["feature"]] + result["bodies"]
+ result["modified"] = []
+ result["deleted"] = []
+ return result
+
+ def _get_feature_info(self, name):
+ f = self._resolve(name, {"feature"})
+ part = self._work_part()
+ return {
+ "feature": self._reference(f, "feature", part, "Feature"),
+ "name": self._name(f, "Feature"),
+ "type": f.FeatureType,
+ "expressions": [
+ {"name": e.Name, "formula": e.RightHandSide, "value": e.Value}
+ for e in f.GetExpressions()
+ ],
+ "parents": [self._reference(v, "feature", part, "Feature") for v in f.GetParents()],
+ "children": [self._reference(v, "feature", part, "Feature") for v in f.GetChildren()],
+ }
+
+ def _edit_feature(self, name, params):
+ f = self._resolve(name, {"feature"})
+ part = self._work_part()
+ kind = f.FeatureType.upper().replace(" ", "_")
+ allowed = (
+ {"distance"}
+ if kind == "EXTRUDE"
+ else {"count", "spacing"}
+ if kind in {"PATTERN_FEATURE", "PATTERN"}
+ else set()
+ )
+ if not params or set(params) - allowed:
+ raise NXToolError(
+ "NX_UNSUPPORTED_EDIT",
+ "Supported edits: EXTRUDE distance; PATTERN_FEATURE count/spacing",
+ )
+ if any(
+ not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0
+ for v in params.values()
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Edit values must be finite and positive")
+ if "count" in params and (int(params["count"]) != params["count"] or params["count"] < 2):
+ raise NXToolError("NX_INVALID_ARGUMENT", "count must be integer >= 2")
+ if kind == "EXTRUDE":
+ builder = part.Features.CreateExtrudeBuilder(f)
+ try:
+ builder.Limits.EndExtend.Value.RightHandSide = str(params["distance"])
+ builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ else:
+ builder = part.Features.CreatePatternFeatureBuilder(f)
+ try:
+ spacing = builder.PatternService.RectangularDefinition.XSpacing
+ if "count" in params:
+ spacing.NCopies.RightHandSide = str(int(params["count"]))
+ if "spacing" in params:
+ spacing.PitchDistance.RightHandSide = str(params["spacing"])
+ builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ errors = self.session.UpdateManager.DoUpdate(self._active_mark)
+ if errors:
+ raise NXToolError("NX_UPDATE_FAILED", str(errors) + " update errors")
+ result = self._get_feature_info(self._reference(f, "feature", part, "Feature")["id"])
+ result.update(modified=[result["feature"]], created=[], deleted=[])
+ return result
+
+ def _checkpoint(self, label="checkpoint"):
+ part = self._work_part()
+ key = "cp_" + uuid.uuid4().hex
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Visible, "NX MCP checkpoint: " + label
+ )
+ self._checkpoints[key] = {
+ "mark": mark,
+ "part_id": self._part_id(part),
+ "index": len(self._history),
+ "label": label,
+ }
+ return {
+ "checkpoint_id": key,
+ "message": "In-session checkpoint created; disk saves are not rolled back",
+ }
+
+ def _checkpoint_state(self):
+ return {
+ "checkpoints": [
+ {k: v for k, v in c.items() if k != "mark"}
+ | {
+ "checkpoint_id": key,
+ "available": self.session.DoesUndoMarkExist(c["mark"], None),
+ }
+ for key, c in self._checkpoints.items()
+ ],
+ "undo_depth": sum(
+ self.session.DoesUndoMarkExist(h["mark"], None) for h in self._history
+ ),
+ "save_semantics": "NX v2606 save removes native marks. Expired checkpoints cannot be rolled back; create a new checkpoint after save.",
+ "retention": "Current NX process only; cross-part rollback is rejected if it would undo another part.",
+ }
+
+ def _rollback_checkpoint(self, checkpoint_id):
+ if checkpoint_id not in self._checkpoints:
+ raise NXToolError("NX_CHECKPOINT_STALE", "Checkpoint is not in this session")
+ cp = self._checkpoints[checkpoint_id]
+ if not self.session.DoesUndoMarkExist(cp["mark"], None):
+ raise NXToolError(
+ "NX_CHECKPOINT_STALE",
+ "Native NX checkpoint expired, commonly at save or part activation",
+ )
+ pid = self._part_id(self._work_part())
+ if cp["part_id"] != pid or any(h["part_id"] != pid for h in self._history[cp["index"] :]):
+ raise NXToolError(
+ "NX_CROSS_PART_ROLLBACK",
+ "Rollback would affect a different part; activate the checkpoint part or reconcile intervening changes",
+ )
+ self.session.UndoToMark(cp["mark"], None)
+ self.objects.invalidate_part(pid)
+ self._record_reverted(self._history[cp["index"] :], checkpoint_id)
+ self._history = self._history[: cp["index"]]
+ self._checkpoints = {k: v for k, v in self._checkpoints.items() if v["index"] < cp["index"]}
+ return {
+ "message": "Rolled back checkpoint; reacquire object references",
+ "checkpoint_id": checkpoint_id,
+ }
+
+ def _undo(self):
+ if not self._history or not self.session.DoesUndoMarkExist(self._history[-1]["mark"], None):
+ raise NXToolError(
+ "NX_UNDO_UNAVAILABLE",
+ "No native undo mark remains; save and part lifecycle changes can expire history",
+ )
+ last = self._history[-1]
+ pid = self._part_id(self._work_part())
+ if last["part_id"] != pid:
+ raise NXToolError(
+ "NX_CROSS_PART_ROLLBACK", "Activate the part owning the latest mutation"
+ )
+ self.session.UndoToMark(last["mark"], None)
+ self._record_reverted([last], self._current_operation)
+ self._history.pop()
+ self.objects.invalidate_part(pid)
+ self._checkpoints = {
+ k: v for k, v in self._checkpoints.items() if v["index"] <= len(self._history)
+ }
+ return {
+ "message": "Undone; reacquire object references",
+ "undone_operation_id": last["operation_id"],
+ }
+
+ def _nx_matrix(self, m):
+ matrix = self.nxopen.Matrix3x3()
+ for i, prefix in enumerate("XYZ"):
+ for j, suffix in enumerate("xyz"):
+ setattr(matrix, prefix + suffix, m[j][i])
+ return matrix
+
+ def _walk_components(self, part):
+ root = part.ComponentAssembly.RootComponent
+
+ def walk(parent, path):
+ for c in parent.GetChildren():
+ p = path + [self._name(c, "Component")]
+ yield c, p
+ yield from walk(c, p)
+
+ return list(walk(root, [])) if root else []
+
+ def _list_components(
+ self,
+ compact=False,
+ include_transforms=True,
+ name_contains=None,
+ suppressed=None,
+ offset=0,
+ limit=None,
+ ):
+ from nx_mcp.assembly_loading import component_info
+
+ page([], offset, limit)
+ part = self._work_part()
+ candidates = [
+ (c, path)
+ for c, path in self._walk_components(part)
+ if (name_contains is None or name_contains.casefold() in c.Name.casefold())
+ and (suppressed is None or bool(c.IsSuppressed) == suppressed)
+ ]
+ selected, metadata = page(candidates, offset, limit)
+ result = []
+ for c, path in selected:
+ ref = self._reference(c, "component", part, "Component")
+ ref["occurrence_path"] = path
+ row = {
+ "object": compact_reference(ref) if compact else ref,
+ "name": c.Name,
+ **component_info(c),
+ "depth": len(path) - 1,
+ "suppressed": bool(c.IsSuppressed),
+ "reference_set": c.ReferenceSet,
+ }
+ if include_transforms:
+ p, m = c.GetPosition()
+ row.update(translation=xyz(p), rotation_matrix=rows(m), coordinate_frame="assembly")
+ if not compact:
+ row["rotation"] = [m.Xx, m.Xy, m.Xz, m.Yx, m.Yy, m.Yz, m.Zx, m.Zy, m.Zz]
+ result.append(row)
+ return {
+ "components": result,
+ **metadata,
+ "matrix_convention": "rotation_matrix is row-major; p_assembly = R p_local + translation. Legacy rotation lists axis vectors.",
+ }
+
+ def _list_topology(self, body, face=None, include_adjacency=False, compact=False):
+ b = self._resolve(body, {"body"})
+ part = self._work_part()
+ faces = list(b.GetFaces())
+ if face is not None:
+ selected = self._resolve(face, {"face"})
+ if selected not in faces:
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "face must belong to body")
+ faces = [selected]
+ edges = (
+ list({int(e.Tag): e for f in faces for e in f.GetEdges()}.values())
+ if face
+ else list(b.GetEdges())
+ )
+
+ def ref(value, kind):
+ r = self._reference(value, kind, part, kind.title())
+ return compact_reference(r) if compact else r
+
+ result = {
+ "body": ref(b, "body"),
+ "faces": [ref(f, "face") for f in faces],
+ "edges": [ref(e, "edge") for e in edges],
+ "solid": b.IsSolidBody,
+ "face_count": len(faces),
+ "edge_count": len(edges),
+ }
+ if include_adjacency:
+ result["face_edges"] = [
+ {
+ "face": ref(f, "face")["id"],
+ "edges": [ref(e, "edge")["id"] for e in f.GetEdges()],
+ }
+ for f in faces
+ ]
+ result["edge_faces"] = [
+ {
+ "edge": ref(e, "edge")["id"],
+ "faces": [ref(f, "face")["id"] for f in e.GetFaces()],
+ }
+ for e in edges
+ ]
+ return result
+
+ def _rename_object(self, object_id, name):
+ if not name or len(name) > 132:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Name must contain 1–132 characters")
+ value = self._resolve(object_id)
+ value.SetName(name)
+ ref = self.objects._objects[object_id].reference
+ self.objects.invalidate_part(ref.part_id)
+ return {
+ "object": self._reference(value, ref.kind, self._work_part(), name),
+ "message": "Renamed; references refreshed",
+ }
+
+ def _geometry(self, ref=None, scope="auto"):
+ part = self._work_part()
+ if scope not in {"auto", "part", "assembly"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "scope must be auto, part or assembly")
+ values = []
+ if ref:
+ obj = self._resolve(ref, {"body", "face", "edge", "component", "feature"})
+ if hasattr(obj, "FindOccurrence"):
+ selected = [obj] + [
+ c for c, _ in self._walk_components(part) if self._descendant(c, obj)
+ ]
+ for c in selected:
+ values.extend(self._occurrence_bodies(c))
+ elif hasattr(obj, "GetBodies") and not hasattr(obj, "IsSolidBody"):
+ values = list(obj.GetBodies())
+ else:
+ values = [obj]
+ else:
+ values = list(part.Bodies)
+ if scope == "assembly" or (scope == "auto" and part.ComponentAssembly.RootComponent):
+ for c, _ in self._walk_components(part):
+ values.extend(self._occurrence_bodies(c))
+ if not values:
+ raise NXToolError(
+ "NX_NO_TARGET_BODY",
+ "No included geometry; inspect suppression, loading and reference sets",
+ )
+ return values
+
+ @staticmethod
+ def _descendant(c, parent):
+ while c.Parent:
+ c = c.Parent
+ if c == parent:
+ return True
+ return False
+
+ def _occurrence_bodies(self, c):
+ p = c
+ while p:
+ if p.IsSuppressed:
+ return []
+ p = p.Parent
+ bodies = []
+ for b in c.Prototype.Bodies:
+ occurrence = c.FindOccurrence(b)
+ if occurrence is not None:
+ bodies.append(occurrence)
+ return bodies
+
+ def _get_bounding_box(self, body=None, scope="auto", precision="conservative"):
+ import NXOpen.UF
+
+ part = self._work_part()
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ objects = self._geometry(body, scope)
+ if precision not in {"conservative", "exact"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "precision must be conservative or exact")
+ if precision == "exact":
+ wcs = rows(part.WCS.CoordinateSystem.Orientation.Element)
+ if any(abs(wcs[i][j] - IDENTITY[i][j]) > 1e-8 for i in range(3) for j in range(3)):
+ raise NXToolError(
+ "NX_UNSUPPORTED_FRAME",
+ "Exact absolute bounds currently require axis-aligned WCS",
+ )
+ result = []
+ for item in objects:
+ if precision == "exact":
+ low, directions, lengths = uf.ModlGeneral.AskBoundingBoxExact(item.Tag, 0)
+ if len(directions) == 9:
+ directions = [directions[i : i + 3] for i in range(0, 9, 3)]
+ corners = [
+ [
+ low[i]
+ + sum(directions[j][i] * lengths[j] * ((mask >> j) & 1) for j in range(3))
+ for i in range(3)
+ ]
+ for mask in range(8)
+ ]
+ box = [min(p[i] for p in corners) for i in range(3)] + [
+ max(p[i] for p in corners) for i in range(3)
+ ]
+ else:
+ box = list(uf.ModlGeneral.AskBoundingBox(item.Tag))
+ result.append(
+ {
+ "body": self._reference(item, "body", part, "Body"),
+ "box": box,
+ "solid": bool(item.IsSolidBody),
+ }
+ )
+ low = [min(row["box"][i] for row in result) for i in range(3)]
+ high = [max(row["box"][i + 3] for row in result) for i in range(3)]
+ return {
+ "min": low,
+ "max": high,
+ "dimensions": [b - a for a, b in zip(low, high, strict=False)],
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ "bounds_type": precision,
+ "bodies": result,
+ "body_count": len(result),
+ "scope": scope,
+ "message": "Native UF "
+ + precision
+ + " bounds of included body occurrences; suppression and reference sets honored",
+ }
+
+ def _measure_distance(self, obj1, obj2):
+ a = self._geometry(obj1)
+ b = self._geometry(obj2)
+ best = None
+ for x in a:
+ for y in b:
+ dist, p1, p2, accuracy = self.session.Measurement.GetMinimumDistance(x, y)
+ if best is None or dist < best["distance"]:
+ best = {
+ "distance": dist,
+ "closest_points": [xyz(p1), xyz(p2)],
+ "accuracy": None,
+ "accuracy_note": "No validated numerical error bound is exposed by this NX binding",
+ "resolved_tags": [int(x.Tag), int(y.Tag)],
+ }
+ return {
+ **best,
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ "references": [obj1, obj2],
+ "method": "NX Measurement.GetMinimumDistance",
+ "pair_count": len(a) * len(b),
+ "warnings": ["Zero distance alone does not distinguish contact from penetration."],
+ }
+
+ def _pattern(self, features, pattern_type="linear", direction="X", spacing=10, count=2):
+ import NXOpen.GeometricUtilities
+
+ if pattern_type != "linear":
+ raise NXToolError(
+ "NX_UNSUPPORTED_ARGUMENT", "Only native linear feature patterns are implemented"
+ )
+ if (
+ type(count) is not int
+ or count < 2
+ or count > 1000
+ or not math.isfinite(spacing)
+ or spacing <= 0
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Require 2–1000 total instances and positive finite pitch"
+ )
+ vectors = {
+ "X": [1, 0, 0],
+ "Y": [0, 1, 0],
+ "Z": [0, 0, 1],
+ "-X": [-1, 0, 0],
+ "-Y": [0, -1, 0],
+ "-Z": [0, 0, -1],
+ }
+ if direction not in vectors:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Invalid principal direction")
+ seeds = [self._resolve(f, {"feature"}) for f in features]
+ if not seeds:
+ raise NXToolError("NX_INVALID_ARGUMENT", "At least one feature is required")
+ part = self._work_part()
+ builder = part.Features.CreatePatternFeatureBuilder(self.nxopen.Features.Feature.Null)
+ try:
+ builder.FeatureList.Add(seeds)
+ builder.PatternMethod = (
+ self.nxopen.Features.PatternFeatureBuilder.PatternMethodOptions.Simple
+ )
+ builder.PatternService.PatternType = (
+ NXOpen.GeometricUtilities.PatternDefinition.PatternEnum.Linear
+ )
+ definition = builder.PatternService.RectangularDefinition
+ definition.XDirection = part.Directions.CreateDirection(
+ self.nxopen.Point3d(0.0, 0.0, 0.0),
+ self.nxopen.Vector3d(*[float(v) for v in vectors[direction]]),
+ self.nxopen.SmartObject.UpdateOption.WithinModeling,
+ )
+ definition.XSpacing.NCopies.RightHandSide = str(count)
+ definition.XSpacing.PitchDistance.RightHandSide = str(spacing)
+ definition.YSpacing.NCopies.RightHandSide = "1"
+ feature = builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ return {
+ "feature": self._reference(feature, "feature", part, "Pattern"),
+ "feature_type": feature.FeatureType,
+ "count": count,
+ "count_includes_seed": True,
+ "spacing": spacing,
+ "direction": direction,
+ "bodies": [self._reference(b, "body", part, "Body") for b in feature.GetBodies()],
+ }
+
+ def _import_geometry(self, path, flatten=False, target="work_part", output_path=None):
+ # A new-part import owns every part it creates and restores the prior selection on failure.
+ before = {int(p.Tag) for p in self.session.Parts}
+ work, display = self.session.Parts.Work, self.session.Parts.Display
+ try:
+ return self._import_geometry_inner(path, flatten, target, output_path)
+ except Exception as error:
+ if target == "new_part":
+ created = [p for p in self.session.Parts if int(p.Tag) not in before]
+ cleanup_errors = []
+ for part in reversed(created):
+ try:
+ ref = self._reference(part, "part", part, "Import part")
+ self._close_part(part=ref["id"], save=False)
+ except Exception as cleanup:
+ cleanup_errors.append(str(cleanup))
+ try:
+ if display is not None:
+ self._activate_part(
+ self._reference(display, "part", display, "Part")["id"],
+ work=False,
+ display=True,
+ )
+ if work is not None:
+ self._activate_part(
+ self._reference(work, "part", work, "Part")["id"],
+ work=True,
+ display=False,
+ )
+ except Exception as cleanup:
+ cleanup_errors.append(str(cleanup))
+ err = (
+ error
+ if isinstance(error, NXToolError)
+ else NXToolError(
+ "NX_API_ERROR", str(error), nx_code=getattr(error, "ErrorCode", None)
+ )
+ )
+ err.details.update(
+ mutation_outcome="partial"
+ if cleanup_errors
+ else "rolled_back"
+ if created
+ else "not_started",
+ cleanup_errors=cleanup_errors,
+ )
+ if err is error:
+ raise
+ raise err from error
+ raise
+
+ def _import_diagnostics(self, import_dir, settings_file, part):
+ return {
+ "translator": "Step214Importer",
+ "settings_file": str(settings_file),
+ "staging_directory": str(import_dir),
+ "output_search_paths": [str(import_dir), str(part.FullPath)],
+ "translator_files": [
+ {
+ "path": str(f),
+ "size": f.stat().st_size,
+ "text": f.read_text(errors="replace")[-16000:]
+ if f.suffix.lower() in {".log", ".err", ".txt"}
+ else None,
+ }
+ for f in sorted(import_dir.iterdir())
+ if f.is_file() and f.name != "input.step"
+ ],
+ }
+
+ def _import_geometry_inner(self, path, flatten=False, target="work_part", output_path=None):
+ import re
+ import shutil
+
+ source = self.workspace.ensure_inside(path)
+ if source.suffix.lower() not in {".step", ".stp"}:
+ raise NXToolError("NX_UNSUPPORTED_ARGUMENT", "Only STEP import is implemented")
+ if not source.is_file():
+ raise NXToolError("NX_FILE_NOT_FOUND", str(source))
+ if target not in {"work_part", "new_part"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "target must be work_part or new_part")
+ if (target == "new_part") != bool(output_path):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Supply output_path exactly when target is new_part"
+ )
+ output = self.workspace.ensure_inside(output_path) if output_path else None
+ if output and output.exists():
+ raise NXToolError("NX_FILE_EXISTS", "Import destination already exists")
+ text = source.read_text(errors="replace")
+ framing = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL).strip("\ufeff \t\r\n")
+ if not re.match(r"ISO-10303-21\s*;", framing, re.IGNORECASE) or not re.search(
+ r"END-ISO-10303-21\s*;\s*$", framing, re.IGNORECASE
+ ):
+ raise NXToolError(
+ "NX_INVALID_STEP",
+ "STEP exchange-file opening or closing marker is missing; file may be truncated",
+ suggestion="Obtain a complete STEP file. No translator was started.",
+ )
+ product_names = set()
+ for pair in re.findall(
+ r"PRODUCT\s*\(\s*'((?:[^']|'')*)'\s*,\s*'((?:[^']|'')*)'", text, re.IGNORECASE
+ ):
+ product_names.update(Path(v.replace("''", "'")).stem.casefold() for v in pair)
+ conflicts = [
+ p.FullPath
+ for p in self.session.Parts
+ if Path(p.FullPath).stem.casefold() in product_names
+ ]
+ if conflicts and not flatten and "NEXT_ASSEMBLY_USAGE_OCCURRENCE" in text.upper():
+ raise NXToolError(
+ "NX_IMPORT_NAME_CONFLICT",
+ "STEP prototype names are already loaded. Close those saved source parts explicitly or use a fresh NX session.",
+ details={"loaded_parts": conflicts},
+ )
+ # Use the verified WorkPart importer for both modes. NX 2606's NewPart
+ # translator mode returned no output in testing; normal part creation is explicit.
+ if output:
+ self._create_part(str(output), units="mm")
+ part = self._work_part()
+ before = {int(b.Tag) for b in part.Bodies}
+ component_before = {int(c.Tag) for c, _ in self._walk_components(part)}
+ import_dir = self.workspace.root / "imports" / self._current_operation
+ import_dir.mkdir(parents=True, exist_ok=False)
+ staged = import_dir / "input.step"
+ shutil.copyfile(source, staged)
+ builder = self.session.DexManager.CreateStep214Importer()
+ try:
+ settings_file = str(
+ Path(
+ __import__("os").environ.get(
+ "UGII_BASE_DIR", r"C:\Program Files\Siemens\Designcenter2606"
+ )
+ )
+ / "STEP214UG"
+ / "step214ug.def"
+ )
+ builder.SettingsFile = settings_file
+ builder.InputFile = str(staged)
+ builder.ImportTo = self.nxopen.Step214Importer.ImportToOption.WorkPart
+ builder.FileOpenFlag = False
+ builder.FlattenAssembly = flatten
+ builder.SimplifyGeometry = False
+ builder.ObjectTypes.Solids = True
+ builder.ObjectTypes.Surfaces = True
+ builder.ObjectTypes.Curves = True
+ builder.ProcessHoldFlag = True
+ try:
+ builder.Commit()
+ except Exception as error:
+ raise NXToolError(
+ "NX_IMPORT_FAILED",
+ str(error),
+ nx_code=getattr(error, "ErrorCode", None),
+ details=self._import_diagnostics(import_dir, settings_file, part),
+ ) from error
+ finally:
+ builder.Destroy()
+ bodies = [
+ self._reference(b, "body", part, "Body")
+ for b in part.Bodies
+ if int(b.Tag) not in before
+ ]
+ components = self._list_components()
+ added_components = [
+ c for c, _ in self._walk_components(part) if int(c.Tag) not in component_before
+ ]
+ if not bodies and not added_components:
+ raise NXToolError(
+ "NX_IMPORT_NO_OUTPUT",
+ "Translator returned without imported bodies or occurrences",
+ details=self._import_diagnostics(import_dir, settings_file, part),
+ )
+ return {
+ "path": str(source),
+ "staging_directory": str(import_dir),
+ "diagnostics": self._import_diagnostics(import_dir, settings_file, part),
+ "bodies": bodies,
+ "body_count": len(bodies),
+ "components": components,
+ "created_component_count": len(added_components),
+ "target": target,
+ "part": self._reference(part, "part", part, "Part"),
+ "units": self._units(),
+ "translator": "NX v2606 Step214Importer WorkPart",
+ "flatten": flatten,
+ "warnings": [
+ "Translator-generated prototypes are staged in the returned import directory. Save to persist them; native undo does not delete translator artifacts."
+ ],
+ }
+
+ def _add_component(self, part_path, name=None, translation=None, rotation_matrix=None):
+ part = self._work_part()
+ path = self.workspace.ensure_inside(part_path)
+ t = vector(translation or [0, 0, 0])
+ r = self._validate_rotation(rotation_matrix or IDENTITY)
+ c, status = part.ComponentAssembly.AddComponent(
+ str(path),
+ "Entire Part",
+ name or path.stem,
+ self.nxopen.Point3d(*t),
+ self._nx_matrix(r),
+ -1,
+ )
+ if status:
+ status.Dispose()
+ return {
+ "component": c.Name,
+ "object": self._reference(c, "component", part, "Component"),
+ "path": str(path),
+ "translation": t,
+ "rotation_matrix": r,
+ "coordinate_frame": "work_part",
+ "message": "Added and placed component",
+ }
+
+ @staticmethod
+ def _validate_rotation(matrix):
+ if len(matrix) != 3:
+ raise NXToolError("NX_INVALID_ARGUMENT", "rotation_matrix must be 3x3")
+ m = [vector(r) for r in matrix]
+ if (
+ any(
+ abs(dot(m[i], m[j]) - (1 if i == j else 0)) > 1e-8
+ for i in range(3)
+ for j in range(3)
+ )
+ or abs(dot(m[0], cross(m[1], m[2])) - 1) > 1e-8
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "rotation_matrix must be a right-handed orthonormal matrix"
+ )
+ return m
+
+ def _set_component_transform(self, component, translation, rotation_matrix):
+ c = self._resolve(component, {"component"})
+ part = self._work_part()
+ if c.Parent != part.ComponentAssembly.RootComponent:
+ raise NXToolError(
+ "NX_UNSUPPORTED_ARGUMENT",
+ "Absolute placement currently supports immediate children; activate their owning subassembly",
+ )
+ t = vector(translation)
+ r = self._validate_rotation(rotation_matrix)
+ p, m = c.GetPosition()
+ delta = matmul(r, transpose(rows(m)))
+ # NX MoveComponent rotates orientation about the component origin and adds translation.
+ shift = [a - b for a, b in zip(t, xyz(p), strict=False)]
+ part.ComponentAssembly.MoveComponent(
+ c, self.nxopen.Vector3d(*shift), self._nx_matrix(delta)
+ )
+ actual_p, actual_m = c.GetPosition()
+ if any(abs(a - b) > 1e-7 for a, b in zip(xyz(actual_p), t, strict=False)) or any(
+ abs(rows(actual_m)[i][j] - r[i][j]) > 1e-7 for i in range(3) for j in range(3)
+ ):
+ raise NXToolError(
+ "NX_PLACEMENT_MISMATCH", "Placement read-back differs; transaction will roll back"
+ )
+ return {
+ "object": self._reference(c, "component", part, "Component"),
+ "translation": xyz(actual_p),
+ "rotation_matrix": rows(actual_m),
+ "coordinate_frame": "work_part",
+ }
+
+ def _reposition_component(self, component, dx=0, dy=0, dz=0, rx=0, ry=0, rz=0):
+ c = self._resolve(component, {"component"})
+ p, m = c.GetPosition()
+ a, b, d = [math.radians(v) for v in vector([rx, ry, rz])]
+ sx, cx, sy, cy, sz, cz = (
+ math.sin(a),
+ math.cos(a),
+ math.sin(b),
+ math.cos(b),
+ math.sin(d),
+ math.cos(d),
+ )
+ r = [
+ [cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx],
+ [sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx],
+ [-sy, cy * sx, cy * cx],
+ ]
+ return self._set_component_transform(
+ component, add(xyz(p), vector([dx, dy, dz])), matmul(r, rows(m))
+ )
+
+ def _batch(self, operations):
+ if not 1 <= len(operations) <= 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Batch requires 1–100 operations")
+ allowed = {
+ "nx_sketch_line",
+ "nx_sketch_rectangle",
+ "nx_sketch_arc",
+ "nx_add_component",
+ "nx_set_component_transform",
+ "nx_reposition_component",
+ }
+ # Preflight is structural and validates all signatures before any NX mutation.
+ for op in operations:
+ if set(op) != {"method", "params"} or op["method"] not in allowed:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Batch supports sketch curves and assembly placement only",
+ )
+ inspect.signature(self._handlers[op["method"]]).bind(**op["params"])
+ results = []
+ key = self._current_operation
+ for i, op in enumerate(operations):
+ if self.store.path(key).with_suffix(".cancel").exists():
+ raise NXToolError(
+ "NX_CANCELLED",
+ "Cancelled between operations",
+ details={"completed_before_rollback": i},
+ )
+ result = self._handlers[op["method"]](**op["params"])
+ results.append({"index": i, "method": op["method"], "result": result})
+ record = self.store.get(key)
+ record.update(progress={"completed": i + 1, "total": len(operations)})
+ self.store.put(record)
+ return {
+ "results": results,
+ "count": len(results),
+ "atomic": True,
+ "message": "Batch committed on NX journal thread",
+ }
+
+ def _capabilities(self, tool=None, prefix=None):
+ import json
+
+ manifest = json.loads(Path(__file__).with_name("capability_manifest.json").read_text())
+ part = self._work_part(required=False)
+ manifest.update(
+ session_id=self.session_id,
+ actual_nx_version=self.nx_version,
+ execution=(
+ "serialized NX batch journal thread"
+ if self.session.IsBatch
+ else "serialized NX UI thread; visible model viewport"
+ ),
+ interactive=not self.session.IsBatch,
+ api_detection={
+ "native_display_modification": hasattr(
+ self.session.DisplayManager, "NewDisplayModification"
+ ),
+ "dynamic_sections": bool(part and hasattr(part, "DynamicSections")),
+ "sketch_solver_status": hasattr(self.nxopen.Sketch, "CalculateStatus"),
+ "step_import": hasattr(self.session.DexManager, "CreateStep214Importer"),
+ "native_pattern": bool(
+ part and hasattr(part.Features, "CreatePatternFeatureBuilder")
+ ),
+ "minimum_distance": hasattr(self.session.Measurement, "GetMinimumDistance"),
+ "native_component_pattern": bool(
+ part and hasattr(part.ComponentAssembly, "CreateComponentPatternBuilder")
+ ),
+ "sketch_dimensions": hasattr(self.nxopen.Sketch, "CreateDimension"),
+ },
+ coordinate_conventions={
+ "lengths": "work-part units unless explicitly named mm3",
+ "angles": "degrees",
+ "rotation_order": "Rz * Ry * Rx",
+ "matrix": "row-major 3x3; p_parent = R*p_local + t",
+ "legacy_rotation_list": "NX axis vectors X,Y,Z, not row-major",
+ "sketch_XZ_normal": [0, -1, 0],
+ },
+ )
+ if self.session.IsBatch:
+ for name in ("nx_screenshot", "nx_render_view", "nx_ui_control"):
+ manifest["tools"][name].update(
+ status="unavailable", scope="Requires the interactive NX host"
+ )
+ if self.nx_version != "v2606":
+ for entry in manifest["tools"].values():
+ entry.update(status="experimental", scope="This NX version has not been tested")
+ if tool is not None and prefix is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use tool or prefix, not both")
+ total = len(manifest["tools"])
+ if tool is not None:
+ if tool not in manifest["tools"]:
+ raise NXToolError("NX_NOT_FOUND", "Tool is not in the capability manifest")
+ manifest["tools"] = {tool: manifest["tools"][tool]}
+ elif prefix is not None:
+ manifest["tools"] = {k: v for k, v in manifest["tools"].items() if k.startswith(prefix)}
+ manifest.update(tool_count=len(manifest["tools"]), total_tool_count=total, units=None)
+ return manifest
+
+ def _finish_sketch(self, sketch_id):
+ result = super()._finish_sketch(sketch_id)
+ sketch = self.objects.resolve(sketch_id)
+ result["object"] = self._reference(sketch, "sketch", self._work_part(), "Sketch")
+ return result
+
+ def _snapshot(self, part):
+ groups = [
+ ("explosion", self._explosions(part)),
+ ("modeling_view", self._modeling_views(part)),
+ ("assembly_constraint", self._assembly_constraints(part)),
+ ("drawing_sheet", getattr(part, "DrawingSheets", [])),
+ ("drawing_view", getattr(part, "DraftingViews", [])),
+ ("dimension", getattr(part, "Dimensions", [])),
+ ("annotation", self._documentation_annotations(part)),
+ ("traceline", getattr(part, "Tracelines", [])),
+ (
+ "component_pattern",
+ self._component_patterns(part),
+ ),
+ ("expression", getattr(part, "Expressions", [])),
+ ("body", part.Bodies),
+ ("feature", part.Features),
+ ("curve", part.Curves),
+ ("point", getattr(part, "Points", [])),
+ ("sketch", part.Sketches),
+ ("section", getattr(part, "DynamicSections", [])),
+ ("component", [c for c, _ in self._walk_components(part)]),
+ ]
+ return {
+ (kind, int(v.Tag)): self._reference(v, kind, part, kind.title())
+ for kind, values in groups
+ for v in values
+ }
+
+ def _invalidate_deleted(self, before, after, invalidate_topology=True):
+ removed = {v["id"] for k, v in before.items() if k not in after}
+ for key, entry in list(self.objects._objects.items()):
+ if key in removed or (invalidate_topology and entry.reference.kind in {"face", "edge"}):
+ self.objects._objects.pop(key, None)
+ self.objects._stale_ids.add(key)
+ self.objects._identities = {
+ k: v for k, v in self.objects._identities.items() if v in self.objects._objects
+ }
+
+ def _record_reverted(self, history, by):
+ for item in history:
+ record = self.store.get(item["operation_id"])
+ if "fingerprint" in record:
+ record["reverted_by"] = by
+ self.store.put(record)
+
+ def _measure_volume(self, body=None, scope="auto"):
+ part = self._work_part()
+ bodies = self._geometry(body, scope)
+ units = [
+ part.UnitCollection.FindObject(n)
+ for n in ["SquareMilliMeter", "CubicMilliMeter", "Kilogram", "MilliMeter", "Newton"]
+ ]
+ result = []
+ for b in bodies:
+ if not b.IsSolidBody:
+ raise NXToolError("NX_NOT_SOLID", "Volume requires solid bodies")
+ props = part.MeasureManager.NewMassProperties(units, 0.999, [b])
+ try:
+ props.InformationUnit = self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter
+ result.append(
+ {
+ "body": self._reference(b, "body", part, "Body"),
+ "volume_mm3": float(props.Volume),
+ }
+ )
+ finally:
+ props.Dispose()
+ return {
+ "bodies": result,
+ "body_count": len(result),
+ "volume_mm3": sum(r["volume_mm3"] for r in result),
+ "volume_units": "mm^3",
+ "semantics": "sum_of_included_bodies",
+ "scope": scope,
+ "warnings": [
+ "Overlapping bodies are counted separately; this is not union volume or a mass estimate."
+ ],
+ }
+
+ def _export_step(self, path):
+ import hashlib
+
+ result = super()._export_step(path)
+ file = Path(result["path"])
+ result.update(
+ size=file.stat().st_size,
+ sha256=hashlib.sha256(file.read_bytes()).hexdigest(),
+ units=self._units(),
+ components=self._list_components()["count"],
+ options={
+ "translator": "StepCreator",
+ "schema": "AP214",
+ "solids": True,
+ "surfaces": True,
+ "curves": False,
+ "layers": "1-256",
+ },
+ validation="Nonempty file with solid BREP records; geometry equivalence requires round-trip validation",
+ warnings=["Export saves the part; native NX undo/checkpoints can expire."],
+ )
+ return result
+
+ def _screenshot(
+ self,
+ path=None,
+ width=1600,
+ height=1000,
+ background="white",
+ style="shaded_with_edges",
+ fit=False,
+ ):
+ return self._capture_view(path, width, height, background, style, fit)
diff --git a/src/nx_mcp/http_surface.py b/src/nx_mcp/http_surface.py
new file mode 100644
index 0000000..47087a4
--- /dev/null
+++ b/src/nx_mcp/http_surface.py
@@ -0,0 +1,58 @@
+"""Serve compatible and compact profiles against one serial NX bridge."""
+
+from contextlib import AsyncExitStack, asynccontextmanager
+
+from mcp.server.transport_security import TransportSecuritySettings
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+from nx_mcp.server import create_server
+
+
+def create_app(bridge, workspace=None, host="192.168.52.10", port=8765):
+ servers = [create_server(bridge, workspace, surface=profile) for profile in ("full", "agent")]
+ for server in servers:
+ server.settings.streamable_http_path = "/mcp"
+ server.settings.json_response = True
+ server.settings.stateless_http = True
+ server.settings.transport_security = TransportSecuritySettings(
+ enable_dns_rebinding_protection=True,
+ allowed_hosts=[f"{address}:{port}" for address in (host, "127.0.0.1", "localhost")],
+ allowed_origins=[
+ f"http://{address}:{port}" for address in (host, "127.0.0.1", "localhost")
+ ],
+ )
+ apps = [server.streamable_http_app() for server in servers]
+
+ @asynccontextmanager
+ async def lifespan(app):
+ async with AsyncExitStack() as stack:
+ for server in servers:
+ await stack.enter_async_context(server.session_manager.run())
+ yield
+
+ return Starlette(
+ routes=[Mount("/agent", app=apps[1]), Mount("/", app=apps[0])], lifespan=lifespan
+ )
+
+
+def main():
+ import os
+ from pathlib import Path
+
+ import uvicorn
+
+ from nx_mcp.bridge import DescriptorBridgeClient
+
+ descriptor = Path(
+ os.environ.get(
+ "NX_MCP_BRIDGE_DESCRIPTOR",
+ str(Path(os.environ["LOCALAPPDATA"]) / "nx-mcp" / "bridge.json"),
+ )
+ )
+ app = create_app(DescriptorBridgeClient(descriptor, timeout=120))
+ uvicorn.run(app, host="0.0.0.0", port=8765)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/nx_mcp/inspection.py b/src/nx_mcp/inspection.py
new file mode 100644
index 0000000..b7a913e
--- /dev/null
+++ b/src/nx_mcp/inspection.py
@@ -0,0 +1,341 @@
+"""Native geometry inspection and viewport export, verified against installed NX APIs."""
+
+from __future__ import annotations
+
+import hashlib
+import itertools
+import math
+import struct
+import uuid
+
+from nx_mcp.runtime import NXToolError
+
+
+def envelope_gap(a, b):
+ return math.sqrt(sum(max(a[i] - b[i + 3], b[i] - a[i + 3], 0) ** 2 for i in range(3)))
+
+
+class InspectionMixin:
+ def _view_info(self):
+ from nx_mcp.hardened import rows, xyz
+
+ part = self.session.Parts.Display
+ if part is None:
+ raise NXToolError("NX_NO_DISPLAY_PART", "Open a display part first")
+ view = part.ModelingViews.WorkView
+ return {
+ "part": self._reference(part, "part", part, "Display part"),
+ "name": view.Name,
+ "rotation": rows(view.Matrix),
+ "origin": xyz(view.Origin),
+ "absolute_origin": xyz(view.AbsoluteOrigin),
+ "scale": view.Scale,
+ "coordinate_frame": "display_part",
+ "matrix_layout": "row-major 3x3; columns are NX view axes",
+ "rendering_style": next(
+ (
+ name
+ for name, member in [
+ ("shaded", self.nxopen.View.RenderingStyleType.Shaded),
+ (
+ "studio",
+ getattr(self.nxopen.View.RenderingStyleType, "Studio", object()),
+ ),
+ ("shaded_with_edges", self.nxopen.View.RenderingStyleType.ShadedWithEdges),
+ ("wireframe", self.nxopen.View.RenderingStyleType.StaticWireframe),
+ ]
+ if member == view.RenderingStyle
+ ),
+ "nx_style_" + str(view.RenderingStyle),
+ ),
+ "interactive": not self.session.IsBatch,
+ }
+
+ def _capture_view(
+ self,
+ path=None,
+ width=1600,
+ height=1000,
+ background="white",
+ style="shaded_with_edges",
+ fit=False,
+ ):
+ import NXOpen.Gateway
+
+ if self.session.IsBatch:
+ raise NXToolError(
+ "NX_VIEWPORT_UNAVAILABLE",
+ "Viewport export requires the interactive NX bridge; no desktop capture fallback",
+ )
+ if (
+ type(width) is not int
+ or type(height) is not int
+ or not (128 <= width <= 4096 and 128 <= height <= 4096)
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "width and height must be 128–4096 pixels")
+ if background not in {"white", "original", "transparent"} or style not in {
+ "current",
+ "shaded",
+ "shaded_with_edges",
+ "wireframe",
+ }:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported background or rendering style")
+ file = (
+ self.workspace.ensure_inside(path)
+ if path
+ else self.workspace.root / "captures" / ("nx-" + uuid.uuid4().hex + ".png")
+ )
+ if file.suffix.lower() != ".png":
+ raise NXToolError("NX_INVALID_ARGUMENT", "Viewport export requires a .png path")
+ if file.exists():
+ raise NXToolError("NX_FILE_EXISTS", "Choose a new capture path")
+ file.parent.mkdir(parents=True, exist_ok=True)
+ part = self.session.Parts.Display
+ if not part:
+ raise NXToolError("NX_NO_DISPLAY_PART", "Open a display part first")
+ view = part.ModelingViews.WorkView
+ old_style = view.RenderingStyle
+ builder = None
+ try:
+ styles = {
+ "shaded": "Shaded",
+ "shaded_with_edges": "ShadedWithEdges",
+ "wireframe": "StaticWireframe",
+ }
+ if style != "current":
+ view.RenderingStyle = getattr(self.nxopen.View.RenderingStyleType, styles[style])
+ if fit:
+ view.Fit()
+ view.UpdateDisplay()
+ camera = self._view_info()
+ builder = part.Views.CreateImageExportBuilder()
+ builder.FileName = str(file)
+ builder.FileFormat = NXOpen.Gateway.ImageExportBuilder.FileFormats.Png
+ builder.RegionMode = False
+ builder.DeviceWidth = width
+ builder.DeviceHeight = height
+ backgrounds = NXOpen.Gateway.ImageExportBuilder.BackgroundOptions
+ builder.BackgroundOption = {
+ "white": backgrounds.CustomColor,
+ "original": backgrounds.Original,
+ "transparent": backgrounds.Transparent,
+ }[background]
+ if background == "white":
+ builder.SetCustomBackgroundColor([1.0, 1.0, 1.0])
+ builder.EnhanceEdges = True
+ builder.Commit()
+ finally:
+ try:
+ if builder:
+ builder.Destroy()
+ finally:
+ view.RenderingStyle = old_style
+ data = file.read_bytes()
+ if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
+ raise NXToolError("NX_CAPTURE_FAILED", "NX did not produce a valid PNG")
+ resolution = list(struct.unpack(">II", data[16:24]))
+ return {
+ "path": str(file),
+ "artifact_path": str(file.relative_to(self.workspace.root)),
+ "capture_kind": "nx_model_viewport",
+ "model_preview": True,
+ "resolution": resolution,
+ "requested_resolution": [width, height],
+ "camera": camera,
+ "background": background,
+ "rendering": "NX viewport rasterization",
+ "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "warnings": (
+ []
+ if resolution == [width, height]
+ else ["NX returned a different resolution than requested"]
+ ),
+ }
+
+ def _interference_pair(self, a, b):
+ import NXOpen.GeometricAnalysis
+
+ part = self._work_part()
+ baseline = ([int(x.Tag) for x in part.Bodies], [int(x.Tag) for x in part.Features])
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Invisible, "NX MCP temporary interference"
+ )
+ builder = None
+ try:
+ builder = part.AnalysisManager.CreateSimpleInterferenceObject()
+ builder.InterferenceType = (
+ NXOpen.GeometricAnalysis.SimpleInterference.InterferenceMethod.InterferenceSolid
+ )
+ builder.FirstBody.Value = a
+ builder.SecondBody.Value = b
+ result = builder.PerformCheck()
+ enum = NXOpen.GeometricAnalysis.SimpleInterference.Result
+ volume = 0.0
+ if result == enum.InterferenceExists:
+ classification = "penetration"
+ units = [
+ part.UnitCollection.FindObject(n)
+ for n in [
+ "SquareMilliMeter",
+ "CubicMilliMeter",
+ "Kilogram",
+ "MilliMeter",
+ "Newton",
+ ]
+ ]
+ for body in builder.GetInterferenceResults():
+ if not body.IsSolidBody:
+ continue
+ props = part.MeasureManager.NewMassProperties(units, 0.999, [body])
+ try:
+ props.InformationUnit = (
+ self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter
+ )
+ volume += float(props.Volume)
+ finally:
+ props.Dispose()
+ elif result == enum.OnlyEdgesOrFacesInterfere:
+ classification = "contact"
+ elif result == enum.NoInterference:
+ classification = "clear"
+ else:
+ raise NXToolError(
+ "NX_INTERFERENCE_UNRESOLVED", "Native NX could not classify this pair"
+ )
+ return {"classification": classification, "interference_volume_mm3": volume}
+ finally:
+ try:
+ if builder:
+ try:
+ builder.Reset()
+ finally:
+ builder.Destroy()
+ finally:
+ try:
+ self.session.UndoToMark(mark, None)
+ self.session.DeleteUndoMark(mark, None)
+ after = ([int(x.Tag) for x in part.Bodies], [int(x.Tag) for x in part.Features])
+ if after != baseline:
+ raise RuntimeError("Temporary interference geometry remains")
+ except Exception as exc:
+ raise NXToolError(
+ "NX_ROLLBACK_FAILED",
+ "Interference cleanup failed: " + str(exc),
+ details={"mutation_outcome": "partial"},
+ ) from exc
+
+ def _check_clearance(
+ self, objects=None, minimum_clearance=0.0, max_pairs=1000, include_clear=False
+ ):
+
+ if (
+ not math.isfinite(minimum_clearance)
+ or minimum_clearance < 0
+ or type(max_pairs) is not int
+ or not 1 <= max_pairs <= 10000
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Require nonnegative finite clearance and 1–10000 max_pairs"
+ )
+ if objects is not None and (not isinstance(objects, list) or len(objects) < 2):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Specify at least two references or omit objects for the assembly",
+ )
+ # Flatten selected groups, deduplicate occurrences, then inspect each distinct body pair once.
+ bodies = (
+ list({int(b.Tag): b for ref in objects for b in self._geometry(ref)}.values())
+ if objects
+ else self._geometry(scope="assembly")
+ )
+ if any(not b.IsSolidBody for b in bodies):
+ raise NXToolError("NX_NOT_SOLID", "Clearance requires solid bodies")
+ return self._analyze_pairs(
+ bodies,
+ list(itertools.combinations(bodies, 2)),
+ minimum_clearance,
+ max_pairs,
+ include_clear,
+ )
+
+ def _analyze_pairs(self, bodies, pairs, minimum_clearance, max_pairs, include_clear):
+ import NXOpen.UF
+
+ from nx_mcp.hardened import xyz
+
+ n = len(pairs)
+ if n > max_pairs:
+ raise NXToolError(
+ "NX_PAIR_LIMIT",
+ f"{n} pairs exceed max_pairs={max_pairs}; select smaller groups or raise the explicit limit",
+ )
+ part = self._work_part()
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ boxes = {int(b.Tag): list(uf.ModlGeneral.AskBoundingBox(b.Tag)) for b in bodies}
+ reports = []
+ skipped = 0
+ counts = {"penetration": 0, "contact": 0, "below_clearance": 0, "clear": 0}
+ for a, b in pairs:
+ gap = envelope_gap(boxes[int(a.Tag)], boxes[int(b.Tag)])
+ if gap > minimum_clearance + 1e-7 and not include_clear:
+ skipped += 1
+ counts["clear"] += 1
+ continue
+ distance, p1, p2, accuracy = self.session.Measurement.GetMinimumDistance(a, b)
+ hit = (
+ self._interference_pair(a, b)
+ if distance <= 1e-7
+ else {"classification": "clear", "interference_volume_mm3": 0.0}
+ )
+ if hit["classification"] == "clear" and distance < minimum_clearance:
+ hit["classification"] = "below_clearance"
+ counts[hit["classification"]] += 1
+ if hit["classification"] != "clear" or include_clear:
+ reports.append(
+ {
+ **hit,
+ "objects": [
+ self._reference(x, "body", part, "Body occurrence") for x in [a, b]
+ ],
+ "distance": distance,
+ "closest_points": [xyz(p1), xyz(p2)],
+ "accuracy": None,
+ "accuracy_note": "No validated numerical error bound is exposed by this NX binding",
+ "method": "NX minimum distance + native solid interference"
+ if distance <= 1e-7
+ else "NX minimum distance",
+ }
+ )
+ return {
+ "pairs": reports,
+ "counts": counts,
+ "body_count": len(bodies),
+ "pair_count": n,
+ "reported_pair_count": len(reports),
+ "broad_phase_clear_pairs": skipped,
+ "minimum_clearance": minimum_clearance,
+ "units": self._units(),
+ "volume_units": "mm^3",
+ "coordinate_frame": "work_part",
+ "complete": True,
+ "semantics": "Pairwise body occurrence checks; interference volumes are not a geometric union",
+ "warnings": [
+ "Broad-phase clear pairs use conservative bounding separation; reported distances use native geometry."
+ ],
+ }
+
+ def _check_interference(self, obj1, obj2):
+ a = self._geometry(obj1)
+ b = self._geometry(obj2)
+ if any(not x.IsSolidBody for x in a + b):
+ raise NXToolError("NX_NOT_SOLID", "Interference requires solid bodies")
+ unique = {
+ tuple(sorted((int(x.Tag), int(y.Tag)))): (x, y) for x in a for y in b if x.Tag != y.Tag
+ }
+ if not unique:
+ raise NXToolError("NX_INVALID_ARGUMENT", "References resolve only to the same body")
+ bodies = list({int(x.Tag): x for x in a + b}.values())
+ result = self._analyze_pairs(bodies, list(unique.values()), 0.0, 10000, True)
+ result["selected_references"] = [obj1, obj2]
+ return result
diff --git a/src/nx_mcp/integration_server.py b/src/nx_mcp/integration_server.py
new file mode 100644
index 0000000..6abe993
--- /dev/null
+++ b/src/nx_mcp/integration_server.py
@@ -0,0 +1,1086 @@
+"""Uniform MCP envelopes and workspace-scoped artifacts for the NX 2606 bridge."""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import inspect
+import json
+import os
+import struct
+import uuid
+from typing import Annotated, Literal
+
+from mcp.types import CallToolResult, ImageContent, TextContent, ToolAnnotations
+from pydantic import BaseModel, ConfigDict, Field
+
+from nx_mcp import (
+ assembly_documentation_server,
+ authoring_server,
+ documentation_editing_server,
+ freeform_server,
+ manufacturing_server,
+ sheet_metal_server,
+)
+from nx_mcp.output_schemas import output_schema
+from nx_mcp.recovery import OperationStore
+from nx_mcp.runtime import NXToolError
+from nx_mcp.workspace import WorkspaceViolation
+
+
+def nx_list_open_parts(
+ compact: bool = False,
+ path_prefix: str | None = None,
+ modified: bool | None = None,
+ active_only: bool = False,
+ offset: int = 0,
+ limit: int | None = None,
+):
+ """List loaded parts. Defaults preserve full inventory. compact retains opaque identity; path_prefix matches an absolute NX-host path (for example D:/CAD/NX_MCP_WORKSPACE/validation/), case-insensitively with either slash style; workspace-relative prefixes do not match. active_only selects work/display parts. Filters precede paging; count is returned rows, total_count is matching rows. Read-only; never saves parts."""
+
+
+def nx_list_components(
+ compact: bool = False,
+ include_transforms: bool = True,
+ name_contains: str | None = None,
+ suppressed: bool | None = None,
+ offset: int = 0,
+ limit: int | None = None,
+):
+ """List recursive component occurrences without loading prototypes. load_state reports fully_loaded, partially_loaded or unloaded; part_path can be null if NX cannot resolve it. Unloaded subassemblies may hide descendants. Use nx_open_part(load_components=true) for explicit recovery. compact retains occurrence paths and omits repeated identity metadata and legacy rotation. include_transforms=False omits pose fields. Name filtering is case-insensitive. Filters precede paging; total_count is matching rows. Defaults preserve existing full results."""
+
+
+def nx_flat_pattern_orientation_edges(upward_face: str):
+ """List current straight boundary edges of an owned planar sheet-metal web face, with endpoints and adjacent face IDs. Pass a returned edge ID as flat_pattern.x_axis_edge. Geometric eligibility only; NX validates the final feature. Does not mutate or invalidate references."""
+
+
+def nx_list_reference_sets():
+ """List work-part custom reference sets, exact direct members and automatic-add setting. Built-in Entire Part and Empty are listed separately."""
+
+
+def nx_create_reference_set(name: str, objects: list[str]):
+ """Create a custom reference set with explicit owned body/curve/datum/direct-component IDs. Use only solid body IDs to exclude prototype datums from assembly drawings. No automatic component membership; duplicate names are rejected. Changes the work part; save it to persist."""
+
+
+def nx_set_component_reference_set(components: list[str], name: str):
+ """Assign an exact existing prototype reference-set name to direct children of the work assembly. Activate a nested owning assembly before editing its children. Preflights every target; does not change prototype membership or component poses. Returns previous/current assignments. Save the assembly to persist."""
+
+
+def nx_list_datums():
+ """List work-part datum planes, axes and coordinate systems with IDs and blanked state. Does not recurse into component prototypes."""
+
+
+def nx_set_datum_visibility(visible: bool = False):
+ """Show/hide all owned datums and coordinate systems in the work/display part. Returns restore_id for nx_restore_display. To exclude component prototype datums from drawings, assign a body-only reference set instead. Changes can persist when saved."""
+
+
+def nx_boolean(
+ boolean_type: Literal["unite", "subtract", "intersect"],
+ targets: Annotated[list[str], Field(min_length=2)],
+):
+ pass
+
+
+def nx_display_info(objects: list[str], count_only: bool = False):
+ pass
+
+
+def nx_set_display(
+ objects: list[str],
+ color_index: int | None = None,
+ transparency: int | None = None,
+ color: Literal[
+ "red", "green", "blue", "yellow", "cyan", "magenta", "orange", "white", "black", "gray"
+ ]
+ | None = None,
+):
+ pass
+
+
+def nx_set_visibility(objects: list[str], mode: Literal["show", "hide", "isolate"] = "show"):
+ pass
+
+
+def nx_restore_display(restore_id: str):
+ pass
+
+
+def nx_highlight_collisions(obj1: str, obj2: str, include_contact: bool = False):
+ pass
+
+
+def nx_clear_highlights():
+ pass
+
+
+def nx_list_sections():
+ pass
+
+
+def nx_section_view(
+ origin: list[float],
+ normal: list[float],
+ section: str | None = None,
+ name: str = "MCP section",
+ cap: bool = True,
+):
+ pass
+
+
+def nx_section_control(section: str, action: Literal["enable", "disable", "delete"]):
+ pass
+
+
+def nx_sketch_diagnostics(sketch_id: str):
+ pass
+
+
+def nx_ui_control(mode: Literal["status", "manual", "agent"] = "status"):
+ pass
+
+
+def nx_view_info():
+ pass
+
+
+def nx_screenshot(
+ path: str | None = None,
+ width: int = 1600,
+ height: int = 1000,
+ background: Literal["white", "original", "transparent"] = "white",
+ style: Literal["current", "shaded", "shaded_with_edges", "wireframe"] = "shaded_with_edges",
+ fit: bool = False,
+):
+ pass
+
+
+def nx_check_interference(obj1: str, obj2: str):
+ pass
+
+
+def nx_check_clearance(
+ objects: list[str] | None = None,
+ minimum_clearance: float = 0.0,
+ max_pairs: int = 1000,
+ include_clear: bool = False,
+):
+ pass
+
+
+# Signature-only definitions are used to publish the actual bridge arguments.
+def nx_create_sketch(
+ plane: Literal["XY", "XZ", "YZ"] = "XY",
+ name: str | None = None,
+ origin: list[float] | None = None,
+ x_axis: list[float] | None = None,
+ y_axis: list[float] | None = None,
+):
+ pass
+
+
+def nx_open_part(path: str, work: bool = True, display: bool = True, load_components: bool = False):
+ pass
+
+
+def nx_activate_part(part: str, work: bool = True, display: bool = True):
+ pass
+
+
+def nx_close_part(save: bool = True, part: str | None = None):
+ pass
+
+
+def nx_sketch_constraint(
+ constraint_type: Literal[
+ "horizontal",
+ "vertical",
+ "fix",
+ "fixed",
+ "parallel",
+ "perpendicular",
+ "equal_length",
+ "equal_radius",
+ "concentric",
+ "tangent",
+ "coincident",
+ "distance",
+ "length",
+ "radius",
+ "diameter",
+ "angle",
+ ],
+ targets: list[str],
+ value: float | None = None,
+):
+ """Apply a supported native constraint to curves owned by one sketch."""
+
+
+def nx_sketch_info(sketch_id: str):
+ pass
+
+
+def nx_sketch_arc(
+ cx: float,
+ cy: float,
+ radius: float,
+ start_angle: float,
+ end_angle: float,
+ sketch_id: str | None = None,
+):
+ pass
+
+
+def nx_edit_feature(name: str, params: dict[str, float]):
+ pass
+
+
+def nx_pattern(
+ features: list[str],
+ pattern_type: Literal["linear"] = "linear",
+ direction: Literal["X", "Y", "Z", "-X", "-Y", "-Z"] = "X",
+ spacing: float = 10,
+ count: int = 2,
+):
+ pass
+
+
+def nx_import_geometry(
+ path: str,
+ flatten: bool = False,
+ target: Literal["work_part", "new_part"] = "work_part",
+ output_path: str | None = None,
+):
+ pass
+
+
+def nx_get_bounding_box(
+ body: str | None = None,
+ scope: Literal["auto", "part", "assembly"] = "auto",
+ precision: Literal["conservative", "exact"] = "conservative",
+):
+ pass
+
+
+def nx_checkpoint(label: str = "checkpoint"):
+ pass
+
+
+def nx_checkpoint_state():
+ pass
+
+
+def nx_rollback(checkpoint_id: str):
+ pass
+
+
+def nx_operation_status(operation_id: str):
+ pass
+
+
+def nx_cancel_operation(operation_id: str):
+ pass
+
+
+def nx_list_topology(
+ body: str, face: str | None = None, include_adjacency: bool = False, compact: bool = False
+):
+ pass
+
+
+def nx_measure_volume(body: str | None = None, scope: Literal["auto", "part", "assembly"] = "auto"):
+ pass
+
+
+def nx_package_assembly(path: str):
+ pass
+
+
+def nx_add_component(
+ part_path: str,
+ name: str | None = None,
+ translation: list[float] | None = None,
+ rotation_matrix: list[list[float]] | None = None,
+):
+ pass
+
+
+def nx_set_component_transform(
+ component: str, translation: list[float], rotation_matrix: list[list[float]]
+):
+ pass
+
+
+def nx_read_result(
+ result_id: str,
+ field: str = "",
+ offset: Annotated[int, Field(ge=0)] = 0,
+ limit: Annotated[int, Field(ge=1, le=100)] = 20,
+):
+ """Read a stored oversized bridge result without repeating its operation. field is a JSON pointer; arrays/strings are paged. Nested omissions remain explicit. Snapshot expiry does not delete operation receipts."""
+
+
+class BatchParameters(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+
+
+class SketchPoint(BatchParameters):
+ x: float
+ y: float
+
+
+class SegmentParameters(BatchParameters):
+ sketch_id: str
+ start: SketchPoint
+ end: SketchPoint
+
+
+class RectangleParameters(BatchParameters):
+ sketch_id: str
+ corner1: SketchPoint
+ corner2: SketchPoint
+
+
+class ArcParameters(BatchParameters):
+ cx: float
+ cy: float
+ radius: Annotated[float, Field(gt=0)]
+ start_angle: float
+ end_angle: float
+ sketch_id: str | None = None
+
+
+Vector3 = Annotated[list[float], Field(min_length=3, max_length=3)]
+Matrix3 = Annotated[list[Vector3], Field(min_length=3, max_length=3)]
+
+
+class ComponentParameters(BatchParameters):
+ part_path: str
+ name: str | None = None
+ translation: Vector3 | None = None
+ rotation_matrix: Matrix3 | None = None
+
+
+class TransformParameters(BatchParameters):
+ component: str
+ translation: Vector3
+ rotation_matrix: Matrix3
+
+
+class RepositionParameters(BatchParameters):
+ component: str
+ dx: float = 0
+ dy: float = 0
+ dz: float = 0
+ rx: float = 0
+ ry: float = 0
+ rz: float = 0
+
+
+class SegmentOperation(BatchParameters):
+ method: Literal["nx_sketch_line"]
+ params: SegmentParameters
+
+
+class RectangleOperation(BatchParameters):
+ method: Literal["nx_sketch_rectangle"]
+ params: RectangleParameters
+
+
+class ArcOperation(BatchParameters):
+ method: Literal["nx_sketch_arc"]
+ params: ArcParameters
+
+
+class ComponentOperation(BatchParameters):
+ method: Literal["nx_add_component"]
+ params: ComponentParameters
+
+
+class TransformOperation(BatchParameters):
+ method: Literal["nx_set_component_transform"]
+ params: TransformParameters
+
+
+class RepositionOperation(BatchParameters):
+ method: Literal["nx_reposition_component"]
+ params: RepositionParameters
+
+
+BatchOperation = Annotated[
+ SegmentOperation
+ | RectangleOperation
+ | ArcOperation
+ | ComponentOperation
+ | TransformOperation
+ | RepositionOperation,
+ Field(discriminator="method"),
+]
+
+
+def nx_batch(operations: Annotated[list[BatchOperation], Field(min_length=1, max_length=100)]):
+ pass
+
+
+def nx_capabilities(tool: str | None = None, prefix: str | None = None):
+ pass
+
+
+def nx_rename_object(object_id: str, name: str):
+ pass
+
+
+def nx_revolve(
+ sketch_name: Annotated[str, Field(min_length=1)],
+ angle: float = 360,
+ axis: Literal["X", "Y", "Z", "-X", "-Y", "-Z"] = "Z",
+ boolean: Literal["none", "unite", "subtract", "intersect"] = "none",
+ axis_origin: Vector3 | None = None,
+ axis_direction: Vector3 | None = None,
+):
+ pass
+
+
+def nx_workspace_info():
+ pass
+
+
+def nx_create_directory(path: str):
+ pass
+
+
+def nx_workspace_list(
+ path: str = ".",
+ offset: Annotated[int, Field(ge=0)] = 0,
+ limit: Annotated[int, Field(ge=1, le=1000)] = 100,
+ prefix: str = "",
+):
+ pass
+
+
+def nx_download_file(
+ path: str,
+ offset: Annotated[int, Field(ge=0)] = 0,
+ length: Annotated[int, Field(ge=1, le=262144)] = 262144,
+ delivery: Literal["base64", "image", "metadata"] = "base64",
+):
+ pass
+
+
+def nx_upload_file(path: str, data_base64: str, sha256: str, total_size: int, offset: int = 0):
+ pass
+
+
+DESCRIPTIONS = {
+ "nx_export_step": "Export the active work part as STEP inside the workspace. Saves the part before translation; native undo marks and checkpoints can expire. Use a disposable copy for review-only exports when source saves are unwanted. Returns path, size, SHA-256, units, component count, translator options and validation scope.",
+ "nx_boolean": "Boolean solid bodies: unite, subtract or intersect. targets[0] is the target body; targets[1:] are tool bodies. Native cube subtraction and volume checks are scoped in nx_capabilities(tool='nx_boolean'); not general certification.",
+ "nx_revolve": "Requires sketch_name (finished sketch ID/name). Revolve about a principal axis through the part origin, or supply both axis_origin=[x,y,z] and nonzero axis_direction in work-part coordinates. Custom axes require leaving axis at its default Z. Angles are degrees, lengths in work-part units; boolean is none/unite/subtract/intersect. Inspect nx_capabilities(tool='nx_revolve') for tested scope.",
+ "nx_workspace_list": "List a workspace directory with prefix filtering and pagination (offset>=0, limit=1..1000, default 100). Returns entries/count for this page, total_count and next_offset. File entries include size/SHA-256. Use nx_download_file(delivery='metadata') to inspect one file.",
+ "nx_workspace_info": "Discover the NX host workspace root and path rules. Paths refer to the NX machine, not the MCP client's filesystem. No session-wide current directory is changed.",
+ "nx_create_directory": "Create a directory and missing parents inside the NX workspace. Accepts workspace-relative or in-workspace absolute host paths. Idempotent: an existing directory succeeds; an existing file fails. Returns actual path and created status.",
+ "nx_create_part": "Create a new NX part at an explicit workspace-relative or absolute in-workspace NX-host path, e.g. projects/controller/parts/base.prt. Missing parent folders are created. Units: mm or inch. Use unique part basenames for simultaneously loaded NX parts.",
+ "nx_delete_feature": "Delete a work-part feature by typed ID or unambiguous name using native update/undo. Dependent geometry may be deleted; inspect changes.deleted and reacquire topology afterward.",
+ "nx_measure_angle": "Measure 0..180 degrees between typed work-part line, straight-edge or planar-face references. Uses line start/end, edge vertex order or outward face normals. Curved entities and component occurrences are unsupported; directions are not an oriented dihedral angle.",
+ "nx_sketch_constraint": "Apply a constraint to owned curve IDs in one sketch. Types: horizontal, vertical, fix/fixed, parallel, perpendicular, equal_length, equal_radius, concentric, tangent, coincident, distance/length, radius, diameter, angle. Only dimensions require value in part units or degrees. Coincident means start-to-start; use nx_sketch_relation for explicit endpoints. Midpoint is not supported.",
+ "nx_save_as": "Save the active work part to a new .prt path inside the NX workspace, creating missing parent folders. Accepts relative or absolute NX-host paths. Existing files are never overwritten. Save As changes the work part's filename; it does not move an entire assembly dependency tree.",
+ "nx_display_info": "Inspect color-table indices, blank state and face transparency for body, component, feature, face or curve references. Components expand to loaded occurrence geometry.",
+ "nx_set_display": "Set an NX color index (1–216) or named color, and/or transparency (0 opaque, 100 transparent). Component/feature targets expand to bodies and faces, with a cap of 10000 unique objects. Preflight with nx_display_info(count_only=true); split larger selections after checking counts. Occurrence overrides do not recolor prototypes. Returns restore_id; restore in reverse order. Changes can persist on save.",
+ "nx_set_visibility": "Show, hide or isolate body/component geometry. Isolation preserves a restorable snapshot and includes ancestor components. Reference curves and datum geometry are not isolated. Explicit show/hide also accepts curves. Returns restore_id.",
+ "nx_restore_display": "Restore explicit appearance/visibility attributes using a same-session restore_id, in reverse order. All references are preflighted; manual handoff, rollback or close can make snapshots stale. Does not reset a part modified flag or remove inherited occurrence overrides.",
+ "nx_highlight_collisions": "Measure native solid interference and highlight the involved body occurrences using NX selection highlighting. Replaces previous MCP highlights. Contacts are optional; clear pairs are never highlighted. Returns measured pairs and entity references. No persistent recoloring.",
+ "nx_clear_highlights": "Remove only highlights created by MCP. Geometry, persistent colors and visibility are unchanged.",
+ "nx_list_sections": "Inspect native dynamic sections and the active view clipping toggle in the display/work part.",
+ "nx_section_view": "Create or edit a native single-plane section in visible NX. origin is in display-part units; normal is normalized in display-part coordinates. Solids are unchanged. Specify section ID to edit an existing active section. NX v2606 retains dot(point-origin, normal) <= 0; reversing normal reverses the retained side. Returns actual plane geometry.",
+ "nx_section_control": "Enable, disable or delete the specified native section. Disabling turns off clipping when that section is active. Deletion removes the section object, not model solids.",
+ "nx_sketch_diagnostics": "Evaluate native solver status and remaining DOF for the entire sketch; enumerate persistent constraints and their curve links. Temporarily activates an inactive sketch and restores the prior state. Rejects another active sketch. Temporarily evaluates the entire sketch and restores the work-region state. Does not infer a minimal conflict set or automatically constrain geometry.",
+ "nx_ui_control": "Inspect UI activity without queueing behind native work, or switch control modes. Agent mode reserves NX input even when idle. Status is a timestamped snapshot, not proof that a native call is responsive. Manual handoff invalidates references/checkpoints. Pause/stop cannot interrupt a native call. Finish NX dialogs before resuming.",
+ "nx_view_info": "Return the displayed model view, camera matrix, scale, rendering style, and interactive state.",
+ "nx_screenshot": "Export the actual interactive NX viewport as PNG and return an inline MCP image. Advisory 128–4096 pixel dimensions (NX can use the actual device size; response reports both), background, shaded/wireframe style and fit. No desktop capture. Paths are workspace-relative; omit for a unique capture path.",
+ "nx_check_interference": "Check native solid interference between two body/component references, including nested occurrence geometry. Return penetration/contact/clear, closest points and pairwise interference volumes in mm^3. Temporary solids are rolled back.",
+ "nx_check_clearance": "Check distinct solid body occurrences in selected groups or the full assembly. minimum_clearance uses part units. Conservative bounds prune clear pairs; reported distances and interference use native geometry. Pair limits are preflighted. Volumes are pairwise, not union volume.",
+ "nx_create_sketch": "Create an active sketch with explicit part-space origin and orthonormal basis. XY: X,Y,+Z; XZ: X,Z,-Y; YZ: Y,Z,+X. Curve coordinates use the returned local basis. Lengths in work-part units.",
+ "nx_sketch_info": "Read the actual sketch origin, basis, normal and owned curve coordinates in part space. IDs preferred.",
+ "nx_sketch_arc": "Add an arc to the active owning sketch using local coordinates; radius in work-part units and angles in degrees. Full circle: start=0,end=360. Pass sketch_id explicitly.",
+ "nx_edit_feature": "Edit native EXTRUDE distance or linear PATTERN_FEATURE count/spacing. Unsupported parameters fail and roll back. IDs or unique case-insensitive names/journal IDs accepted.",
+ "nx_pattern": "Create an associative native linear feature pattern. Count includes seed; 16 instances at pitch 16.5 of a 14-wide seed span 261.5. Work-part units.",
+ "nx_import_geometry": "Import STEP through installed NX Step214Importer into the work part for solids, or target=new_part with a new output_path for assemblies; flatten=false preserves structure. Reports new directly-owned bodies and resulting components. Translator files are not undone.",
+ "nx_get_bounding_box": "Native UF bounds; precision selects conservative or exact (exact requires axis-aligned WCS). auto includes recursive assembly geometry when present; part includes directly owned bodies; assembly includes both. Coordinates and units are work-part absolute.",
+ "nx_activate_part": "Activate an already loaded part by ID or unique path/name without closing other parts. Display activation also changes work part under NX rules.",
+ "nx_open_part": "Optional load_components=true fully loads unsuppressed occurrence prototypes, including an already loaded parent; restores load preferences and preserves current reference sets. Accept a workspace-relative or absolute in-workspace NX-host path. Open or reuse a loaded workspace .prt and activate it; work/display flags are explicit. Does not recreate loaded parts.",
+ "nx_close_part": "Closing a prototype referenced by a loaded assembly is rejected before saving; close parent assemblies first. Close the specified loaded part (ID), or current work part; save defaults true. NX may unload unused assembly prototypes. Returns all closed part references/counts; re-list open parts between closes.",
+ "nx_checkpoint": "Create an in-session model undo checkpoint. NX v2606 saves expire native marks; create a new checkpoint after save. Restart/close also invalidates checkpoints.",
+ "nx_checkpoint_state": "Inspect available checkpoint IDs and retained model-operation history. Read-only calls retain marks. Native NX save can expire them; availability is checked against NX.",
+ "nx_rollback": "Rollback to an in-session checkpoint. Rejects rollback across mutations to unrelated parts. Reacquire object IDs afterward; save explicitly to persist.",
+ "nx_operation_status": "Read durable request state without waiting on the NX thread: running, committed, failed, unknown. Unknown never authorizes blind retry.",
+ "nx_cancel_operation": "Request cooperative cancellation of a running batch between child operations. Cannot interrupt a single NXOpen call; inspect final outcome.",
+ "nx_batch": "Execute 1–100 sketch-curve/add-component/placement operations serially on the NX thread under one rollback mark. Structural preflight, progress, cancellation; all-or-rollback for model changes. Supply one stable operation_id for safe retry.",
+ "nx_add_component": "Add a .prt occurrence with initial translation and right-handed row-major 3x3 rotation. Work-part coordinates; p_parent=R*p_local+t. Returns typed occurrence.",
+ "nx_set_component_transform": "Assign absolute translation and row-major rotation to an immediate child. Read-back verified; repeating the same placement is idempotent. Activate owning subassembly for nested placement.",
+ "nx_reposition_component": "Relative translation and rotation of immediate child in work-part coordinates. Degrees, Rz*Ry*Rx. Use a stable operation_id for retry; use nx_set_component_transform for absolute placement.",
+ "nx_measure_distance": "Measure minimum BREP distance for body, face, edge, feature-body or component pairs, including nested occurrences. Returns closest points, accuracy and work-part units. Zero does not prove interference.",
+ "nx_list_topology": "Enumerate body topology. Optional face restricts results to that face and its boundary edges; include_adjacency returns face_edges and edge_faces ID mappings. compact retains minimal typed IDs. Use current upward-face boundary edges for flat-pattern orientation rather than guessing a former outer edge. Reacquire after edits/rollback/close.",
+ "nx_rename_object": "Rename a referenced object and return its actual NX-normalized display name. Reacquire references afterward.",
+ "nx_download_file": "Read a workspace file. delivery=image returns an existing PNG inline as MCP image content (max 8 MiB), without base64 in text. delivery=metadata returns size/SHA-256 only. Default base64 returns chunks: offset>=0, length=1..262144, bytes_returned/next_offset/eof. Image/metadata modes require default chunk arguments. Paths are on the NX host.",
+ "nx_upload_file": "Upload .prt/.step/.stp/.png/.json/.zip/.txt/.pdf chunks (max 256 KiB) into a new workspace file. Requires final SHA-256 and total size, sequential offsets. Repeated identical chunks are safe; existing differing files are never overwritten.",
+ "nx_package_assembly": "Package the saved active assembly and all loaded prototype dependencies into a new workspace ZIP with a SHA-256 manifest. Refuses unsaved referenced parts and files outside the workspace.",
+ "nx_capabilities": "Inspect NX-version-specific tested scope. Use tool=exact_name or prefix=nx_sheet to limit response size; omit both for all tools. API detection is separate from native test evidence. Batch NX has no model viewport.",
+}
+
+READ_ONLY = {
+ "nx_flat_pattern_orientation_edges",
+ "nx_list_reference_sets",
+ "nx_list_datums",
+ "nx_display_info",
+ "nx_list_sections",
+ "nx_sketch_diagnostics",
+ "nx_view_info",
+ "nx_check_interference",
+ "nx_check_clearance",
+ "nx_ui_control",
+ "nx_status",
+ "nx_list_sketches",
+ "nx_list_features",
+ "nx_list_bodies",
+ "nx_list_components",
+ "nx_list_open_parts",
+ "nx_get_bounding_box",
+ "nx_measure_volume",
+ "nx_measure_distance",
+ "nx_measure_angle",
+ "nx_get_feature_info",
+ "nx_sketch_info",
+ "nx_list_topology",
+ "nx_checkpoint_state",
+ "nx_capabilities",
+ "nx_operation_status",
+ "nx_workspace_info",
+ "nx_workspace_list",
+ "nx_read_result",
+ "nx_download_file",
+}
+READ_ONLY.update(
+ authoring_server.READ_ONLY
+ | sheet_metal_server.READ_ONLY
+ | freeform_server.READ_ONLY
+ | manufacturing_server.READ_ONLY
+ | assembly_documentation_server.READ_ONLY
+ | documentation_editing_server.READ_ONLY
+)
+DESCRIPTIONS.update(
+ {
+ name: obj.__doc__ or name
+ for name, obj in {
+ **vars(authoring_server),
+ **vars(sheet_metal_server),
+ **vars(freeform_server),
+ **vars(manufacturing_server),
+ **vars(assembly_documentation_server),
+ **vars(documentation_editing_server),
+ }.items()
+ if name.startswith("nx_") and inspect.isfunction(obj)
+ }
+)
+
+SIDE = {
+ "nx_create_directory",
+ "nx_workspace_info",
+ "nx_workspace_list",
+ "nx_read_result",
+ "nx_download_file",
+ "nx_upload_file",
+ "nx_operation_status",
+ "nx_cancel_operation",
+}
+PATHS = {
+ "nx_export_planar_dxf": "path",
+ "nx_export_explosion_animation": "path",
+ "nx_export_flat_pattern": "path",
+ "nx_set_sheet_metal_defaults": "bend_table",
+ "nx_render_view": "path",
+ "nx_copy_project": "path",
+ "nx_component_action": "part_path",
+ "nx_save_presentation": "path",
+ "nx_restore_presentation": "path",
+ "nx_inspection_report": "path",
+ "nx_create_part": "path",
+ "nx_open_part": "path",
+ "nx_export_step": "path",
+ "nx_add_component": "part_path",
+ "nx_import_geometry": "path",
+ "nx_screenshot": "path",
+ "nx_save_as": "path",
+ "nx_export_drawing_pdf": "path",
+}
+
+
+class IntegrationEnvelope(BaseModel):
+ """Common output contract; each tool's result fields remain extensible."""
+
+ model_config = ConfigDict(extra="allow")
+ status: Literal["success", "error"]
+ warnings: list[str]
+ units: str | None
+ operation_id: str | None = None
+ mutation_outcome: str | None = None
+
+
+def envelope(payload, error=False):
+ if payload.get("status") not in {None, "success", "error"}:
+ payload = {**payload, "validation_status": payload["status"], "status": "success"}
+ payload = {"status": "error" if error else "success", "warnings": [], "units": None, **payload}
+ return CallToolResult(
+ content=[TextContent(type="text", text=json.dumps(payload, ensure_ascii=False))],
+ structuredContent=payload,
+ isError=error,
+ )
+
+
+def configure(mcp, bridge, workspace):
+ if workspace is None:
+ return
+ existing = dict(mcp._tool_manager._tools)
+ definitions = {
+ name: obj
+ for name, obj in globals().items()
+ if name.startswith("nx_") and inspect.isfunction(obj)
+ }
+ definitions.update(
+ {
+ name: obj
+ for name, obj in {
+ **vars(authoring_server),
+ **vars(sheet_metal_server),
+ **vars(freeform_server),
+ **vars(manufacturing_server),
+ **vars(assembly_documentation_server),
+ **vars(documentation_editing_server),
+ }.items()
+ if name.startswith("nx_") and inspect.isfunction(obj)
+ }
+ )
+ names = set(existing) | set(definitions)
+ for name in names:
+ old = existing.get(name)
+ fn = definitions.get(name) or old.fn
+ sig = inspect.signature(fn)
+ # Resolve postponed annotations in the original callable's module.
+ from typing import get_type_hints
+
+ hints = get_type_hints(fn, include_extras=True)
+ parameters = [
+ p.replace(annotation=hints.get(p.name, p.annotation)) for p in sig.parameters.values()
+ ]
+ if name not in READ_ONLY and name not in SIDE and name != "nx_package_assembly":
+ parameters.append(
+ inspect.Parameter(
+ "operation_id",
+ inspect.Parameter.KEYWORD_ONLY,
+ default=None,
+ annotation=str | None,
+ )
+ )
+ sig = sig.replace(parameters=parameters, return_annotation=CallToolResult)
+
+ def factory(method, signature):
+ async def proxy(**kwargs):
+ try:
+ bound = signature.bind(**kwargs)
+ bound.apply_defaults()
+ from pydantic_core import to_jsonable_python
+
+ params = to_jsonable_python(dict(bound.arguments))
+ if "operation_id" in params and method not in {
+ "nx_operation_status",
+ "nx_cancel_operation",
+ }:
+ params["operation_id"] = params["operation_id"] or (
+ "op_" + uuid.uuid4().hex
+ )
+ if method == "nx_package_assembly":
+ result = await package_assembly(bridge, workspace, params["path"])
+ elif method in SIDE:
+ result = artifact_call(method, params, workspace)
+ else:
+ if (path_key := PATHS.get(method)) and params.get(path_key) is not None:
+ params[path_key] = str(workspace.resolve(params[path_key]))
+ if method == "nx_import_geometry" and params.get("output_path"):
+ params["output_path"] = str(workspace.resolve(params["output_path"]))
+ if method == "nx_batch":
+ for op in params["operations"]:
+ if op.get("method") == "nx_add_component" and "part_path" in op.get(
+ "params", {}
+ ):
+ op["params"]["part_path"] = str(
+ workspace.resolve(op["params"]["part_path"])
+ )
+ result = await bridge.call(method, params)
+ response = envelope(result, error=result.get("status") == "error")
+ if (
+ method in {"nx_screenshot", "nx_render_view"}
+ or (method == "nx_download_file" and params["delivery"] == "image")
+ ) and not response.isError:
+ file = workspace.resolve(result["path"])
+ if file.stat().st_size <= 8 * 1024 * 1024:
+ data = file.read_bytes()
+ if hashlib.sha256(data).hexdigest() != result["sha256"]:
+ raise NXToolError(
+ "NX_ARTIFACT_CHANGED",
+ "Capture file changed after its operation committed",
+ details={"mutation_outcome": "committed"},
+ )
+ response.content.append(
+ ImageContent(
+ type="image",
+ mimeType="image/png",
+ data=base64.b64encode(data).decode(),
+ )
+ )
+ else:
+ result.setdefault("warnings", []).append(
+ "Image exceeds inline limit; use nx_download_file"
+ )
+ response = envelope(result)
+ return response
+ except (NXToolError, WorkspaceViolation, ValueError, TypeError) as e:
+ error = (
+ e
+ if isinstance(e, NXToolError)
+ else NXToolError(
+ "NX_PATH_OUTSIDE_WORKSPACE"
+ if isinstance(e, WorkspaceViolation)
+ else "NX_INVALID_ARGUMENT",
+ str(e),
+ )
+ )
+ if "params" in locals() and params.get("operation_id"):
+ error.details.setdefault("operation_id", params["operation_id"])
+ error.details.setdefault("mutation_outcome", "unknown")
+ return envelope(error.as_dict(), True)
+
+ proxy.__name__ = method
+ proxy.__signature__ = signature
+ return proxy
+
+ if old:
+ mcp.remove_tool(name)
+ description = DESCRIPTIONS.get(
+ name,
+ (inspect.getdoc(definitions[name]) if name in definitions else None)
+ or (old.description if old else name),
+ )
+ description = description.replace("EXPERIMENTAL: ", "")
+ if description.strip() == name:
+ description = (
+ "Experimental NX operation; semantics and installed API support have not been validated. "
+ + name
+ )
+ mcp.add_tool(
+ factory(name, sig),
+ name=name,
+ description=description,
+ structured_output=False,
+ annotations=ToolAnnotations(
+ readOnlyHint=name in READ_ONLY and name != "nx_ui_control",
+ idempotentHint=name in READ_ONLY
+ or name
+ in {
+ "nx_set_component_transform",
+ "nx_create_directory",
+ "nx_edit_explosion",
+ "nx_show_explosion",
+ },
+ ),
+ )
+ tool = mcp._tool_manager.get_tool(name)
+ tool.fn_metadata.output_model = IntegrationEnvelope
+ tool.fn_metadata.output_schema = output_schema(
+ name, IntegrationEnvelope.model_json_schema()
+ )
+ tool.fn_metadata.arg_model.model_config["extra"] = "forbid"
+ tool.fn_metadata.arg_model.model_rebuild(force=True)
+ tool.parameters = tool.fn_metadata.arg_model.model_json_schema()
+ if name == "nx_edit_sketch":
+ tool.parameters["properties"]["operations"].update(
+ minItems=1, maxItems=100, items={"oneOf": authoring_server.SKETCH_OPERATION_SCHEMAS}
+ )
+ if name == "nx_edit_explosion":
+ for schema in tool.parameters["properties"]["placements"]["anyOf"]:
+ if schema.get("type") == "array":
+ schema.update(maxItems=1000, items=authoring_server.EXPLOSION_PLACEMENT_SCHEMA)
+ original_call = mcp.call_tool
+
+ async def uniform_call(name, arguments):
+ try:
+ return await original_call(name, arguments)
+ except Exception as error:
+ return envelope(
+ NXToolError(
+ "NX_INVALID_ARGUMENT", str(error), details={"mutation_outcome": "not_started"}
+ ).as_dict(),
+ True,
+ )
+
+ mcp.call_tool = uniform_call
+ mcp._mcp_server.call_tool(validate_input=False)(uniform_call)
+ mcp._mcp_server.instructions = "Siemens NX v2606 integration. Use nx_capabilities for tested scope. Discover the host root with nx_workspace_info. File paths are workspace-relative or absolute inside that root; use explicit project subfolders on every file call. Use client-supplied operation_id for mutation retry; query receipts after transport failure. No general certification is claimed."
+
+
+def artifact_call(method, p, workspace):
+ store = OperationStore(workspace.root)
+ if method == "nx_read_result":
+ from nx_mcp.result_transport import read_result
+
+ return read_result(workspace.root / ".nx-mcp" / "bridge-results", **p)
+ if method == "nx_operation_status":
+ return store.get(p["operation_id"])
+ if method == "nx_cancel_operation":
+ record = store.get(p["operation_id"])
+ if record["state"] != "running" or record.get("method") != "nx_batch":
+ raise NXToolError("NX_NOT_CANCELLABLE", "Only running batches accept cancellation")
+ store.path(p["operation_id"]).with_suffix(".cancel").touch()
+ return {
+ "status": "success",
+ "operation_id": p["operation_id"],
+ "cancellation_requested": True,
+ }
+ if method == "nx_workspace_info":
+ return {
+ "status": "success",
+ "root": str(workspace.root),
+ "path_host": "NX server",
+ "relative_to": str(workspace.root),
+ "absolute_paths": "accepted inside workspace only",
+ "parent_creation": ["nx_create_part", "nx_save_as", "nx_upload_file"],
+ "current_directory": "No mutable current directory; use explicit paths on every call",
+ }
+ path = workspace.resolve(p["path"])
+ if method == "nx_create_directory":
+ existed = path.is_dir()
+ try:
+ path.mkdir(parents=True, exist_ok=True)
+ except OSError as error:
+ raise NXToolError("NX_DIRECTORY_ERROR", str(error)) from error
+ return {
+ "status": "success",
+ "path": str(path),
+ "relative_path": str(path.relative_to(workspace.root)),
+ "created": not existed,
+ "mutation_outcome": "committed",
+ }
+ if ".nx-mcp" in path.relative_to(workspace.root).parts:
+ raise NXToolError("NX_PATH_RESERVED", "Internal service state is not an artifact")
+
+ def metadata(file):
+ h = hashlib.sha256()
+ with file.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ h.update(chunk)
+ return {
+ "path": str(file.relative_to(workspace.root)),
+ "size": file.stat().st_size,
+ "sha256": h.hexdigest(),
+ }
+
+ if method == "nx_workspace_list":
+ if not path.exists():
+ raise NXToolError(
+ "NX_DIRECTORY_NOT_FOUND",
+ "Workspace directory does not exist; inspect the parent directory or correct path",
+ )
+ if not path.is_dir():
+ raise NXToolError(
+ "NX_NOT_DIRECTORY",
+ "path must name a workspace directory; use nx_download_file(delivery='metadata') for a file",
+ )
+ offset, limit, prefix = p.get("offset", 0), p.get("limit", 100), p.get("prefix", "")
+ if offset < 0 or not 1 <= limit <= 1000:
+ raise NXToolError("NX_INVALID_ARGUMENT", "offset must be >= 0; limit must be 1..1000")
+ files = [
+ f
+ for f in sorted(path.iterdir())
+ if f.name.casefold() != ".nx-mcp" and f.name.startswith(prefix)
+ ]
+ items = []
+ for f in files[offset : offset + limit]:
+ workspace.ensure_inside(f)
+ items.append(
+ metadata(f) | {"kind": "file"}
+ if f.is_file()
+ else {"path": str(f.relative_to(workspace.root)), "kind": "directory"}
+ )
+ next_offset = offset + len(items)
+ return {
+ "status": "success",
+ "path": str(path),
+ "entries": items,
+ "count": len(items),
+ "total_count": len(files),
+ "offset": offset,
+ "next_offset": next_offset if next_offset < len(files) else None,
+ }
+ if method == "nx_download_file":
+ if p["offset"] < 0 or not 1 <= p["length"] <= 262144:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "offset must be >= 0; length must be 1..262144 bytes"
+ )
+ if not path.is_file():
+ raise NXToolError("NX_FILE_NOT_FOUND", "path must name an existing workspace file")
+ delivery = p.get("delivery", "base64")
+ if delivery not in {"base64", "image", "metadata"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "delivery must be base64, image or metadata")
+ if delivery != "base64" and (p["offset"] != 0 or p["length"] != 262144):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "image/metadata delivery requires default offset=0 and length=262144; chunk ranges apply only to base64",
+ )
+ meta = metadata(path)
+ if delivery == "metadata":
+ return {"status": "success", **meta, "delivery": delivery}
+ if delivery == "image":
+ if meta["size"] > 8 * 1024 * 1024:
+ raise NXToolError(
+ "NX_IMAGE_TOO_LARGE", "Inline PNG limit is 8 MiB; use base64 chunks"
+ )
+ with path.open("rb") as stream:
+ header = stream.read(24)
+ if len(header) != 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR":
+ raise NXToolError(
+ "NX_UNSUPPORTED_IMAGE",
+ "Inline delivery supports PNG files with an IHDR header; use base64 for other formats",
+ )
+ return {
+ "status": "success",
+ **meta,
+ "delivery": delivery,
+ "mime_type": "image/png",
+ "resolution": list(struct.unpack(">II", header[16:24])),
+ }
+ with path.open("rb") as stream:
+ stream.seek(p["offset"])
+ data = stream.read(p["length"])
+ next_offset = p["offset"] + len(data)
+ return {
+ "status": "success",
+ **meta,
+ "offset": p["offset"],
+ "bytes_returned": len(data),
+ "next_offset": next_offset if next_offset < meta["size"] else None,
+ "data_base64": base64.b64encode(data).decode(),
+ "eof": next_offset >= meta["size"],
+ }
+ if path.suffix.lower() not in {
+ ".prt",
+ ".step",
+ ".stp",
+ ".png",
+ ".json",
+ ".zip",
+ ".txt",
+ ".pdf",
+ }:
+ raise NXToolError(
+ "NX_UNSUPPORTED_FILE_TYPE", "Upload is limited to CAD and review artifacts"
+ )
+ if not 0 <= p["offset"] <= p["total_size"] <= 256 * 1024 * 1024:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Invalid offset or total_size (max 256 MiB)")
+ if len(p["sha256"]) != 64 or any(c not in "0123456789abcdef" for c in p["sha256"]):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Expected lowercase SHA-256")
+ data = base64.b64decode(p["data_base64"], validate=True)
+ if len(data) > 262144 or p["offset"] + len(data) > p["total_size"]:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Chunk exceeds limit")
+ if path.exists():
+ meta = metadata(path)
+ if meta["sha256"] == p["sha256"] and meta["size"] == p["total_size"]:
+ return {"status": "success", **meta, "committed": True, "replayed": True}
+ raise NXToolError("NX_FILE_EXISTS", "Existing file differs; choose a new destination")
+ staging = workspace.root / ".nx-mcp" / "uploads"
+ staging.mkdir(exist_ok=True, parents=True)
+ key = hashlib.sha256(
+ (str(path) + "|" + p["sha256"] + "|" + str(p["total_size"])).encode()
+ ).hexdigest()
+ temp = staging / key
+ size = temp.stat().st_size if temp.exists() else 0
+ if p["offset"] < size:
+ with temp.open("rb") as f:
+ f.seek(p["offset"])
+ previous = f.read(len(data))
+ if previous != data:
+ raise NXToolError("NX_UPLOAD_CONFLICT", "Retried chunk differs from staged data")
+ elif p["offset"] == size:
+ with temp.open("ab") as f:
+ f.write(data)
+ f.flush()
+ os.fsync(f.fileno())
+ else:
+ raise NXToolError("NX_UPLOAD_GAP", "Chunks must be contiguous")
+ complete = temp.stat().st_size == p["total_size"]
+ if complete:
+ if metadata(temp)["sha256"] != p["sha256"]:
+ raise NXToolError(
+ "NX_CHECKSUM_MISMATCH",
+ "Uploaded bytes do not match SHA-256; choose a corrected upload",
+ )
+ path.parent.mkdir(parents=True, exist_ok=True)
+ # Exclusive destination creation prevents overwrite races between clients.
+ os.link(temp, path) # Atomic exclusive publication on the same filesystem.
+ temp.unlink()
+ return {
+ "status": "success",
+ "path": str(path.relative_to(workspace.root)),
+ "received": size + len(data) if p["offset"] == size else size,
+ "committed": complete,
+ "sha256": p["sha256"],
+ }
+
+
+async def package_assembly(bridge, workspace, path):
+ import zipfile
+
+ destination = workspace.resolve(path)
+ if destination.suffix.lower() != ".zip":
+ raise NXToolError("NX_INVALID_ARGUMENT", "Package path must end with .zip")
+ opened = await bridge.call("nx_list_open_parts", {})
+ active = next(p for p in opened["parts"] if p["work"])
+ components = await bridge.call("nx_list_components", {})
+ paths = {active["path"]} | {c["part_path"] for c in components["components"]}
+ for p in opened["parts"]:
+ if p["path"] in paths and p["modified"]:
+ raise NXToolError(
+ "NX_UNSAVED_PART", "Save referenced parts before packaging: " + p["path"]
+ )
+ manifest = []
+ for p in sorted(paths):
+ file = workspace.ensure_inside(p)
+ if not file.is_file():
+ raise NXToolError("NX_FILE_NOT_FOUND", str(file))
+ manifest.append(
+ {
+ "path": str(file.relative_to(workspace.root)).replace("\\", "/"),
+ "size": file.stat().st_size,
+ "sha256": hashlib.sha256(file.read_bytes()).hexdigest(),
+ }
+ )
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ temp = workspace.root / ".nx-mcp" / ("package-" + uuid.uuid4().hex + ".zip")
+ temp.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ with zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED) as archive:
+ for item in manifest:
+ archive.write(workspace.resolve(item["path"]), item["path"])
+ archive.writestr(
+ "nx-assembly-manifest.json",
+ json.dumps(
+ {"assembly": active, "components": components, "files": manifest}, indent=2
+ ),
+ )
+ os.link(temp, destination)
+ finally:
+ temp.unlink(missing_ok=True)
+ return {
+ "status": "success",
+ "path": str(destination.relative_to(workspace.root)),
+ "size": destination.stat().st_size,
+ "sha256": hashlib.sha256(destination.read_bytes()).hexdigest(),
+ "prototype_files": len(paths) - 1,
+ "component_instances": components["count"],
+ "files": manifest,
+ }
diff --git a/src/nx_mcp/interactive.py b/src/nx_mcp/interactive.py
new file mode 100644
index 0000000..9406cbd
--- /dev/null
+++ b/src/nx_mcp/interactive.py
@@ -0,0 +1,520 @@
+"""Windows NX UI-thread host. No background thread calls NXOpen.
+
+The journal returns after installing a Win32 timer. NX's existing message pump
+invokes TIMERPROC on the registering thread; the pinned callback drains one call.
+"""
+
+from __future__ import annotations
+
+import ctypes
+import json
+import os
+import threading
+import time
+import uuid
+from pathlib import Path
+
+from nx_mcp.runtime import NXToolError
+
+_host = None
+_retired = []
+
+
+def nx_main_window():
+ """Find the main window owned by this NX process, before creating our panel."""
+ from ctypes import wintypes as w
+
+ user = ctypes.WinDLL("user32", use_last_error=True)
+ callback_type = ctypes.WINFUNCTYPE(w.BOOL, w.HWND, w.LPARAM)
+ user.EnumWindows.argtypes = [callback_type, w.LPARAM]
+ user.GetWindowThreadProcessId.argtypes = [w.HWND, ctypes.POINTER(w.DWORD)]
+ user.GetWindowRect.argtypes = [w.HWND, ctypes.POINTER(w.RECT)]
+ user.IsWindowVisible.argtypes = [w.HWND]
+ windows = []
+
+ def visit(hwnd, param):
+ pid = w.DWORD()
+ user.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
+ if pid.value == os.getpid() and user.IsWindowVisible(hwnd):
+ rect = w.RECT()
+ user.GetWindowRect(hwnd, ctypes.byref(rect))
+ windows.append(((rect.right - rect.left) * (rect.bottom - rect.top), hwnd))
+ return True
+
+ callback = callback_type(visit)
+ user.EnumWindows(callback, 0)
+ if not windows:
+ raise RuntimeError("NX has no visible main window")
+ return max(windows)[1]
+
+
+class ControlPanel:
+ def __init__(self, host):
+ from ctypes import wintypes as w
+
+ self.host = host
+ self.user = ctypes.WinDLL("user32", use_last_error=True)
+ self.proc_type = ctypes.WINFUNCTYPE(ctypes.c_ssize_t, w.HWND, w.UINT, w.WPARAM, w.LPARAM)
+
+ class WNDCLASS(ctypes.Structure):
+ _fields_ = [
+ ("style", w.UINT),
+ ("lpfnWndProc", self.proc_type),
+ ("cbClsExtra", ctypes.c_int),
+ ("cbWndExtra", ctypes.c_int),
+ ("hInstance", w.HINSTANCE),
+ ("hIcon", w.HICON),
+ ("hCursor", w.HANDLE),
+ ("hbrBackground", w.HBRUSH),
+ ("lpszMenuName", w.LPCWSTR),
+ ("lpszClassName", w.LPCWSTR),
+ ]
+
+ self.user.DefWindowProcW.argtypes = [w.HWND, w.UINT, w.WPARAM, w.LPARAM]
+ self.user.DefWindowProcW.restype = ctypes.c_ssize_t
+ self.user.CreateWindowExW.argtypes = [
+ w.DWORD,
+ w.LPCWSTR,
+ w.LPCWSTR,
+ w.DWORD,
+ ctypes.c_int,
+ ctypes.c_int,
+ ctypes.c_int,
+ ctypes.c_int,
+ w.HWND,
+ w.HMENU,
+ w.HINSTANCE,
+ ctypes.c_void_p,
+ ]
+ self.user.CreateWindowExW.restype = w.HWND
+ self.user.SetWindowTextW.argtypes = [w.HWND, w.LPCWSTR]
+ self.user.UpdateWindow.argtypes = [w.HWND]
+ self.user.DestroyWindow.argtypes = [w.HWND]
+ self.user.UnregisterClassW.argtypes = [w.LPCWSTR, w.HINSTANCE]
+ self.user.RegisterClassW.argtypes = [ctypes.POINTER(WNDCLASS)]
+ self.proc = self.proc_type(self._message)
+ cls = WNDCLASS()
+ cls.lpfnWndProc = self.proc
+ cls.hbrBackground = 6
+ cls.lpszClassName = "NxMcpPanel" + uuid.uuid4().hex
+ self._class = cls
+ if not self.user.RegisterClassW(ctypes.byref(cls)):
+ raise ctypes.WinError(ctypes.get_last_error())
+ self.hwnd = self.user.CreateWindowExW(
+ 0x80,
+ cls.lpszClassName,
+ "NX MCP control",
+ 0x10C80000,
+ 40,
+ 60,
+ 560,
+ 195,
+ self.host.main_hwnd,
+ None,
+ None,
+ None,
+ )
+ if not self.hwnd:
+ raise ctypes.WinError(ctypes.get_last_error())
+ self.label = self.user.CreateWindowExW(
+ 0,
+ "STATIC",
+ "Starting interactive bridge...",
+ 0x50000000,
+ 12,
+ 10,
+ 530,
+ 85,
+ self.hwnd,
+ None,
+ None,
+ None,
+ )
+ for caption, x, ident in [
+ ("Pause / manual", 12, 101),
+ ("Resume agent", 152, 102),
+ ("Stop bridge", 292, 103),
+ ]:
+ self.user.CreateWindowExW(
+ 0, "BUTTON", caption, 0x50010000, x, 110, 130, 30, self.hwnd, ident, None, None
+ )
+
+ def _message(self, hwnd, msg, wp, lp):
+ try:
+ if msg == 0x111:
+ ident = int(wp) & 0xFFFF
+ if ident == 101:
+ self.host.requested_mode = "manual"
+ elif ident == 102:
+ self.host.requested_mode = "agent"
+ elif ident == 103:
+ self.host.stop_requested = True
+ return 0
+ if msg == 0x10:
+ self.host.stop_requested = True
+ return 0
+ except BaseException as exc:
+ self.host.last_error = str(exc)
+ return self.user.DefWindowProcW(hwnd, msg, wp, lp)
+
+ def update(self, text):
+ if text == getattr(self, "_text", None):
+ return
+ self._text = text
+ self.user.SetWindowTextW(self.label, text)
+ # Paint only this panel before native work. Never pump arbitrary NX
+ # messages here: that would permit reentrant modeling calls.
+ self.user.UpdateWindow(self.label)
+ self.user.UpdateWindow(self.hwnd)
+
+ def close(self):
+ self.user.DestroyWindow(self.hwnd)
+ self.user.UnregisterClassW(self._class.lpszClassName, None)
+
+
+class InteractiveHost:
+ def __init__(self, workspace, descriptor_path):
+ import secrets
+
+ import NXOpen
+
+ from nx_mcp import nx_bridge
+ from nx_mcp.bridge import BridgeDescriptor, BridgeServer, MainThreadDispatcher
+ from nx_mcp.hardened import HardenedExecutor
+ from nx_mcp.workspace import Workspace
+
+ self.session = NXOpen.Session.GetSession()
+ self.nx = NXOpen
+ self.ui = NXOpen.UI.GetUI()
+ if self.session.IsBatch:
+ raise RuntimeError("Interactive host requires a graphical NX session")
+ self.thread = threading.get_ident()
+ self.native_thread = ctypes.windll.kernel32.GetCurrentThreadId()
+ self.root = Path(workspace)
+ self.root.mkdir(parents=True, exist_ok=True)
+ self.state_dir = self.root / ".nx-mcp"
+ self.state_dir.mkdir(exist_ok=True)
+ self.stop_file = self.state_dir / "ui-stop"
+ self.stop_file.unlink(missing_ok=True)
+ self.mode = "manual"
+ self.requested_mode = None
+ self.owns_lock = False
+ self.busy = False
+ self.stop_requested = False
+ self.stopped = False
+ self.ticks = 0
+ self.completed = 0
+ self.running_method = None
+ self.operation_started = None
+ self.last_duration_seconds = None
+ self._snapshot = None
+ self.last_method = None
+ self.last_error = None
+ self.started = time.time()
+ batch_descriptor = Path(os.environ.get("LOCALAPPDATA", "")) / "nx-mcp" / "bridge.json"
+ default_workspace = Path(os.environ.get("NX_MCP_WORKSPACE", r"D:\CAD\NX_MCP_WORKSPACE"))
+ if self.root.resolve() == default_workspace.resolve() and batch_descriptor.exists():
+ raise RuntimeError(
+ "Stop the batch bridge before attaching the interactive host to this workspace"
+ )
+ self.executor = HardenedExecutor(
+ self.session,
+ NXOpen,
+ nx_bridge._detect_nx_version(self.session),
+ Workspace(self.root),
+ enable_experimental=True,
+ enable_journal=False,
+ )
+ self.executor._handlers["nx_ui_control"] = self.control
+ token = secrets.token_hex(32)
+ self.dispatcher = MainThreadDispatcher(self.execute)
+ self.server = BridgeServer(
+ self.dispatch,
+ token=token,
+ concurrent_requests=True,
+ result_directory=Path(self.root) / ".nx-mcp" / "bridge-results",
+ )
+ self.descriptor_path = Path(descriptor_path)
+ self.server.start()
+ self.descriptor = BridgeDescriptor.create(
+ self.server.port, self.executor.nx_version, token=token
+ )
+ self.main_hwnd = nx_main_window()
+ self.window_disabled = False
+ self.panel = ControlPanel(self)
+ self.user = ctypes.WinDLL("user32", use_last_error=True)
+ self.user.EnableWindow.argtypes = [ctypes.c_void_p, ctypes.c_int]
+ self.user.IsWindowEnabled.argtypes = [ctypes.c_void_p]
+ self.timer_type = ctypes.WINFUNCTYPE(
+ None, ctypes.c_void_p, ctypes.c_uint, ctypes.c_size_t, ctypes.c_uint32
+ )
+ self.callback = self.timer_type(self.tick)
+ self.user.SetTimer.argtypes = [
+ ctypes.c_void_p,
+ ctypes.c_size_t,
+ ctypes.c_uint,
+ self.timer_type,
+ ]
+ self.user.SetTimer.restype = ctypes.c_size_t
+ self.user.KillTimer.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
+ self.timer = self.user.SetTimer(None, 0, 100, self.callback)
+ if not self.timer:
+ raise ctypes.WinError(ctypes.get_last_error())
+ self.descriptor.write(self.descriptor_path)
+ self.auto_start = True
+
+ def dispatch(self, method, params):
+ # Bridge-worker health reads use an immutable UI-thread snapshot only.
+ # They must not wait behind the native operation they are diagnosing.
+ snapshot = getattr(self, "_snapshot", None)
+ if method == "nx_ui_control" and params.get("mode", "status") == "status" and snapshot:
+ result = dict(snapshot)
+ result["snapshot_age_seconds"] = max(
+ 0, time.monotonic() - result.pop("_sample_monotonic")
+ )
+ result["snapshot_only"] = True
+ started = result.pop("_operation_monotonic", None)
+ result["operation_elapsed_seconds"] = (
+ max(0, time.monotonic() - started) if started is not None else None
+ )
+ return result
+ return self.dispatcher.call(method, params)
+
+ def publish(self):
+ state = self.status()
+ self._snapshot = {
+ **state,
+ "_sample_monotonic": time.monotonic(),
+ "_operation_monotonic": self.operation_started,
+ }
+ self.panel.update(self.panel_text())
+ temp = self.state_dir / "ui-state.tmp"
+ temp.write_text(json.dumps(state))
+ temp.replace(self.state_dir / "ui-state.json")
+
+ def panel_text(self):
+ if self.running_method:
+ return (
+ f"Running {self.running_method} — NX input reserved\n"
+ "Native work may block repainting. Pause / Stop applies after it returns."
+ )
+ if self.mode == "manual":
+ return "Manual editing enabled — agent requests paused\nFinish NX dialogs, then Resume agent."
+ detail = self.last_error or (
+ f"Last: {self.last_method} ({self.last_duration_seconds:.2f}s)"
+ if self.last_duration_seconds is not None
+ else "Waiting for MCP requests"
+ )
+ return (
+ "Agent idle — NX input intentionally reserved\nUse Pause / manual to edit or navigate NX.\n"
+ + detail
+ )
+
+ def status(self):
+ return {
+ "interactive": True,
+ "pid": os.getpid(),
+ "mode": self.mode,
+ "activity": "running"
+ if self.running_method
+ else ("reserved_idle" if self.mode == "agent" else "manual"),
+ "running_method": self.running_method,
+ "operation_elapsed_seconds": (
+ time.monotonic() - self.operation_started
+ if self.operation_started is not None
+ else None
+ ),
+ "last_duration_seconds": self.last_duration_seconds,
+ "sampled_at": time.time(),
+ "snapshot_only": False,
+ "pause_semantics": "between operations; does not interrupt a native call",
+ "ui_locked_by_bridge": self.owns_lock,
+ "actual_ui_lock": self.ui.AskLockStatus() == self.nx.UI.Status.Lock,
+ "native_lock_value": str(self.ui.AskLockStatus()),
+ "can_open_part": self.ui.CanOpenPart(),
+ "last_unlock": getattr(self, "last_unlock", None),
+ "nx_window_input_enabled": bool(self.user.IsWindowEnabled(self.main_hwnd)),
+ "main_thread_id": self.native_thread,
+ "callback_thread_id": ctypes.windll.kernel32.GetCurrentThreadId(),
+ "ticks": self.ticks,
+ "completed_operations": self.completed,
+ "last_method": self.last_method,
+ "last_error": self.last_error,
+ "uptime_seconds": time.time() - self.started,
+ "scheduler": "Win32 UI-thread timer; one queued call per tick",
+ }
+
+ def control(self, mode="status"):
+ if threading.get_ident() != self.thread:
+ raise RuntimeError("UI control called off NX thread")
+ if mode == "manual":
+ if self.mode == "agent":
+ if hasattr(self.executor, "_clear_highlights"):
+ self.executor._clear_highlights()
+ # Manual edits have no bridge receipt. Do not let a later agent
+ # rollback undo them, or resolve references across that boundary.
+ for part in self.session.Parts:
+ self.executor.objects.invalidate_part(self.executor._part_id(part))
+ self.executor._history.clear()
+ self.executor._checkpoints.clear()
+ self.executor._review_epoch = getattr(self.executor, "_review_epoch", 0) + 1
+ if self.owns_lock:
+ before = str(self.ui.AskLockStatus())
+ self.ui.UnlockAccess()
+ self.last_unlock = {
+ "before": before,
+ "after": str(self.ui.AskLockStatus()),
+ "can_open_part": self.ui.CanOpenPart(),
+ }
+ self.owns_lock = False
+ if self.window_disabled:
+ self.user.EnableWindow(self.main_hwnd, True)
+ self.window_disabled = False
+ self.mode = "manual"
+ self.auto_start = False
+ elif mode == "agent":
+ if not self.owns_lock:
+ if self.ui.AskLockStatus() == self.nx.UI.Status.Lock or not self.ui.CanOpenPart():
+ raise NXToolError(
+ "NX_UI_BUSY",
+ "Finish the current NX dialog before resuming agent control",
+ details={"mutation_outcome": "not_started"},
+ )
+ self.ui.LockAccess()
+ self.owns_lock = True
+ self.user.EnableWindow(self.main_hwnd, False)
+ self.window_disabled = True
+ self.mode = "agent"
+ elif mode != "status":
+ raise NXToolError("NX_INVALID_ARGUMENT", "mode must be status, manual or agent")
+ return self.status()
+
+ def execute(self, method, params):
+ if threading.get_ident() != self.thread:
+ raise RuntimeError("NX request called off registering UI thread")
+ if method == "nx_ui_control":
+ return self.control(params.get("mode", "status"))
+ if method == "nx_status":
+ return {**self.executor.execute(method, params), "ui": self.status()}
+ if self.mode != "agent" or not self.owns_lock:
+ raise NXToolError(
+ "NX_UI_PAUSED",
+ "Agent control is paused. Finish manual edits and resume in NX MCP control.",
+ details={"mutation_outcome": "not_started"},
+ )
+ # FileNew must not run inside LockAccess: NX v2606 can strand its
+ # internal UI lock. The disabled main window reserves user input while
+ # native operations execute on this same UI thread.
+ self.ui.UnlockAccess()
+ self.last_method = method
+ self.running_method = method
+ self.operation_started = time.monotonic()
+ self.last_error = None
+ try:
+ try:
+ self.publish()
+ except Exception as exc:
+ self.last_error = "UI status publication failed: " + str(exc)
+ result = self.executor.execute(method, params)
+ self.completed += 1
+ part = self.session.Parts.Display
+ from nx_mcp.hardened import READ_ONLY
+
+ sheet = getattr(getattr(part, "DrawingSheets", None), "CurrentDrawingSheet", None)
+ if part and method not in READ_ONLY and sheet is None:
+ try:
+ part.ModelingViews.WorkView.UpdateDisplay()
+ except Exception as exc:
+ result.setdefault("warnings", []).append("View refresh: " + str(exc))
+ return result
+ except BaseException as exc:
+ self.last_error = str(exc)
+ raise
+ finally:
+ self.last_duration_seconds = time.monotonic() - self.operation_started
+ self.running_method = None
+ self.operation_started = None
+ # Restore the between-operation native lock after all NX work.
+ try:
+ if self.ui.AskLockStatus() != self.nx.UI.Status.Lock:
+ self.ui.LockAccess()
+ except Exception as exc:
+ self.last_error = "Cannot restore agent UI reservation: " + str(exc)
+ self.control("manual")
+ try:
+ self.publish()
+ except Exception as exc:
+ # A failed diagnostic write must not turn a committed operation
+ # into a failure response that invites an unsafe retry.
+ self.last_error = "UI status publication failed: " + str(exc)
+
+ def tick(self, *args):
+ if self.busy or self.stopped:
+ return
+ self.busy = True
+ try:
+ self.ticks += 1
+ if self.stop_requested or self.stop_file.exists():
+ self.stop()
+ return
+ if self.requested_mode:
+ mode, self.requested_mode = self.requested_mode, None
+ try:
+ self.control(mode)
+ except NXToolError as exc:
+ self.last_error = str(exc)
+ if self.auto_start:
+ try:
+ self.control("agent")
+ self.auto_start = False
+ except NXToolError:
+ pass
+ self.dispatcher.drain(timeout=0, limit=1)
+ self.panel.update(self.panel_text())
+ if self.ticks % 10 == 0:
+ self.publish()
+ except BaseException as exc:
+ self.last_error = str(exc)
+ try: # noqa: SIM105 - Last-resort native callback cleanup must not escape.
+ self.control("manual")
+ except BaseException:
+ pass
+ finally:
+ self.busy = False
+
+ def stop(self):
+ from nx_mcp.bridge import BridgeDescriptor
+
+ self.control("manual")
+ self.stopped = True
+ self.user.KillTimer(None, self.timer)
+ self.dispatcher.stop()
+ self.server.stop()
+ self.panel.close()
+ try:
+ if BridgeDescriptor.read(self.descriptor_path).token == self.descriptor.token:
+ self.descriptor_path.unlink()
+ except (ValueError, OSError):
+ pass
+
+
+def start(workspace, descriptor_path):
+ global _host
+ if _host is not None and not _host.stopped:
+ return _host.status()
+ if _host is not None:
+ _retired.append(_host) # Keep native callback delegates alive until NX exits.
+ candidate = InteractiveHost.__new__(InteractiveHost)
+ try:
+ candidate.__init__(workspace, descriptor_path)
+ except BaseException:
+ if getattr(candidate, "timer", None):
+ candidate.user.KillTimer(None, candidate.timer)
+ if hasattr(candidate, "server"):
+ candidate.server.stop()
+ if hasattr(candidate, "panel"):
+ candidate.panel.close()
+ _retired.append(candidate)
+ raise
+ _host = candidate
+ return _host.status()
diff --git a/src/nx_mcp/inventory.py b/src/nx_mcp/inventory.py
new file mode 100644
index 0000000..b16c16d
--- /dev/null
+++ b/src/nx_mcp/inventory.py
@@ -0,0 +1,30 @@
+"""Backward-compatible projections for large native inventories."""
+
+from nx_mcp.runtime import NXToolError
+
+
+def compact_reference(reference):
+ return {
+ k: v
+ for k, v in reference.items()
+ if k in {"id", "kind", "name", "part_id", "occurrence_path"}
+ }
+
+
+def page(rows, offset=0, limit=None):
+ if (
+ type(offset) is not int
+ or offset < 0
+ or (limit is not None and (type(limit) is not int or not 1 <= limit <= 1000))
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "offset must be nonnegative; limit must be 1–1000 or null"
+ )
+ selected = rows[offset:] if limit is None else rows[offset : offset + limit]
+ end = offset + len(selected)
+ return selected, {
+ "total_count": len(rows),
+ "count": len(selected),
+ "offset": offset,
+ "next_offset": end if end < len(rows) else None,
+ }
diff --git a/src/nx_mcp/legacy_repairs.py b/src/nx_mcp/legacy_repairs.py
new file mode 100644
index 0000000..7fab8d2
--- /dev/null
+++ b/src/nx_mcp/legacy_repairs.py
@@ -0,0 +1,132 @@
+"""Typed-reference replacements for legacy NX 2606 entry points."""
+
+from __future__ import annotations
+
+import math
+
+from nx_mcp.runtime import NXToolError
+
+
+class LegacyRepairsMixin:
+ def _delete_feature(self, name):
+ feature = self._resolve(name, {"feature"})
+ ref = self._reference(feature, "feature", self._work_part(), "Feature")
+ manager = self.session.UpdateManager
+ self._require_api(manager, "AddToDeleteList", "DoUpdate")
+ manager.AddToDeleteList(feature)
+ self._update_model()
+ return {"deleted": [ref]}
+
+ def _angle_direction(self, obj):
+ if isinstance(obj, self.nxopen.Line):
+ a, b = obj.StartPoint, obj.EndPoint
+ return [b.X - a.X, b.Y - a.Y, b.Z - a.Z], "line_start_to_end"
+ if isinstance(obj, self.nxopen.Edge):
+ if obj.SolidEdgeType != self.nxopen.Edge.EdgeType.Linear:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Angle requires a straight edge")
+ a, b = obj.GetVertices()
+ return [b.X - a.X, b.Y - a.Y, b.Z - a.Z], "edge_vertex_order"
+ if isinstance(obj, self.nxopen.Face):
+ uf = self.nxopen.UF.UFSession.GetUFSession()
+ code, _, direction, _, _, _, sign = uf.Modeling.AskFaceData(obj.Tag)
+ if code != 22:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Angle requires a planar face")
+ return [float(x) * sign for x in direction], "outward_face_normal"
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Use lines, straight edges or planar faces")
+
+ def _measure_angle(self, obj1, obj2):
+ objects = [self._resolve(r, {"curve", "edge", "face"}) for r in (obj1, obj2)]
+ vectors, conventions = zip(*(self._angle_direction(o) for o in objects), strict=True)
+ lengths = [math.sqrt(sum(x * x for x in v)) for v in vectors]
+ if any(n <= 1e-12 for n in lengths):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Cannot measure a degenerate direction")
+ dot = sum(a * b for a, b in zip(*vectors, strict=True)) / math.prod(lengths)
+ angle = math.degrees(math.acos(max(-1.0, min(1.0, dot))))
+ return {
+ "angle_deg": angle,
+ "units": "deg",
+ "coordinate_frame": "work_part",
+ "range": [0, 180],
+ "direction_conventions": list(conventions),
+ "resolved_references": [
+ self._reference(
+ o,
+ "face"
+ if isinstance(o, self.nxopen.Face)
+ else "edge"
+ if isinstance(o, self.nxopen.Edge)
+ else "curve",
+ self._work_part(),
+ "Angle target",
+ )
+ for o in objects
+ ],
+ }
+
+ def _sketch_constraint(self, constraint_type, targets, value=None):
+ key = constraint_type.strip().lower()
+ single = {"fix", "fixed", "horizontal", "vertical"}
+ pair = {
+ "parallel",
+ "perpendicular",
+ "equal_length",
+ "equal_radius",
+ "concentric",
+ "tangent",
+ "coincident",
+ }
+ dims = {
+ "distance": "length",
+ "length": "length",
+ "radius": "radius",
+ "diameter": "diameter",
+ }
+ if key not in single | pair | set(dims) | {"angle"}:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Unsupported constraint type; use the published enum"
+ )
+ count = 2 if key in pair or key == "angle" else 1
+ if len(targets) != count:
+ raise NXToolError("NX_INVALID_ARGUMENT", f"{key} requires {count} curve reference(s)")
+ dimensional = key in dims or key == "angle"
+ if dimensional != (value is not None):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Only dimensional constraints require a value")
+ curves = [self._resolve(t, {"curve"}) for t in targets]
+ sketches = [
+ s
+ for s in self._work_part().Sketches
+ if all(any(int(c.Tag) == int(g.Tag) for g in s.GetAllGeometry()) for c in curves)
+ ]
+ if len(sketches) != 1:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Targets must belong to one sketch")
+ sketch = sketches[0]
+ ref = self._reference(sketch, "sketch", self._work_part(), "Sketch")["id"]
+ if key in single:
+ return self._edit_sketch(
+ ref,
+ [
+ {
+ "action": "constraint",
+ "curve": targets[0],
+ "type": "fixed" if key in {"fix", "fixed"} else key,
+ }
+ ],
+ )
+ if key == "tangent":
+ return self._sketch_tangent(ref, *targets)
+ if key in pair:
+ # Legacy signature has no endpoint arguments: make the default explicit.
+ result = self._sketch_relation(
+ ref,
+ *targets,
+ relation=key,
+ **({"point1": "start", "point2": "start"} if key == "coincident" else {}),
+ )
+ if key == "coincident":
+ result["endpoint_convention"] = (
+ "start_to_start; use nx_sketch_relation for explicit endpoints"
+ )
+ return result
+ if key == "angle":
+ return self._sketch_angle(ref, *targets, value=value, origin=[0, 0])
+ return self._sketch_dimension(ref, targets[0], dims[key], value, [0, 0])
diff --git a/src/nx_mcp/manufacturing.py b/src/nx_mcp/manufacturing.py
new file mode 100644
index 0000000..5b0865e
--- /dev/null
+++ b/src/nx_mcp/manufacturing.py
@@ -0,0 +1,426 @@
+"""Native thread and geometric manufacturing inspection operations."""
+
+from __future__ import annotations
+
+from nx_mcp.authoring import finite
+from nx_mcp.runtime import NXToolError
+
+
+class ManufacturingMixin:
+ def _thread(
+ self,
+ face,
+ start_face,
+ pitch,
+ major_diameter,
+ minor_diameter,
+ length,
+ angle=60.0,
+ detailed=False,
+ left_hand=False,
+ starts=1,
+ reverse=False,
+ ):
+ import NXOpen.Features as F
+
+ target = self._engineering_owned(face, "face")
+ start = self._engineering_owned(start_face, "face")
+ import NXOpen.UF as U
+
+ cylinder = U.UFSession.GetUFSession().Modeling.AskFaceData(target.Tag)
+ if cylinder[0] != 16 or start.GetBody() != target.GetBody():
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Select a cylindrical face and a start face on the same body"
+ )
+ diameter = 2 * cylinder[4]
+ values = {
+ k: finite(v, k, True)
+ for k, v in {
+ "pitch": pitch,
+ "major_diameter": major_diameter,
+ "minor_diameter": minor_diameter,
+ "length": length,
+ "angle": angle,
+ }.items()
+ }
+ if (
+ values["minor_diameter"] >= values["major_diameter"]
+ or angle >= 180
+ or type(starts) is not int
+ or not 1 <= starts <= 16
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Require minor < major, angle < 180 degrees and 1..16 starts"
+ )
+ b = self._freeform_builder("CreateThreadBuilder")
+ try:
+ b.ThreadInput = F.ThreadBuilder.Input.Manual
+ b.SmartThread = False
+ b.ThreadType = (
+ F.ThreadBuilder.Type.Detailed if detailed else F.ThreadBuilder.Type.Symbolic
+ )
+ b.CylindricalFace.Value = target
+ b.StartObject.Value = start
+ b.TapDrillDiameterExp.RightHandSide = str(diameter)
+ b.ShaftDiameterExp.RightHandSide = str(diameter)
+ b.ThreadLimit = F.ThreadBuilder.LimitOption.Value
+ b.ThreadHandedness = (
+ F.ThreadBuilder.Handedness.LeftHand
+ if left_hand
+ else F.ThreadBuilder.Handedness.RightHand
+ )
+ b.NumStarts = starts
+ b.ReverseThreadDirection = reverse
+ for prop, key in [
+ ("PitchExp", "pitch"),
+ ("MajorDiameterExp", "major_diameter"),
+ ("MinorDiameterExp", "minor_diameter"),
+ ("ThreadLength", "length"),
+ ("AngleExp", "angle"),
+ ]:
+ getattr(b, prop).RightHandSide = str(values[key])
+ result = self._freeform_commit(b)
+ result.update(
+ {
+ "representation": "detailed" if detailed else "symbolic",
+ "internal": b.IsInternalThread,
+ "pitch": b.Pitch,
+ "major_diameter": b.MajorDiameter,
+ "minor_diameter": b.MinorDiameter,
+ "length": b.ThreadLength.Value,
+ "starts": b.NumStarts,
+ }
+ )
+ return result
+ finally:
+ b.Destroy()
+
+ def _pmi_datum(self, faces, letter, position, annotation=None):
+ import re
+
+ from nx_mcp.freeform import points3
+
+ targets = self._freeform_refs(faces, "face")
+ if not isinstance(letter, str) or not re.fullmatch(r"[A-Z]{1,3}", letter):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Datum identifier must be 1..3 uppercase letters"
+ )
+ point = points3([position])[0]
+ existing = self._engineering_owned(annotation, "annotation") if annotation else None
+ b = self._work_part().Annotations.Datums.CreatePmiDatumFeatureSymbolBuilder(existing)
+ try:
+ b.Letter = letter
+ return self._commit_pmi(b, targets, point, existing)
+ finally:
+ b.Destroy()
+
+ def _pmi_fcf(
+ self,
+ faces,
+ characteristic,
+ tolerance,
+ position,
+ datums=None,
+ annotation=None,
+ material="none",
+ zone_shape="none",
+ datum_material=None,
+ projected_height=None,
+ tangent_plane=False,
+ free_state=False,
+ ):
+ import NXOpen.Annotations as A
+
+ from nx_mcp.freeform import points3
+
+ allowed = {
+ "Straightness",
+ "Flatness",
+ "Circularity",
+ "Cylindricity",
+ "ProfileOfALine",
+ "ProfileOfASurface",
+ "Angularity",
+ "Perpendicularity",
+ "Parallelism",
+ "Position",
+ "Concentricity",
+ "Symmetry",
+ "CircularRunout",
+ "TotalRunout",
+ "AxisIntersection",
+ }
+ if characteristic not in allowed:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported geometric characteristic")
+ tolerance = finite(tolerance, "tolerance", True)
+ targets = self._freeform_refs(faces, "face")
+ point = points3([position])[0]
+ if datums is not None and (not isinstance(datums, list) or len(datums) > 3):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "At most three datum annotation IDs are supported"
+ )
+ refs = [self._engineering_owned(r, "annotation") for r in (datums or [])]
+ if characteristic in {"Straightness", "Flatness", "Circularity", "Cylindricity"} and refs:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Form tolerances do not take datum references")
+ letters = []
+ for obj in refs:
+ reader = self._work_part().Annotations.Datums.CreatePmiDatumFeatureSymbolBuilder(obj)
+ try:
+ letters.append(reader.Letter)
+ finally:
+ reader.Destroy()
+ modifiers = {
+ "none": "NotSet",
+ "MMC": "MaximumMaterialCondition",
+ "LMC": "LeastMaterialCondition",
+ "RFS": "RegardlessOfFeatureSize",
+ }
+ zones = {
+ "none": "NotSet",
+ "diameter": "Diameter",
+ "spherical_diameter": "SphericalDiameter",
+ "square": "Square",
+ }
+ if material not in modifiers or zone_shape not in zones:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown tolerance modifier or zone shape")
+ if datum_material is None:
+ datum_material = ["none"] * len(refs)
+ if len(datum_material) != len(refs) or any(x not in modifiers for x in datum_material):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Specify one supported material modifier per datum"
+ )
+ if projected_height is not None:
+ projected_height = finite(projected_height, "projected_height", True)
+ if characteristic in {"Straightness", "Flatness", "Circularity", "Cylindricity"} and (
+ material != "none" or projected_height is not None
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "These form-tolerance modifier combinations are not supported",
+ )
+ existing = self._engineering_owned(annotation, "annotation") if annotation else None
+ b = self._work_part().Annotations.CreatePmiFeatureControlFrameBuilder(existing)
+ try:
+ b.Characteristic = getattr(
+ A.FeatureControlFrameBuilder.FcfCharacteristic, characteristic
+ )
+ b.FrameStyle = A.FeatureControlFrameBuilder.FcfFrameStyle.SingleFrame
+ frame = b.FeatureControlFrameDataList.FindItem(0)
+ frame.ToleranceValue = str(tolerance)
+ frame.MaterialModifier = getattr(
+ A.FeatureControlFrameDataBuilder.ToleranceMaterialModifier, modifiers[material]
+ )
+ frame.ZoneShape = getattr(
+ A.FeatureControlFrameDataBuilder.ToleranceZoneShape, zones[zone_shape]
+ )
+ frame.Projected = projected_height is not None
+ frame.ProjectedValue = str(projected_height or 0)
+ frame.TangentPlane = tangent_plane
+ frame.FreeState = free_state
+ datum_fields = [
+ "PrimaryDatumReference",
+ "SecondaryDatumReference",
+ "TertiaryDatumReference",
+ ]
+ for index, name in enumerate(datum_fields):
+ datum = getattr(frame, name)
+ datum.Letter = letters[index] if index < len(letters) else ""
+ datum.MaterialCondition = getattr(
+ A.DatumReferenceBuilder.DatumReferenceMaterialCondition,
+ modifiers[datum_material[index] if index < len(datum_material) else "none"],
+ )
+ result = self._commit_pmi(b, targets, point, existing)
+ result.update(
+ {
+ "characteristic": characteristic,
+ "tolerance": tolerance,
+ "datum_letters": letters,
+ "material": material,
+ "zone_shape": zone_shape,
+ "datum_material": datum_material,
+ "projected_height": projected_height,
+ "tangent_plane": tangent_plane,
+ "free_state": free_state,
+ }
+ )
+ return result
+ finally:
+ b.Destroy()
+
+ def _commit_pmi(self, builder, targets, position, existing):
+ builder.AssociatedObjects.Nxobjects.Clear()
+ builder.AssociatedObjects.Nxobjects.Add(targets)
+ builder.Origin.SetInferRelativeToGeometry(True)
+ builder.Origin.Origin.SetValue(None, None, self.nxopen.Point3d(*position))
+ if not builder.Validate():
+ raise NXToolError("NX_ANNOTATION_INVALID", "Native PMI builder validation failed")
+ obj = builder.Commit()
+ self._update_model()
+ if obj is None:
+ raise NXToolError("NX_VERIFICATION_FAILED", "NX returned no PMI object")
+ ref = self._reference(obj, "annotation", self._work_part(), "PMI")
+ return {
+ "annotation": ref,
+ "created": [] if existing else [ref],
+ "modified": [ref] if existing else [],
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ "geometry_associated": True,
+ }
+
+ def _face_samples(self, faces, samples_per_axis):
+ import NXOpen.UF as U
+
+ if type(samples_per_axis) is not int or not 1 <= samples_per_axis <= 20:
+ raise NXToolError("NX_INVALID_ARGUMENT", "samples_per_axis must be 1..20")
+ if len(faces) * samples_per_axis**2 > 10000:
+ raise NXToolError(
+ "NX_SAMPLE_LIMIT", "Select fewer faces or lower sample density (maximum 10000)"
+ )
+ uf = U.UFSession.GetUFSession().Modeling
+ samples = []
+ skipped = 0
+ for face in faces:
+ bounds = uf.AskFaceUvMinmax(face.Tag)
+ for i in range(samples_per_axis):
+ for j in range(samples_per_axis):
+ uv = [
+ bounds[0] + (bounds[1] - bounds[0]) * (i + 0.5) / samples_per_axis,
+ bounds[2] + (bounds[3] - bounds[2]) * (j + 0.5) / samples_per_axis,
+ ]
+ data = uf.AskFaceProps(face.Tag, uv)
+ if uf.AskPointContainment(data[0], face.Tag) == 2:
+ skipped += 1
+ continue
+ samples.append((face, uv, data))
+ return samples, skipped
+
+ def _face_analysis(self, faces, samples_per_axis=3, pull_direction=None, minimum_draft=1.0):
+ import math
+
+ from nx_mcp.visual_tools import unit_normal
+
+ objects = self._freeform_refs(faces, "face")
+ direction = unit_normal(pull_direction) if pull_direction is not None else None
+ minimum = finite(minimum_draft, "minimum_draft")
+ if not 0 <= minimum < 90 or (direction is None and minimum_draft != 1.0):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "minimum_draft requires a pull direction and must be 0..<90 degrees",
+ )
+ samples, skipped = self._face_samples(objects, samples_per_axis)
+ reports = []
+ for face, uv, data in samples:
+ point, _, _, _, _, normal, radii = data
+ curvature = [0.0 if abs(r) > 1e25 else 1 / r if abs(r) > 1e-12 else None for r in radii]
+ record = {
+ "face": self._reference(face, "face", self._work_part(), "Face"),
+ "uv": uv,
+ "point": list(point),
+ "normal": list(normal),
+ "principal_radii": [None if abs(r) > 1e25 else r for r in radii],
+ "principal_curvatures": curvature,
+ }
+ if direction is not None:
+ cosine = sum(a * b for a, b in zip(normal, direction, strict=False))
+ draft = math.degrees(math.asin(max(-1.0, min(1.0, cosine))))
+ record.update(
+ {
+ "signed_draft_degrees": draft,
+ "classification": "negative"
+ if draft < -1e-7
+ else "below_minimum"
+ if draft < minimum
+ else "positive",
+ }
+ )
+ reports.append(record)
+ return {
+ "samples": reports,
+ "sample_count": len(reports),
+ "skipped_outside_trim": skipped,
+ "units": self._units(),
+ "curvature_units": "1/" + self._units(),
+ "coordinate_frame": "work_part",
+ "pull_direction": direction,
+ "minimum_draft_degrees": minimum if direction is not None else None,
+ "method": "native face derivatives at a trimmed UV grid; sampled values, not certified global extrema or mold-release feasibility",
+ }
+
+ def _wall_thickness(self, body, faces=None, samples_per_axis=3, tolerance=0.001):
+ import math
+
+ import NXOpen.UF as U
+
+ target = self._engineering_owned(body, "body")
+ if not target.IsSolidBody:
+ raise NXToolError("NX_NOT_SOLID", "Wall thickness requires a solid body")
+ tolerance = finite(tolerance, "tolerance", True)
+ selected = (
+ self._freeform_refs(faces, "face") if faces is not None else list(target.GetFaces())
+ )
+ if any(f.GetBody() != target for f in selected):
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Faces must belong to the selected body")
+ samples, skipped = self._face_samples(selected, samples_per_axis)
+ uf = U.UFSession.GetUFSession().Modeling
+ face_map = {int(f.Tag): f for f in target.GetFaces()}
+ identity = [1.0 if i % 5 == 0 else 0.0 for i in range(16)]
+ reports = []
+ for face, _uv, data in samples:
+ point, normal = data[0], data[5]
+ direction = [-v for v in normal]
+ origin = [p + tolerance * d for p, d in zip(point, direction, strict=False)]
+ if uf.AskPointContainment(origin, target.Tag) != 1:
+ reports.append(
+ {
+ "source_face": self._reference(face, "face", self._work_part(), "Face"),
+ "point": list(point),
+ "status": "unresolved",
+ "reason": "Inward offset is not inside solid; decrease tolerance or sample away from boundaries",
+ }
+ )
+ continue
+ _, hits = uf.TraceARay(1, [target.Tag], origin, direction, identity, 0)
+ hit = next(
+ (
+ h
+ for h in hits
+ if sum(
+ (a - b) * d for a, b, d in zip(h.HitPoint, origin, direction, strict=False)
+ )
+ > 0
+ ),
+ None,
+ )
+ record = {
+ "source_face": self._reference(face, "face", self._work_part(), "Face"),
+ "point": list(point),
+ "status": "unresolved",
+ }
+ if hit is not None:
+ distance = math.dist(point, hit.HitPoint)
+ record.update(
+ {
+ "status": "measured",
+ "thickness": distance,
+ "opposite_point": list(hit.HitPoint),
+ "opposite_face": self._reference(
+ face_map[int(hit.HitFace)], "face", self._work_part(), "Face"
+ ),
+ }
+ )
+ reports.append(record)
+ distances = [x["thickness"] for x in reports if x["status"] == "measured"]
+ return {
+ "body": self._reference(target, "body", self._work_part(), "Body"),
+ "samples": reports,
+ "sample_count": len(reports),
+ "measured_count": len(distances),
+ "skipped_outside_trim": skipped,
+ "minimum_sampled_thickness": min(distances) if distances else None,
+ "maximum_sampled_thickness": max(distances) if distances else None,
+ "tolerance": tolerance,
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ "method": "first exit along inward native face normal; sampled wall distance, not global minimum thickness or rolling-ball thickness",
+ }
diff --git a/src/nx_mcp/manufacturing_server.py b/src/nx_mcp/manufacturing_server.py
new file mode 100644
index 0000000..e49f858
--- /dev/null
+++ b/src/nx_mcp/manufacturing_server.py
@@ -0,0 +1,123 @@
+"""Native manufacturing detail and bounded, explicitly sampled analysis contracts."""
+
+from __future__ import annotations
+
+from typing import Annotated, Literal
+
+from nx_mcp.schema_types import Field
+
+READ_ONLY = {"nx_face_analysis", "nx_wall_thickness"}
+NON_MODEL: set[str] = set()
+
+
+def nx_thread(
+ face: str,
+ start_face: str,
+ pitch: float,
+ major_diameter: float,
+ minor_diameter: float,
+ length: float,
+ angle: float = 60.0,
+ detailed: bool = False,
+ left_hand: bool = False,
+ starts: int = 1,
+ reverse: bool = False,
+):
+ """Create an associative native manual thread on a cylindrical face with an explicit start face on the same body. All lengths use part units; angle is the included profile angle in degrees. Symbolic threads preserve simplified geometry; detailed=True models the thread. Actual cylinder diameter is used for tap-drill/shaft diameter. Specify valid minor/major diameters, pitch, length, handedness and 1..16 starts. No standard or fit class is inferred from these manual dimensions. Returns native internal/external classification and parameter readback."""
+
+
+def nx_pmi_datum(
+ faces: list[str], letter: str, position: list[float], annotation: str | None = None
+):
+ """Create or edit a native geometry-associated PMI datum feature symbol on work-part faces. letter is 1..3 uppercase letters, position is [x,y,z] in part units. annotation edits an existing datum ID. Uses the current annotation plane. Datum schemes are supplied by the caller, not inferred from manufacturing intent."""
+
+
+def nx_pmi_fcf(
+ faces: list[str],
+ characteristic: Literal[
+ "Straightness",
+ "Flatness",
+ "Circularity",
+ "Cylindricity",
+ "ProfileOfALine",
+ "ProfileOfASurface",
+ "Angularity",
+ "Perpendicularity",
+ "Parallelism",
+ "Position",
+ "Concentricity",
+ "Symmetry",
+ "CircularRunout",
+ "TotalRunout",
+ "AxisIntersection",
+ ],
+ tolerance: float,
+ position: list[float],
+ datums: list[str] | None = None,
+ annotation: str | None = None,
+ material: Literal["none", "MMC", "LMC", "RFS"] = "none",
+ zone_shape: Literal["none", "diameter", "spherical_diameter", "square"] = "none",
+ datum_material: list[Literal["none", "MMC", "LMC", "RFS"]] | None = None,
+ projected_height: float | None = None,
+ tangent_plane: bool = False,
+ free_state: bool = False,
+):
+ """Create/edit a native single-frame geometry-associated GD&T PMI feature-control frame. Select work-part faces, a positive tolerance in part units, [x,y,z] annotation position and up to three ordered existing datum annotation IDs. Form tolerances reject datum references. Supports explicit tolerance/datum material modifiers, zone shape, projected height, tangent-plane and free-state flags. Omitted modifiers reset to none on editing. Does not claim a complete standards compliance check. annotation edits an existing FCF ID."""
+
+
+def nx_face_analysis(
+ faces: list[str],
+ samples_per_axis: int = 3,
+ pull_direction: list[float] | None = None,
+ minimum_draft: float = 1.0,
+):
+ """Sample native face normals and principal curvatures on a trimmed UV grid. Optional work-part pull_direction enables signed draft angles: asin(normal dot pull), with positive/negative/below-minimum classifications. minimum_draft is degrees. 1..20 samples per UV axis, at most 10000 total; skips points outside trimmed boundaries. Reports sampled values, not global curvature extrema, mold-release feasibility, or an undercut certification."""
+
+
+def nx_wall_thickness(
+ body: str,
+ faces: list[str] | None = None,
+ samples_per_axis: int = 3,
+ tolerance: float = 0.001,
+):
+ """Measure a solid's sampled wall thickness using native inward-normal ray intersections. Select an owned work-part solid and optionally its faces. Sample a trimmed UV grid (1..20 per axis, max 10000); start each ray tolerance part-units inside the solid. Returns source/opposite faces and points, sampled min/max, unresolved counts and units. This is first-exit normal thickness, not global minimum or rolling-ball thickness. Thin regions smaller than tolerance require a smaller tolerance."""
+
+
+def nx_thread_catalog(
+ standard: str | None = None, size: str | None = None, offset: int = 0, limit: int = 50
+):
+ """Query installed NX thread-table choices in place. Without standard returns names; select standard for sizes and exact size for dimensional metadata, method and radial engagement. Paged, 1..200 rows. No catalog file transfer; no invented fit classes. Exact catalog strings are required by nx_standard_thread."""
+
+
+def nx_standard_thread(
+ face: str,
+ start_face: str,
+ standard: str,
+ size: str,
+ length: float,
+ method: str | None = None,
+ radial_engage: str | None = None,
+ detailed: bool = False,
+ left_hand: bool = False,
+ reverse: bool = False,
+):
+ """Create a native ThreadTable thread from one installed standard/size row. Use nx_thread_catalog to disambiguate method and radial_engage. Select a cylindrical face and same-body start face; length uses part units. Symbolic or detailed, handedness and direction are explicit. Returns native dimensions and actual catalog selection; no manual approximation of a standard thread."""
+
+
+READ_ONLY.add("nx_thread_catalog")
+
+
+NON_MODEL.add("nx_export_planar_dxf")
+Point3 = Annotated[list[float], Field(min_length=3, max_length=3)]
+
+
+def nx_export_planar_dxf(
+ source: str,
+ path: str,
+ origin: Point3 | None = None,
+ x_axis: Point3 | None = None,
+ y_axis: Point3 | None = None,
+ layer: str = "OUTLINE",
+ layers: dict[str, str] | None = None,
+):
+ """Export an owned sketch or planar face (all boundary loops, including holes) as analytic LINE/ARC/CIRCLE DXF at 1:1 mm. No model changes or save. Rejects splines and non-planar geometry, never approximates them. Default frame is the sketch basis or a deterministic face basis; response reports the exact frame. Supply both orthonormal axes and an origin in the source plane to choose PCB coordinates, in work-part units. layers maps current source curve/edge IDs to ASCII layer names; unlisted entities use layer. Existing files are rejected. This is separate from native sheet-metal flat-pattern export."""
diff --git a/src/nx_mcp/nx_bridge.py b/src/nx_mcp/nx_bridge.py
index 4d65aa1..479c5c3 100644
--- a/src/nx_mcp/nx_bridge.py
+++ b/src/nx_mcp/nx_bridge.py
@@ -2,8 +2,10 @@
from __future__ import annotations
+import base64
import os
import secrets
+import subprocess
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
@@ -30,6 +32,12 @@ class NXOpenExecutor:
"nx_sketch_rectangle",
"nx_finish_sketch",
"nx_extrude",
+ "nx_revolve",
+ "nx_sketch_arc",
+ "nx_hole",
+ "nx_boolean",
+ "nx_add_component",
+ "nx_reposition_component",
}
def __init__(
@@ -67,6 +75,19 @@ def __init__(
"nx_extrude": self._extrude,
"nx_undo": self._undo,
"nx_fit_view": self._fit_view,
+ "nx_revolve": self._revolve,
+ "nx_sketch_arc": self._sketch_arc_legacy,
+ "nx_hole": self._hole,
+ "nx_boolean": self._boolean,
+ "nx_screenshot": self._screenshot,
+ "nx_get_bounding_box": self._get_bounding_box,
+ "nx_measure_volume": self._measure_volume,
+ "nx_add_component": self._add_component,
+ "nx_list_components": self._list_components,
+ "nx_reposition_component": self._reposition_component,
+ "nx_set_view": self._set_view,
+ "nx_get_feature_info": self._get_feature_info,
+ "nx_list_open_parts": self._list_open_parts,
}
def execute(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
@@ -210,7 +231,7 @@ def _open_part(self, path: str) -> dict[str, Any]:
def _save_part(self) -> dict[str, Any]:
part = self._work_part()
part.Save(
- self.nxopen.BasePart.SaveComponents.TrueValue,
+ self.nxopen.BasePart.SaveComponents.FalseValue,
self.nxopen.BasePart.CloseAfterSave.FalseValue,
)
self._undo_marks.clear()
@@ -232,18 +253,42 @@ def _close_part(self, save: bool = True) -> dict[str, Any]:
return {"message": f"Closed part: {part_name}"}
def _export_step(self, path: str) -> dict[str, Any]:
- self._work_part()
+ part = self._work_part()
destination = self.workspace.ensure_inside(path)
destination.parent.mkdir(parents=True, exist_ok=True)
+ self._save_part()
builder = self.session.DexManager.CreateStepCreator()
try:
+ builder.SettingsFile = str(
+ Path(os.environ.get("UGII_BASE_DIR", r"C:\Program Files\Siemens\Designcenter2606"))
+ / "STEP214UG"
+ / "ugstep214.def"
+ )
+ builder.LayerMask = "1-256"
+ builder.InputFile = part.FullPath
+ builder.ExportFrom = self.nxopen.StepCreator.ExportFromOption.ExistingPart
+ builder.ObjectTypes.Solids = True
+ builder.ObjectTypes.Surfaces = True
+ builder.ObjectTypes.Curves = False
builder.OutputFile = str(destination)
+ builder.ProcessHoldFlag = True
builder.Commit()
finally:
builder.Destroy()
+ if not destination.is_file() or destination.stat().st_size == 0:
+ log = destination.with_suffix(".log")
+ detail = (
+ log.read_text(errors="replace")[-1200:] if log.is_file() else "No translator log"
+ )
+ raise NXToolError("NX_EXPORT_FAILED", "STEP output was not created: " + detail)
+ payload = destination.read_text(errors="replace")
+ if not any(
+ token in payload for token in ("MANIFOLD_SOLID_BREP", "BREP_WITH_VOIDS", "FACETED_BREP")
+ ):
+ raise NXToolError("NX_EXPORT_NO_SOLIDS", "STEP file contains no solid BREP entities")
return {
"path": str(destination),
- "message": f"Exported STEP: {destination.name}",
+ "message": f"Exported and verified STEP file: {destination.name}",
}
def _create_sketch(self, plane: str = "XY", name: str | None = None) -> dict[str, Any]:
@@ -422,6 +467,483 @@ def _fit_view(self) -> dict[str, Any]:
part.ModelingViews.WorkView.Fit()
return {"message": "View fitted"}
+ def _find_sketch(self, name: str) -> Any:
+ part = self._work_part()
+ for sketch in part.Sketches:
+ sketch_name = self._name(sketch, "Sketch")
+ if sketch_name == name or sketch_name.endswith(name):
+ return sketch
+ raise NXToolError("NX_NOT_FOUND", f"Sketch not found: {name}")
+
+ def _revolve(
+ self,
+ angle: float = 360.0,
+ axis: str = "Z",
+ sketch_name: str | None = None,
+ boolean: str = "none",
+ axis_origin: list[float] | None = None,
+ axis_direction: list[float] | None = None,
+ ) -> dict[str, Any]:
+ if angle <= 0 or angle > 360:
+ raise NXToolError("NX_INVALID_ARGUMENT", "angle must be greater than 0 and at most 360")
+ if not sketch_name:
+ raise NXToolError("NX_INVALID_ARGUMENT", "sketch_name is required")
+ vectors = {
+ "X": (1.0, 0.0, 0.0),
+ "Y": (0.0, 1.0, 0.0),
+ "Z": (0.0, 0.0, 1.0),
+ "-X": (-1.0, 0.0, 0.0),
+ "-Y": (0.0, -1.0, 0.0),
+ "-Z": (0.0, 0.0, -1.0),
+ }
+ axis_key = axis.strip().upper()
+ if axis_key not in vectors:
+ raise NXToolError("NX_INVALID_ARGUMENT", "axis must be X, Y, Z, -X, -Y, or -Z")
+ from nx_mcp.hardened import vector as point_vector
+ from nx_mcp.visual_tools import unit_normal
+
+ if (axis_origin is None) != (axis_direction is None):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Supply both axis_origin and axis_direction")
+ if axis_direction is not None and axis != "Z":
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Custom axis cannot be combined with a nondefault principal axis",
+ )
+ axis_point = (
+ point_vector(axis_origin, "axis_origin") if axis_origin is not None else [0.0, 0.0, 0.0]
+ )
+ axis_vector = (
+ unit_normal(axis_direction, "axis_direction")
+ if axis_direction is not None
+ else vectors[axis_key]
+ )
+ boolean_types = {
+ "none": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Create,
+ "unite": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Unite,
+ "subtract": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Subtract,
+ "intersect": self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Intersect,
+ }
+ boolean_key = boolean.strip().lower()
+ if boolean_key not in boolean_types:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "boolean must be none, unite, subtract, or intersect"
+ )
+ part = self._work_part()
+ sketch = self._find_sketch(sketch_name)
+ section = part.Sections.CreateSection()
+ rule = part.ScRuleFactory.CreateRuleCurveFeature(
+ [sketch.Feature],
+ self.nxopen.DisplayableObject.Null,
+ part.ScRuleFactory.CreateRuleOptions(),
+ )
+ section.AddToSection(
+ [rule],
+ self.nxopen.NXObject.Null,
+ self.nxopen.NXObject.Null,
+ self.nxopen.NXObject.Null,
+ self.nxopen.Point3d(0.0, 0.0, 0.0),
+ self.nxopen.Section.Mode.Create,
+ False,
+ )
+ vector = self.nxopen.Vector3d(*axis_vector)
+ origin = part.Points.CreatePoint(self.nxopen.Point3d(*axis_point))
+ direction = part.Directions.CreateDirection(origin, vector)
+ revolve_axis = part.Axes.CreateAxis(
+ origin,
+ direction,
+ self.nxopen.SmartObject.UpdateOption.WithinModeling,
+ )
+ builder = part.Features.CreateRevolveBuilder(self.nxopen.Features.Feature.Null)
+ try:
+ builder.Section = section
+ builder.Axis = revolve_axis
+ builder.Limits.StartExtend.Value.RightHandSide = "0"
+ builder.Limits.EndExtend.Value.RightHandSide = str(angle)
+ builder.BooleanOperation.Type = boolean_types[boolean_key]
+ if boolean_key != "none":
+ bodies = list(part.Bodies)
+ if not bodies:
+ raise NXToolError("NX_NO_TARGET_BODY", "No target body is available")
+ builder.BooleanOperation.SetTargetBodies(bodies)
+ feature = builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ return {
+ "feature": self._reference(feature, "feature", part, "Revolve"),
+ "angle": angle,
+ "axis": axis_key if axis_direction is None else "custom",
+ "axis_origin": list(axis_point),
+ "axis_direction": list(axis_vector),
+ "message": f"Revolved {self._name(sketch, sketch_name)} by {angle} degrees",
+ }
+
+ def _sketch_arc_legacy(
+ self,
+ cx: float,
+ cy: float,
+ radius: float,
+ start_angle: float,
+ end_angle: float,
+ ) -> dict[str, Any]:
+ import math
+
+ if radius <= 0:
+ raise NXToolError("NX_INVALID_ARGUMENT", "radius must be greater than zero")
+ if start_angle == end_angle:
+ raise NXToolError("NX_INVALID_ARGUMENT", "start_angle and end_angle must differ")
+ part = self._work_part()
+ arc = part.Curves.CreateArc(
+ self.nxopen.Point3d(cx, cy, 0.0),
+ self.nxopen.Vector3d(1.0, 0.0, 0.0),
+ self.nxopen.Vector3d(0.0, 1.0, 0.0),
+ radius,
+ math.radians(start_angle),
+ math.radians(end_angle),
+ )
+ sketch = self.session.ActiveSketch
+ if sketch is None:
+ raise NXToolError("NX_NO_ACTIVE_SKETCH", "Create and activate an XY sketch first")
+ sketch.AddGeometry(arc, self.nxopen.Sketch.InferConstraintsOption.InferNoConstraints)
+ reference = self._reference(arc, "curve", part, "Arc")
+ return {
+ "object": reference,
+ "center": [cx, cy],
+ "radius": radius,
+ "start_angle": start_angle,
+ "end_angle": end_angle,
+ "message": "Created arc",
+ }
+
+ def _hole(
+ self,
+ diameter: float,
+ depth: float,
+ x: float,
+ y: float,
+ z: float,
+ ) -> dict[str, Any]:
+ if diameter <= 0 or depth <= 0:
+ raise NXToolError("NX_INVALID_ARGUMENT", "diameter and depth must be greater than zero")
+ part = self._work_part()
+ bodies = list(part.Bodies)
+ if not bodies:
+ raise NXToolError("NX_NO_TARGET_BODY", "Create a solid body before creating a hole")
+ builder = part.Features.CreateCylinderBuilder(self.nxopen.Features.Feature.Null)
+ try:
+ builder.Origin = self.nxopen.Point3d(x, y, z)
+ builder.Direction = self.nxopen.Vector3d(0.0, 0.0, 1.0)
+ builder.Diameter.RightHandSide = str(diameter)
+ builder.Height.RightHandSide = str(depth)
+ builder.BooleanOption.Type = (
+ self.nxopen.GeometricUtilities.BooleanOperation.BooleanType.Subtract
+ )
+ builder.BooleanOption.SetTargetBodies([bodies[0]])
+ feature = builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ return {
+ "feature": self._reference(feature, "feature", part, "Hole"),
+ "diameter": diameter,
+ "depth": depth,
+ "location": [x, y, z],
+ "message": "Created cylindrical hole along +Z",
+ }
+
+ def _resolve_body(self, value: str, part: Any) -> Any:
+ try:
+ return self.objects.resolve(
+ value,
+ expected_kind="body",
+ part_id=self._part_id(part),
+ )
+ except NXToolError:
+ pass
+ bodies = list(part.Bodies)
+ for index, body in enumerate(bodies, start=1):
+ names = {
+ self._name(body, f"body_{index}"),
+ f"body_{index}",
+ str(getattr(body, "JournalIdentifier", "")),
+ }
+ if value in names:
+ return body
+ raise NXToolError("NX_NOT_FOUND", f"Body not found: {value}")
+
+ def _boolean(self, boolean_type: str, targets: list[str]) -> dict[str, Any]:
+ type_map = {
+ "unite": self.nxopen.Features.FeatureBooleanType.Unite,
+ "subtract": self.nxopen.Features.FeatureBooleanType.Subtract,
+ "intersect": self.nxopen.Features.FeatureBooleanType.Intersect,
+ }
+ key = boolean_type.strip().lower()
+ if key not in type_map:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "boolean_type must be unite, subtract, or intersect"
+ )
+ if len(targets) < 2:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "targets must contain the target body followed by at least one tool body",
+ )
+ part = self._work_part()
+ bodies = [self._resolve_body(value, part) for value in targets]
+ builder = part.Features.CreateBooleanBuilder(self.nxopen.Features.BooleanFeature.Null)
+ try:
+ builder.Operation = type_map[key]
+ builder.Target = bodies[0]
+ builder.Tools.Add(bodies[1:])
+ feature = builder.CommitFeature()
+ finally:
+ builder.Destroy()
+ return {
+ "feature": self._reference(feature, "feature", part, "Boolean"),
+ "boolean_type": key,
+ "targets": targets,
+ "message": f"Boolean {key} completed",
+ }
+
+ def _get_bounding_box(self, body=None):
+ import NXOpen.UF
+
+ part = self._work_part()
+ bodies = [self._resolve_body(body, part)] if body else list(part.Bodies)
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ rows = []
+ for item in bodies:
+ box = list(uf.ModlGeneral.AskBoundingBox(item.Tag))
+ rows.append(
+ {
+ "body": self._reference(item, "body", part, "Body"),
+ "solid": bool(item.IsSolidBody),
+ "box": box,
+ }
+ )
+ if not rows:
+ raise NXToolError("NX_NO_TARGET_BODY", "No bodies in work part")
+ low = [min(row["box"][i] for row in rows) for i in range(3)]
+ high = [max(row["box"][i + 3] for row in rows) for i in range(3)]
+ return {
+ "min": low,
+ "max": high,
+ "dimensions": [b - a for a, b in zip(low, high, strict=False)],
+ "units": str(part.PartUnits),
+ "bodies": rows,
+ "message": "UF bounding boxes; may be conservative for curved geometry",
+ }
+
+ def _measure_volume(self, body=None):
+ part = self._work_part()
+ bodies = [self._resolve_body(body, part)] if body else list(part.Bodies)
+ units = [
+ part.UnitCollection.FindObject(n)
+ for n in ["SquareMilliMeter", "CubicMilliMeter", "Kilogram", "MilliMeter", "Newton"]
+ ]
+ rows = []
+ for item in bodies:
+ if not item.IsSolidBody:
+ raise NXToolError("NX_NOT_SOLID", "Volume measurement requires solid bodies")
+ props = part.MeasureManager.NewMassProperties(units, 0.999, [item])
+ try:
+ props.InformationUnit = self.nxopen.MeasureBodies.AnalysisUnit.KilogramMillimeter
+ rows.append(
+ {
+ "body": self._reference(item, "body", part, "Body"),
+ "volume_mm3": float(props.Volume),
+ }
+ )
+ finally:
+ props.Dispose()
+ return {
+ "bodies": rows,
+ "volume_mm3": sum(r["volume_mm3"] for r in rows),
+ "message": "Sum of solid body volumes; overlapping bodies are counted separately",
+ }
+
+ def _add_component(self, part_path, name=None):
+ part = self._work_part()
+ path = self.workspace.ensure_inside(part_path)
+ matrix = self.nxopen.Matrix3x3()
+ matrix.Xx = matrix.Yy = matrix.Zz = 1.0
+ component, status = part.ComponentAssembly.AddComponent(
+ str(path),
+ "Entire Part",
+ name or Path(path).stem,
+ self.nxopen.Point3d(0.0, 0.0, 0.0),
+ matrix,
+ -1,
+ )
+ try:
+ return {
+ "component": component.Name,
+ "tag": int(component.Tag),
+ "path": str(path),
+ "message": "Component added at origin",
+ }
+ finally:
+ if status is not None:
+ status.Dispose()
+
+ def _list_components(self):
+ part = self._work_part()
+ root = part.ComponentAssembly.RootComponent
+ rows = []
+
+ def walk(parent, depth):
+ for comp in parent.GetChildren():
+ point, matrix = comp.GetPosition()
+ rows.append(
+ {
+ "name": comp.Name,
+ "tag": int(comp.Tag),
+ "depth": depth,
+ "translation": [point.X, point.Y, point.Z],
+ "rotation": [
+ matrix.Xx,
+ matrix.Xy,
+ matrix.Xz,
+ matrix.Yx,
+ matrix.Yy,
+ matrix.Yz,
+ matrix.Zx,
+ matrix.Zy,
+ matrix.Zz,
+ ],
+ "part_path": comp.Prototype.FullPath,
+ }
+ )
+ walk(comp, depth + 1)
+
+ if root is not None:
+ walk(root, 0)
+ return {"components": rows, "count": len(rows)}
+
+ def _reposition_component(self, component, dx=0, dy=0, dz=0, rx=0, ry=0, rz=0):
+ import math
+
+ part = self._work_part()
+ root = part.ComponentAssembly.RootComponent
+ matches = [c for c in root.GetChildren() if c.Name == component]
+ if len(matches) != 1:
+ raise NXToolError(
+ "NX_NOT_FOUND", "Component name must match exactly one immediate child"
+ )
+ a, b, c = [math.radians(v) for v in (rx, ry, rz)]
+ sx, cx, sy, cy, sz, cz = (
+ math.sin(a),
+ math.cos(a),
+ math.sin(b),
+ math.cos(b),
+ math.sin(c),
+ math.cos(c),
+ )
+ matrix = self.nxopen.Matrix3x3()
+ values = [
+ cz * cy,
+ sz * cy,
+ -sy,
+ cz * sy * sx - sz * cx,
+ sz * sy * sx + cz * cx,
+ cy * sx,
+ cz * sy * cx + sz * sx,
+ sz * sy * cx - cz * sx,
+ cy * cx,
+ ]
+ for key, value in zip(
+ ("Xx", "Xy", "Xz", "Yx", "Yy", "Yz", "Zx", "Zy", "Zz"), values, strict=False
+ ):
+ setattr(matrix, key, value)
+ part.ComponentAssembly.MoveComponent(matches[0], self.nxopen.Vector3d(dx, dy, dz), matrix)
+ return {"component": component, "message": "Applied relative translation and rotation"}
+
+ def _set_view(self, orientation):
+ options = {
+ "isometric": "Isometric",
+ "trimetric": "Trimetric",
+ "front": "Front",
+ "back": "Back",
+ "top": "Top",
+ "bottom": "Bottom",
+ "left": "Left",
+ "right": "Right",
+ }
+ key = orientation.strip().lower()
+ if key not in options:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown view orientation")
+ self._work_part().ModelingViews.WorkView.Orient(
+ getattr(self.nxopen.View.Canned, options[key]), self.nxopen.View.ScaleAdjustment.Fit
+ )
+ return {
+ "message": "View orientation set",
+ "orientation": key,
+ "viewport_available": not self.session.IsBatch,
+ }
+
+ def _get_feature_info(self, name):
+ part = self._work_part()
+ try:
+ feature = self.objects.resolve(
+ name, expected_kind="feature", part_id=self._part_id(part)
+ )
+ except NXToolError:
+ matches = [f for f in part.Features if f.Name == name or f.JournalIdentifier == name]
+ if len(matches) != 1:
+ raise NXToolError(
+ "NX_NOT_FOUND", "Feature reference is not unique or does not exist"
+ ) from None
+ feature = matches[0]
+ return {
+ "name": feature.Name,
+ "type": feature.FeatureType,
+ "identifier": feature.JournalIdentifier,
+ "expressions": [
+ {"name": e.Name, "formula": e.RightHandSide} for e in feature.GetExpressions()
+ ],
+ }
+
+ def _list_open_parts(self):
+ return {"parts": [{"name": p.Name, "path": p.FullPath} for p in self.session.Parts]}
+
+ def _screenshot(self, path: str) -> dict[str, Any]:
+ destination = self.workspace.ensure_inside(path)
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ script = r"""
+$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Windows.Forms
+Add-Type -AssemblyName System.Drawing
+$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
+$bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height)
+$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
+try {
+ $graphics.CopyFromScreen($bounds.Left, $bounds.Top, 0, 0, $bounds.Size)
+ $bitmap.Save($env:NX_MCP_SCREENSHOT_PATH, [System.Drawing.Imaging.ImageFormat]::Png)
+} finally {
+ $graphics.Dispose()
+ $bitmap.Dispose()
+}
+"""
+ encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii")
+ environment = dict(os.environ)
+ environment["NX_MCP_SCREENSHOT_PATH"] = str(destination)
+ completed = subprocess.run(
+ [
+ "powershell.exe",
+ "-NoProfile",
+ "-NonInteractive",
+ "-EncodedCommand",
+ encoded,
+ ],
+ capture_output=True,
+ check=False,
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
+ env=environment,
+ text=True,
+ timeout=30,
+ )
+ if completed.returncode != 0 or not destination.is_file():
+ message = (completed.stderr or completed.stdout or "screen capture failed").strip()
+ raise NXToolError("NX_SCREENSHOT_FAILED", message)
+ return {"path": str(destination), "message": f"Screenshot saved to {destination.name}"}
+
@dataclass
class BridgeRuntime:
@@ -480,7 +1002,9 @@ def start_bridge(
import NXOpen
session = NXOpen.Session.GetSession()
- executor = NXOpenExecutor(
+ from nx_mcp.hardened import HardenedExecutor
+
+ executor = HardenedExecutor(
session,
NXOpen,
_detect_nx_version(session),
@@ -490,7 +1014,11 @@ def start_bridge(
)
token = secrets.token_hex(32)
dispatcher = MainThreadDispatcher(executor.execute)
- server = BridgeServer(dispatcher.call, token=token)
+ server = BridgeServer(
+ dispatcher.call,
+ token=token,
+ result_directory=Path(workspace_root) / ".nx-mcp" / "bridge-results",
+ )
server.start()
descriptor = BridgeDescriptor.create(
server.port,
diff --git a/src/nx_mcp/output_schemas.py b/src/nx_mcp/output_schemas.py
new file mode 100644
index 0000000..360bd1d
--- /dev/null
+++ b/src/nx_mcp/output_schemas.py
@@ -0,0 +1,640 @@
+"""JSON Schema payload contracts for the integration surface.
+
+Schemas describe committed results; they do not run a second validation step after
+an NX mutation. Errors have a separate branch so success cardinality requirements
+never hide a useful kernel error. Additive native metadata remains permitted.
+"""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from typing import Any
+
+S = {"type": "string"}
+N = {"type": "number"}
+COUNT = {"type": "integer", "minimum": 0}
+B = {"type": "boolean"}
+NULL = {"type": "null"}
+OUTCOMES = [
+ "not_started",
+ "running",
+ "committed",
+ "rolled_back",
+ "partial",
+ "unknown",
+ "not_applicable",
+]
+
+
+def arr(item: dict, count: int | None = None) -> dict:
+ result: dict[str, Any] = {"type": "array", "items": item}
+ if count is not None:
+ result.update(minItems=count, maxItems=count)
+ return result
+
+
+def obj(properties: dict, required: list[str] | None = None) -> dict:
+ return {
+ "type": "object",
+ "properties": properties,
+ "required": list(properties) if required is None else required,
+ "additionalProperties": True,
+ }
+
+
+VEC = arr(N, 3)
+REF = obj(
+ {
+ "id": S,
+ "kind": S,
+ "name": S,
+ "part_id": S,
+ "session_id": S,
+ "generation_id": S,
+ "owner_part_path": S,
+ "journal_id": S,
+ "display_name": S,
+ },
+ ["id", "kind", "name", "part_id"],
+)
+REF["description"] = (
+ "Opaque identity, separate from display name. Reacquire after close, rollback or manual handoff; use occurrence context for assemblies."
+)
+HASH = {"type": "string", "pattern": "^[0-9a-f]{64}$"}
+META = {"path": S, "size": COUNT, "sha256": HASH}
+PAIR = obj(
+ {
+ "objects": arr(REF, 2),
+ "distance": N,
+ "closest_points": arr(VEC, 2),
+ "classification": {"enum": ["clear", "contact", "penetration", "below_clearance"]},
+ "interference_volume_mm3": N,
+ }
+)
+
+# Required fields follow actual bridge implementations; optional metadata remains typed.
+PAYLOADS: dict[str, dict[str, Any]] = {
+ "nx_get_bounding_box": obj(
+ {
+ "min": VEC,
+ "max": VEC,
+ "dimensions": VEC,
+ "coordinate_frame": S,
+ "bounds_type": {"enum": ["exact", "conservative"]},
+ "body_count": COUNT,
+ "scope": S,
+ "bodies": arr(obj({"body": REF, "box": arr(N, 6), "solid": B})),
+ }
+ ),
+ "nx_measure_distance": obj(
+ {
+ "distance": N,
+ "closest_points": arr(VEC, 2),
+ "references": arr(S, 2),
+ "resolved_tags": arr(COUNT, 2),
+ "coordinate_frame": S,
+ "pair_count": COUNT,
+ "method": S,
+ "accuracy": NULL,
+ }
+ ),
+ "nx_measure_volume": obj(
+ {
+ "volume_mm3": N,
+ "volume_units": {"const": "mm^3"},
+ "semantics": {"const": "sum_of_included_bodies"},
+ "body_count": COUNT,
+ "scope": S,
+ "bodies": arr(obj({"body": REF, "volume_mm3": N})),
+ }
+ ),
+ "nx_list_topology": obj({"body": REF, "faces": arr(REF), "edges": arr(REF), "solid": B}),
+ "nx_list_components": obj(
+ {
+ "count": COUNT,
+ "matrix_convention": S,
+ "components": arr(
+ obj(
+ {
+ "object": REF,
+ "name": S,
+ "part_path": S,
+ "depth": COUNT,
+ "translation": VEC,
+ "rotation_matrix": arr(VEC, 3),
+ "coordinate_frame": S,
+ "suppressed": B,
+ "reference_set": S,
+ }
+ )
+ ),
+ }
+ ),
+ "nx_operation_status": obj(
+ {
+ "operation_id": S,
+ "state": {"enum": ["running", "committed", "failed", "unknown", "rolled_back"]},
+ "mutation_outcome": {"enum": OUTCOMES},
+ "method": S,
+ "reason": S,
+ "session_id": S,
+ "started_at": S,
+ "finished_at": S,
+ "result": {"type": "object"},
+ "error": {"type": "object"},
+ "reverted_by": S,
+ "query_operation_id": S,
+ "query_session_id": S,
+ },
+ ["operation_id", "state", "mutation_outcome"],
+ ),
+ "nx_checkpoint": obj({"checkpoint_id": S, "message": S}),
+ "nx_rollback": obj({"checkpoint_id": S, "message": S}),
+ "nx_checkpoint_state": obj(
+ {
+ "checkpoints": arr(
+ obj({"checkpoint_id": S, "part_id": S, "index": COUNT, "label": S, "available": B})
+ ),
+ "undo_depth": COUNT,
+ "save_semantics": S,
+ "retention": S,
+ }
+ ),
+ "nx_workspace_list": obj(
+ {
+ "path": S,
+ "count": COUNT,
+ "total_count": COUNT,
+ "offset": COUNT,
+ "next_offset": {"anyOf": [COUNT, NULL]},
+ "entries": arr(
+ {
+ "oneOf": [
+ obj({**META, "kind": {"const": "file"}}),
+ obj({"path": S, "kind": {"const": "directory"}}),
+ ]
+ }
+ ),
+ }
+ ),
+ "nx_download_file": {
+ "oneOf": [
+ obj({**META, "delivery": {"const": "metadata"}}),
+ obj(
+ {
+ **META,
+ "delivery": {"const": "image"},
+ "mime_type": {"const": "image/png"},
+ "resolution": arr(COUNT, 2),
+ }
+ ),
+ obj(
+ {
+ **META,
+ "offset": COUNT,
+ "bytes_returned": COUNT,
+ "next_offset": {"anyOf": [COUNT, NULL]},
+ "data_base64": S,
+ "eof": B,
+ }
+ ),
+ ]
+ },
+}
+for name in ("nx_check_interference", "nx_check_clearance"):
+ PAYLOADS[name] = obj(
+ {
+ "pairs": arr(PAIR),
+ "counts": {"type": "object", "additionalProperties": COUNT},
+ "body_count": COUNT,
+ "pair_count": COUNT,
+ "reported_pair_count": COUNT,
+ "broad_phase_clear_pairs": COUNT,
+ "minimum_clearance": N,
+ "volume_units": {"const": "mm^3"},
+ "coordinate_frame": S,
+ "complete": B,
+ "semantics": S,
+ }
+ )
+for name in ("nx_extrude", "nx_pattern"):
+ PAYLOADS[name] = obj(
+ {"feature": REF, "body": {"anyOf": [REF, NULL]}, "bodies": arr(REF), "body_count": COUNT},
+ ["feature", "bodies"],
+ )
+PAYLOADS["nx_revolve"] = obj({"feature": REF, "angle": N, "axis": S})
+for name in (
+ "nx_screenshot",
+ "nx_render_view",
+ "nx_export_step",
+ "nx_export_drawing_pdf",
+ "nx_export_flat_pattern",
+ "nx_package_assembly",
+):
+ PAYLOADS[name] = obj({**META, "mime_type": S, "resolution": arr(COUNT, 2)}, ["path", "sha256"])
+
+
+# Lifecycle and authoring results use their actual native field names.
+INTEGER = {"type": "integer"}
+PAGE = {
+ "count": COUNT,
+ "total_count": COUNT,
+ "offset": COUNT,
+ "next_offset": {"anyOf": [COUNT, NULL]},
+}
+EXPRESSION = obj(
+ {
+ "object": REF,
+ "name": S,
+ "formula": S,
+ "type": S,
+ "value": {"anyOf": [N, NULL]},
+ "units": S,
+ "editable": B,
+ "parents": arr(REF),
+ "dependents": arr(REF),
+ "dependency_scope": S,
+ }
+)
+PAYLOADS.update(
+ {
+ "nx_create_part": obj({"part": REF, "message": S}),
+ "nx_open_part": obj(
+ {"part": REF, "work": B, "display": B, "already_loaded": B, "path": S, "message": S}
+ ),
+ "nx_activate_part": obj({"part": REF, "work": B, "display": B, "message": S}),
+ "nx_save_as": obj({"part": REF, "path": S, "message": S}),
+ "nx_save_part": obj(
+ {
+ "path": S,
+ "message": S,
+ "recovery": PAYLOADS["nx_checkpoint_state"],
+ "requested_file": S,
+ "saved_files": arr(S),
+ "observed_changed_files": arr(S),
+ "save_scope": S,
+ "verification_scope": S,
+ "verified_part_count": COUNT,
+ "target_file": {"type": "object"},
+ "unrelated_modified_parts": arr({"type": "object"}),
+ "unexpected_changes": arr({"type": "object"}),
+ "unexpected_new_parts": arr({"type": "object"}),
+ "native_save_errors": arr({"type": "object"}),
+ }
+ ),
+ "nx_close_part": obj(
+ {
+ "closed_parts": arr(REF),
+ "closed_count": COUNT,
+ "remaining_count": COUNT,
+ "message": S,
+ }
+ ),
+ "nx_list_open_parts": obj(
+ {
+ "parts": arr(
+ obj({"part": REF, "name": S, "path": S, "work": B, "display": B, "modified": B})
+ ),
+ **PAGE,
+ }
+ ),
+ "nx_sketch_info": obj(
+ {
+ "object": REF,
+ "frame": obj(
+ {
+ "origin": VEC,
+ "x_axis": VEC,
+ "y_axis": VEC,
+ "normal": VEC,
+ "coordinate_frame": S,
+ }
+ ),
+ "curves": arr(obj({"object": REF, "type": S})),
+ "curve_count": COUNT,
+ }
+ ),
+ "nx_sketch_diagnostics": obj(
+ {
+ "sketch": REF,
+ "solver_status": S,
+ "remaining_degrees_of_freedom": {"anyOf": [INTEGER, NULL]},
+ "native_dof_value": INTEGER,
+ "constraints": arr(
+ obj({"object": REF, "type": S, "expression": EXPRESSION}, ["object", "type"])
+ ),
+ "constraint_count": COUNT,
+ "geometry": arr(obj({"object": REF, "constraints": arr(S)})),
+ "geometry_count": COUNT,
+ "evaluation": S,
+ "conflicting_constraints": NULL,
+ "work_region_handling": S,
+ }
+ ),
+ "nx_sheet_metal_feature": obj(
+ {
+ "feature": REF,
+ "bodies": arr(REF),
+ "body_count": COUNT,
+ "body": {"anyOf": [REF, NULL]},
+ "coordinate_frame": S,
+ "created": arr(REF),
+ "modified": arr(REF),
+ "operation": S,
+ "native_feature_type": S,
+ "requested_parameters": {"type": "object"},
+ "expressions": arr(EXPRESSION),
+ "model_view_name": S,
+ },
+ [
+ "feature",
+ "bodies",
+ "body_count",
+ "body",
+ "coordinate_frame",
+ "created",
+ "modified",
+ "operation",
+ "native_feature_type",
+ "requested_parameters",
+ "expressions",
+ ],
+ ),
+ "nx_sheet_metal_info": obj(
+ {
+ "items": arr(
+ obj(
+ {
+ "body": REF,
+ "sheet_metal": B,
+ "thickness": N,
+ "bends": arr(
+ obj(
+ {
+ "face": REF,
+ "state": S,
+ "inner_radius": N,
+ "angle_degrees": N,
+ "neutral_factor": N,
+ }
+ )
+ ),
+ "bend_count": COUNT,
+ },
+ ["body", "sheet_metal"],
+ )
+ ),
+ "total": COUNT,
+ "offset": COUNT,
+ "next_offset": {"anyOf": [COUNT, NULL]},
+ "coordinate_frame": S,
+ }
+ ),
+ "nx_list_drawings": obj(
+ {
+ "sheets": arr(
+ obj(
+ {
+ "object": REF,
+ "name": S,
+ "active": B,
+ "width": N,
+ "height": N,
+ "units": S,
+ "scale": arr(N, 2),
+ "views": arr(obj({"object": REF, "name": S})),
+ }
+ )
+ ),
+ "units": {"const": "per_sheet"},
+ "coordinate_frame": {"const": "drawing_sheet"},
+ "modeling_active": B,
+ }
+ ),
+ "nx_drawing_view_info": obj(
+ {
+ "object": REF,
+ "drawing": REF,
+ "native_type": S,
+ "position": arr(N, 2),
+ "scale": N,
+ "bounds": arr(N, 4),
+ "inside_sheet": B,
+ "units": S,
+ "coordinate_frame": S,
+ "bounds_semantics": S,
+ }
+ ),
+ }
+)
+PAYLOADS["nx_activate_drawing"] = deepcopy(PAYLOADS["nx_list_drawings"])
+PAYLOADS["nx_edit_drawing_view"] = deepcopy(PAYLOADS["nx_drawing_view_info"])
+component_fields = PAYLOADS["nx_list_components"]["properties"]["components"]["items"]
+component_fields["properties"].update(
+ part_path={"anyOf": [S, NULL]}, load_state=S, prototype_type={"anyOf": [S, NULL]}
+)
+component_fields["required"] = [
+ x
+ for x in component_fields["required"]
+ if x not in {"translation", "rotation_matrix", "coordinate_frame"}
+]
+PAYLOADS["nx_list_components"]["properties"].update(PAGE)
+
+
+REFERENCE_SET = obj(
+ {
+ "object": REF,
+ "name": S,
+ "member_count": COUNT,
+ "members": arr(REF),
+ "add_components_automatically": B,
+ }
+)
+DISPLAY_ROW = obj({"object": REF, "blanked": B})
+PAYLOADS.update(
+ {
+ "nx_create_reference_set": REFERENCE_SET,
+ "nx_list_reference_sets": obj(
+ {"reference_sets": arr(REFERENCE_SET), "count": COUNT, "built_in": arr(S)}
+ ),
+ "nx_set_component_reference_set": obj(
+ {
+ "components": arr(
+ obj({"object": REF, "previous_reference_set": S, "reference_set": S})
+ ),
+ "count": COUNT,
+ "prototype_parts_modified": {"const": False},
+ }
+ ),
+ "nx_list_datums": obj(
+ {
+ "datums": arr(obj({"object": REF, "native_type": S, "blanked": B})),
+ "count": COUNT,
+ "scope": {"const": "work_part"},
+ }
+ ),
+ "nx_set_datum_visibility": obj(
+ {
+ "restore_id": S,
+ "objects": arr(DISPLAY_ROW),
+ "count": COUNT,
+ "visible": B,
+ "scope": {"const": "work_part"},
+ }
+ ),
+ "nx_flat_pattern_orientation_edges": obj(
+ {
+ "upward_face": REF,
+ "edges": arr(
+ obj({"edge": REF, "start": VEC, "end": VEC, "adjacent_faces": arr(S)})
+ ),
+ "count": COUNT,
+ "coordinate_frame": S,
+ "eligibility": S,
+ }
+ ),
+ "nx_create_drawing": obj(
+ {
+ "object": REF,
+ "sheet_name": S,
+ "size": S,
+ "dimensions_mm": arr(N, 2),
+ "dimensions": arr(N, 2),
+ "scale": N,
+ "projection": S,
+ }
+ ),
+ "nx_list_dimensions": obj(
+ {
+ "dimensions": arr(
+ obj(
+ {
+ "object": REF,
+ "native_type": S,
+ "computed_value": N,
+ "retained": B,
+ "measurement_valid": B,
+ "origin": VEC,
+ }
+ )
+ ),
+ "coordinate_frame": S,
+ }
+ ),
+ "nx_sheet_metal_defaults": obj(
+ {
+ "parameters": {
+ "type": "object",
+ "additionalProperties": {"anyOf": [EXPRESSION, NULL]},
+ },
+ "parameter_entry": S,
+ "bend_definition": S,
+ "bend_table": S,
+ "bend_allowance_formula": S,
+ "bend_deduction_formula": S,
+ "material": S,
+ "tool": S,
+ "material_catalog_status": S,
+ }
+ ),
+ }
+)
+PAYLOADS["nx_set_sheet_metal_defaults"] = deepcopy(PAYLOADS["nx_sheet_metal_defaults"])
+
+
+PAYLOADS.update(
+ {
+ "nx_read_result": obj(
+ {
+ "result_id": S,
+ "field": S,
+ "value": {},
+ "total_count": {"anyOf": [COUNT, NULL]},
+ "offset": COUNT,
+ "next_offset": {"anyOf": [COUNT, NULL]},
+ "omitted": {"type": "object"},
+ }
+ ),
+ "nx_dimension_format": obj(
+ {
+ "object": REF,
+ "computed_value": N,
+ "measurement_units": S,
+ "measurement_valid": B,
+ "decimal_places": COUNT,
+ "tolerance_decimal_places": COUNT,
+ "upper_tolerance": N,
+ "lower_tolerance": N,
+ "tolerance_type": S,
+ "trailing_zeros": B,
+ "display_units": S,
+ "decimal_separator": S,
+ "association_count": COUNT,
+ }
+ ),
+ "nx_export_planar_dxf": obj(
+ {
+ "path": S,
+ "size": COUNT,
+ "sha256": S,
+ "units": {"const": "mm"},
+ "scale": {"const": 1.0},
+ "coordinate_frame": {"type": "object"},
+ "entity_count": COUNT,
+ "entity_counts": {"type": "object"},
+ "entities": arr({"type": "object"}),
+ "all_boundary_loops_included": B,
+ }
+ ),
+ }
+)
+PAYLOADS["nx_edit_dimension_format"] = deepcopy(PAYLOADS["nx_dimension_format"])
+
+
+def output_schema(name: str, common: dict) -> dict:
+ """Keep an object root for MCP; discriminate errors before success payloads."""
+ schema = deepcopy(common)
+ schema["title"] = name + " result"
+ schema["description"] = "Structured success/error envelope with additive metadata. " + (
+ "Tool-specific payload is typed."
+ if name in PAYLOADS
+ else "Tool-specific payload remains extensible."
+ )
+ schema["properties"].update(
+ {
+ "full_result": obj(
+ {
+ "id": S,
+ "sha256": S,
+ "size": COUNT,
+ "tool": {"const": "nx_read_result"},
+ "immutable": {"const": True},
+ }
+ ),
+ "omitted": {"type": "object"},
+ "code": S,
+ "message": S,
+ "retryable": B,
+ "suggestion": S,
+ "nx_code": {"type": ["integer", "string"]},
+ "details": obj({"operation_id": S, "mutation_outcome": {"enum": OUTCOMES}}, []),
+ }
+ )
+ schema["properties"]["mutation_outcome"] = {"anyOf": [{"enum": OUTCOMES}, NULL]}
+ schema["allOf"] = [
+ {
+ "if": {"properties": {"status": {"const": "error"}}},
+ "then": {"required": ["code", "message", "retryable"]},
+ }
+ ]
+ if name in PAYLOADS:
+ schema["allOf"].append(
+ {
+ "if": {
+ "properties": {"status": {"const": "success"}},
+ "not": {"required": ["full_result"]},
+ },
+ "then": deepcopy(PAYLOADS[name]),
+ }
+ )
+ return schema
diff --git a/src/nx_mcp/planar_dxf.py b/src/nx_mcp/planar_dxf.py
new file mode 100644
index 0000000..170d7db
--- /dev/null
+++ b/src/nx_mcp/planar_dxf.py
@@ -0,0 +1,221 @@
+"""Analytic planar DXF export from native NX curves, without model mutation."""
+
+import hashlib
+import math
+import re
+from collections import Counter
+
+from nx_mcp.runtime import NXToolError
+
+
+def dxf_text(entities):
+ """Write an ASCII DXF with explicit millimeter units and layer definitions."""
+ rows = []
+
+ def put(*pairs):
+ for code, value in pairs:
+ rows.extend(
+ (str(code), format(value, ".15g") if isinstance(value, float) else str(value))
+ )
+
+ put(
+ (0, "SECTION"),
+ (2, "HEADER"),
+ (9, "$ACADVER"),
+ (1, "AC1027"),
+ (9, "$INSUNITS"),
+ (70, 4),
+ (9, "$MEASUREMENT"),
+ (70, 1),
+ (0, "ENDSEC"),
+ )
+ layers = sorted({e["layer"] for e in entities} | {"0"})
+ put(
+ (0, "SECTION"),
+ (2, "TABLES"),
+ (0, "TABLE"),
+ (2, "LTYPE"),
+ (70, 1),
+ (0, "LTYPE"),
+ (2, "CONTINUOUS"),
+ (70, 0),
+ (3, "Solid line"),
+ (72, 65),
+ (73, 0),
+ (40, 0.0),
+ (0, "ENDTAB"),
+ (0, "TABLE"),
+ (2, "LAYER"),
+ (70, len(layers)),
+ )
+ for layer in layers:
+ put((0, "LAYER"), (2, layer), (70, 0), (62, 7), (6, "CONTINUOUS"))
+ put((0, "ENDTAB"), (0, "ENDSEC"), (0, "SECTION"), (2, "ENTITIES"))
+ for e in entities:
+ kind = e["type"]
+ put(
+ (0, kind),
+ (100, "AcDbEntity"),
+ (8, e["layer"]),
+ (100, "AcDbLine" if kind == "LINE" else "AcDbCircle"),
+ )
+ p = e["start"] if kind == "LINE" else e["center"]
+ put((10, p[0]), (20, p[1]), (30, 0.0))
+ if kind == "LINE":
+ put((11, e["end"][0]), (21, e["end"][1]), (31, 0.0))
+ else:
+ put((40, e["radius"]))
+ if kind == "ARC":
+ put((100, "AcDbArc"), (50, e["start_angle"]), (51, e["end_angle"]))
+ put((0, "ENDSEC"), (0, "EOF"))
+ return "\n".join(rows) + "\n"
+
+
+class PlanarDxfMixin:
+ def _export_planar_dxf(
+ self, source, path, origin=None, x_axis=None, y_axis=None, layer="OUTLINE", layers=None
+ ):
+ import NXOpen.UF as U
+
+ from nx_mcp.hardened import cross, dot, vector
+ from nx_mcp.visual_tools import unit_normal
+
+ destination = self.workspace.ensure_inside(path)
+ if destination.suffix.lower() != ".dxf" or destination.exists():
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Choose a new .dxf path; existing files are never overwritten",
+ details={"mutation_outcome": "not_started"},
+ )
+ layers = {} if layers is None else layers
+ for name in [layer, *layers.values()]:
+ if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", name):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "DXF layer names require 1..64 ASCII letters, digits, underscore, period or hyphen",
+ )
+ if (x_axis is None) != (y_axis is None):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Supply both output basis axes")
+ obj = self._resolve(source, {"sketch", "face"})
+ if obj.IsOccurrence or obj.OwningPart != self._work_part():
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH", "Select an owned sketch or face in the work part"
+ )
+ uf = U.UFSession.GetUFSession()
+ if isinstance(obj, self.nxopen.Sketch):
+ frame = self._sketch_frame(obj)
+ curves = list(obj.GetAllGeometry())
+ kind = "curve"
+ else:
+ typ, point, normal, _, _, _, _ = uf.Modeling.AskFaceData(obj.Tag)
+ if typ != 22:
+ raise NXToolError("NX_NON_PLANAR", "Select a planar face or planar sketch")
+ normal = unit_normal(normal)
+ seed = [1, 0, 0] if abs(normal[0]) < 0.9 else [0, 1, 0]
+ x = unit_normal([seed[i] - dot(seed, normal) * normal[i] for i in range(3)])
+ frame = {
+ "origin": list(point),
+ "x_axis": x,
+ "y_axis": cross(normal, x),
+ "normal": normal,
+ }
+ curves = list(obj.GetEdges())
+ kind = "edge"
+ if not curves or len(curves) > 20000:
+ raise NXToolError("NX_OBJECT_LIMIT", "Select 1..20000 analytic curves")
+ o = vector(origin, "origin") if origin is not None else frame["origin"]
+ x = vector(x_axis, "x_axis") if x_axis is not None else frame["x_axis"]
+ y = vector(y_axis, "y_axis") if y_axis is not None else frame["y_axis"]
+ if abs(dot(x, x) - 1) > 1e-8 or abs(dot(y, y) - 1) > 1e-8 or abs(dot(x, y)) > 1e-8:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Output basis must be orthonormal")
+ normal = cross(x, y)
+ if abs(abs(dot(normal, frame["normal"])) - 1) > 1e-8:
+ raise NXToolError("NX_NON_PLANAR", "Output basis must lie in the source plane")
+ factor = {"mm": 1.0, "inch": 25.4}[self._units()]
+
+ def point2(p):
+ delta = [p[i] - o[i] for i in range(3)]
+ if abs(dot(delta, normal)) * factor > 1e-6:
+ raise NXToolError(
+ "NX_NON_PLANAR", "Source geometry or origin is outside the output plane"
+ )
+ return [dot(delta, x) * factor, dot(delta, y) * factor]
+
+ entities = []
+ used = set()
+ for curve in curves:
+ ref = self._reference(curve, kind, self._work_part(), "DXF curve")
+ used.add(ref["id"])
+ evaluator = uf.Eval.Initialize2(curve.Tag)
+ limits = uf.Eval.AskLimits(evaluator)
+ start = point2(uf.Eval.EvaluateUnitVectors(evaluator, limits[0])[0])
+ end = point2(uf.Eval.EvaluateUnitVectors(evaluator, limits[1])[0])
+ e = {"source": ref, "layer": layers.get(ref["id"], layer)}
+ if uf.Eval.IsLine(evaluator):
+ e.update(type="LINE", start=start, end=end)
+ elif uf.Eval.IsArc(evaluator):
+ arc = uf.Eval.AskArc(evaluator)
+ center = point2(arc.Center)
+ direction = dot(cross(list(arc.XAxis), list(arc.YAxis)), normal)
+ if abs(abs(direction) - 1) > 1e-8:
+ raise NXToolError("NX_NON_PLANAR", "Circular curve is not in the output plane")
+ e.update(
+ type="CIRCLE"
+ if abs(abs(limits[1] - limits[0]) - 2 * math.pi) < 1e-8
+ else "ARC",
+ center=center,
+ radius=arc.Radius * factor,
+ )
+ if e["type"] == "ARC":
+ if direction < 0:
+ start, end = end, start
+ e.update(
+ start_angle=math.degrees(
+ math.atan2(start[1] - center[1], start[0] - center[0])
+ )
+ % 360,
+ end_angle=math.degrees(math.atan2(end[1] - center[1], end[0] - center[0]))
+ % 360,
+ )
+ else:
+ raise NXToolError(
+ "NX_UNSUPPORTED_GEOMETRY",
+ "Planar DXF preserves lines/arcs/circles only; unsupported curves are never approximated",
+ details={"object": ref, "mutation_outcome": "not_started"},
+ )
+ entities.append(e)
+ if set(layers) - used:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Layer overrides must reference curves or edges of the selected source",
+ )
+ raw = dxf_text(entities).encode("ascii")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ with destination.open("xb") as stream:
+ try:
+ stream.write(raw)
+ except OSError:
+ stream.close()
+ destination.unlink(missing_ok=True)
+ raise
+ return {
+ "path": str(destination),
+ "size": len(raw),
+ "sha256": hashlib.sha256(raw).hexdigest(),
+ "units": "mm",
+ "scale": 1.0,
+ "coordinate_frame": {
+ "origin": o,
+ "x_axis": x,
+ "y_axis": y,
+ "normal": normal,
+ "source_units": self._units(),
+ },
+ "entity_count": len(entities),
+ "entity_counts": dict(Counter(e["type"] for e in entities)),
+ "entities": entities,
+ "all_boundary_loops_included": True,
+ "warnings": [
+ "Exports the selected analytic geometry; does not certify loop validity or fabrication readiness."
+ ],
+ }
diff --git a/src/nx_mcp/real_smoke.py b/src/nx_mcp/real_smoke.py
index 9568deb..eb48994 100644
--- a/src/nx_mcp/real_smoke.py
+++ b/src/nx_mcp/real_smoke.py
@@ -61,9 +61,20 @@ async def run_iteration(
)
after = await _call(client, "nx_list_bodies", {})
await _call(client, "nx_fit_view", {})
- exported = await _call(client, "nx_export_step", {"path": step_path})
+ # STEP export saves the part and invalidates native undo marks. Exercise
+ # undo before that save boundary, then recreate the solid for export.
await _call(client, "nx_undo", {})
after_undo = await _call(client, "nx_list_bodies", {})
+ sketches = await _call(client, "nx_list_sketches", {})
+ sketch_id = next(
+ item["id"] for item in sketches["objects"] if item["name"] == sketch["object"]["name"]
+ )
+ extruded = await _call(
+ client,
+ "nx_extrude",
+ {"sketch_id": sketch_id, "distance": 12.5, "reverse": False},
+ )
+ exported = await _call(client, "nx_export_step", {"path": step_path})
await _call(client, "nx_save_part", {})
await _call(client, "nx_close_part", {"save": False})
except Exception:
diff --git a/src/nx_mcp/recovery.py b/src/nx_mcp/recovery.py
new file mode 100644
index 0000000..e709b0d
--- /dev/null
+++ b/src/nx_mcp/recovery.py
@@ -0,0 +1,74 @@
+"""Durable request receipts. No NXOpen calls; safe for sidecar status queries."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+from datetime import datetime, timezone
+from pathlib import Path
+
+from nx_mcp.runtime import NXToolError
+
+
+def timestamp():
+ return datetime.now(timezone.utc).isoformat()
+
+
+class OperationStore:
+ def __init__(self, root):
+ self.root = Path(root) / ".nx-mcp" / "operations"
+ self.root.mkdir(parents=True, exist_ok=True)
+
+ def path(self, operation_id):
+ if not isinstance(operation_id, str) or not re.fullmatch(
+ r"[A-Za-z0-9_-]{8,128}", operation_id
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "operation_id must be 8–128 ASCII letters, digits, underscores or hyphens",
+ )
+ return self.root / (operation_id + ".json")
+
+ def get(self, operation_id):
+ path = self.path(operation_id)
+ if not path.exists():
+ return {
+ "operation_id": operation_id,
+ "state": "unknown",
+ "mutation_outcome": "unknown",
+ "reason": "No durable receipt exists; do not infer failure.",
+ }
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except (ValueError, OSError) as e:
+ raise NXToolError("NX_RECEIPT_UNREADABLE", str(e)) from e
+
+ def put(self, record):
+ path = self.path(record["operation_id"])
+ temp = path.with_suffix(".tmp")
+ with temp.open("w", encoding="utf-8") as f:
+ json.dump(record, f, ensure_ascii=False, allow_nan=False)
+ f.flush()
+ os.fsync(f.fileno())
+ os.replace(temp, path)
+
+ def recover(self, session_id):
+ for path in self.root.glob("*.json"):
+ record = json.loads(path.read_text(encoding="utf-8"))
+ if record.get("state") == "running" and record.get("session_id") != session_id:
+ record.update(
+ state="unknown",
+ mutation_outcome="unknown",
+ reason="NX process restarted before final receipt; manual reconciliation required.",
+ )
+ self.put(record)
+
+ @staticmethod
+ def fingerprint(method, params):
+ return hashlib.sha256(
+ json.dumps(
+ [method, params], sort_keys=True, separators=(",", ":"), allow_nan=False
+ ).encode()
+ ).hexdigest()
diff --git a/src/nx_mcp/reference_geometry.py b/src/nx_mcp/reference_geometry.py
new file mode 100644
index 0000000..6bdcc14
--- /dev/null
+++ b/src/nx_mcp/reference_geometry.py
@@ -0,0 +1,174 @@
+"""Explicit reference-set membership and datum display controls for NX 2606."""
+
+from nx_mcp.runtime import NXToolError
+
+
+class ReferenceGeometryMixin:
+ def _flat_pattern_orientation_edges(self, upward_face):
+ import NXOpen.UF
+
+ from nx_mcp.hardened import xyz
+
+ face = self._engineering_owned(upward_face, "face")
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ data = uf.Modeling.AskFaceData(face.Tag)
+ if data[0] != 22:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select a planar upward web face")
+ if not self._sm_manager().IsSheetmetalBody(face.GetBody()):
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a sheet-metal face")
+ items = []
+ for edge in face.GetEdges():
+ if edge.SolidEdgeType != self.nxopen.Edge.EdgeType.Linear:
+ continue
+ start, end = edge.GetVertices()
+ items.append(
+ {
+ "edge": self._reference(edge, "edge", self._work_part(), "Orientation edge"),
+ "start": xyz(start),
+ "end": xyz(end),
+ "adjacent_faces": [
+ self._reference(f, "face", self._work_part(), "Face")["id"]
+ for f in edge.GetFaces()
+ ],
+ }
+ )
+ return {
+ "upward_face": self._reference(face, "face", self._work_part(), "Upward face"),
+ "edges": items,
+ "count": len(items),
+ "coordinate_frame": "work_part",
+ "eligibility": "linear_boundary_of_planar_sheet_metal_face",
+ "warnings": [
+ "These geometric candidates avoid stale outer-edge guesses. Final validity depends on the native Flat Pattern commit; no temporary feature is committed by this inspection."
+ ],
+ }
+
+ def _reference_set_record(self, value):
+ members = list(value.AskAllDirectMembers())
+ return {
+ "object": self._reference(value, "reference_set", self._work_part(), "Reference set"),
+ "name": value.Name,
+ "member_count": len(members),
+ "members": [self._display_ref(x) for x in members],
+ "add_components_automatically": bool(value.GetAddComponentsAutomatically()),
+ }
+
+ def _list_reference_sets(self):
+ part = self._work_part()
+ self._require_api(part, "GetAllReferenceSets")
+ records = [self._reference_set_record(x) for x in part.GetAllReferenceSets()]
+ return {
+ "reference_sets": records,
+ "count": len(records),
+ "built_in": ["Entire Part", "Empty"],
+ }
+
+ def _create_reference_set(self, name, objects):
+ part = self._work_part()
+ if (
+ not isinstance(name, str)
+ or not name.strip()
+ or len(name) > 132
+ or name.casefold() in {"entire part", "empty"}
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Use a nonempty custom reference-set name of at most 132 characters",
+ )
+ self._require_api(part, "CreateReferenceSet", "GetAllReferenceSets")
+ if any(x.Name.casefold() == name.casefold() for x in part.GetAllReferenceSets()):
+ raise NXToolError("NX_ALREADY_EXISTS", "A reference set with this name already exists")
+ if not isinstance(objects, list) or not 1 <= len(objects) <= 1000:
+ raise NXToolError("NX_INVALID_ARGUMENT", "objects requires 1–1000 explicit references")
+ values = [self._resolve(x, {"body", "curve", "component", "datum"}) for x in objects]
+ if any(x.IsOccurrence and x.OwningPart != part for x in values):
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Members must be owned by the work part")
+ result = part.CreateReferenceSet()
+ result.SetName(name)
+ result.SetAddComponentsAutomatically(False, False)
+ result.AddObjectsToReferenceSet(values)
+ self._update_model()
+ return self._reference_set_record(result)
+
+ def _set_component_reference_set(self, components, name):
+ part = self._work_part()
+ if not isinstance(components, list) or not 1 <= len(components) <= 1000:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "components requires 1–1000 direct occurrence references"
+ )
+ values = [self._resolve(x, {"component"}) for x in components]
+ direct = (
+ list(part.ComponentAssembly.RootComponent.GetChildren())
+ if part.ComponentAssembly.RootComponent
+ else []
+ )
+ if any(x not in direct for x in values):
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH",
+ "Activate the owning assembly; only direct children can be changed",
+ )
+ for c in values:
+ names = [x.Name for x in c.Prototype.GetAllReferenceSets()] + ["Entire Part", "Empty"]
+ if name not in names:
+ raise NXToolError(
+ "NX_NOT_FOUND",
+ "Reference set is absent from a component prototype",
+ details={"component": c.Name, "reference_set": name},
+ )
+ self._require_api(part.ComponentAssembly, "ReplaceReferenceSet")
+ before = [
+ {
+ "object": self._reference(c, "component", part, "Component"),
+ "previous_reference_set": c.ReferenceSet,
+ }
+ for c in values
+ ]
+ for c in values:
+ part.ComponentAssembly.ReplaceReferenceSet(c, name)
+ self._update_model()
+ if any(c.ReferenceSet != name for c in values):
+ raise NXToolError("NX_VERIFICATION_FAILED", "Reference-set assignment did not persist")
+ return {
+ "components": [
+ dict(r, reference_set=c.ReferenceSet) for r, c in zip(before, values, strict=True)
+ ],
+ "count": len(values),
+ "prototype_parts_modified": False,
+ }
+
+ def _datum_objects(self):
+ part = self._work_part()
+ values = list(part.Datums) + list(part.CoordinateSystems)
+ return list({int(x.Tag): x for x in values}.values())
+
+ def _list_datums(self):
+ values = self._datum_objects()
+ return {
+ "datums": [
+ {
+ "object": self._reference(x, "datum", self._work_part(), "Datum"),
+ "native_type": type(x).__name__,
+ "blanked": bool(x.IsBlanked),
+ }
+ for x in values
+ ],
+ "count": len(values),
+ "scope": "work_part",
+ }
+
+ def _set_datum_visibility(self, visible=False):
+ self._visual_part()
+ values = self._datum_objects()
+ before = self._display_records(values)
+ for x in values:
+ (x.Unblank if visible else x.Blank)()
+ return {
+ "restore_id": self._save_display_snapshot(before),
+ "objects": self._display_records(values),
+ "count": len(values),
+ "visible": visible,
+ "scope": "work_part",
+ "warnings": [
+ "Changes owned datums and coordinate systems only. Use body-only component reference sets to exclude prototype datums from assembly drawings."
+ ],
+ }
diff --git a/src/nx_mcp/release_engineering.py b/src/nx_mcp/release_engineering.py
new file mode 100644
index 0000000..33da7f4
--- /dev/null
+++ b/src/nx_mcp/release_engineering.py
@@ -0,0 +1,619 @@
+"""Native drawing authoring and persistent imported-geometry references."""
+
+import math
+import uuid
+
+from nx_mcp.authoring import finite
+from nx_mcp.runtime import NXToolError
+
+
+def sheet_point(value):
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Position requires two sheet coordinates")
+ return [finite(x, "position") for x in value]
+
+
+class ReleaseEngineeringMixin:
+ def _sheet_units(self, sheet):
+ import NXOpen.Drawings as D
+
+ if sheet.Units == D.DrawingSheet.Unit.Millimeters:
+ return "mm"
+ if sheet.Units == D.DrawingSheet.Unit.Inches:
+ return "in"
+ raise NXToolError("NX_UNSUPPORTED_UNITS", "Unknown native drawing units")
+
+ def _view_sheet(self, view):
+ matches = [s for s in self._work_part().DrawingSheets if view in s.GetDraftingViews()]
+ if len(matches) != 1:
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select a view on one work-part sheet")
+ return matches[0]
+
+ def _drawing_view_info(self, view):
+ import NXOpen.UF as U
+
+ from nx_mcp.hardened import xyz
+
+ obj = self._drawing_object(view, "drawing_view")
+ sheet = self._view_sheet(obj)
+ uf = U.UFSession.GetUFSession().Draw
+ bounds = list(uf.AskViewBorders(obj.Tag))
+ style = self._view_style(view)
+ warnings = []
+ if not style["hidden_lines"] or not style["self_hidden"]:
+ warnings.append(
+ "Hidden-line processing is disabled: occluded edges may be drawn as visible. "
+ "To hide them use hidden_lines=true, self_hidden=true, hidden_font=0."
+ )
+ return {
+ "object": self._reference(obj, "drawing_view", self._work_part(), "View"),
+ "drawing": self._reference(sheet, "drawing_sheet", self._work_part(), "Sheet"),
+ "native_type": type(obj).__name__,
+ "style": style,
+ "warnings": warnings,
+ "position": xyz(obj.GetDrawingReferencePoint())[:2],
+ "scale": uf.AskViewScale(obj.Tag)[1],
+ "bounds": bounds,
+ "inside_sheet": bounds[0] >= 0
+ and bounds[1] >= 0
+ and bounds[2] <= sheet.Length
+ and bounds[3] <= sheet.Height,
+ "units": self._sheet_units(sheet),
+ "coordinate_frame": "drawing_sheet",
+ "bounds_semantics": "native drafting view border, excluding separately placed annotations",
+ }
+
+ def _edit_drawing_view(self, view, position=None, scale=None, style=None):
+ import NXOpen.UF as U
+
+ style = {k: v for k, v in (style or {}).items() if v is not None}
+ if position is None and scale is None and not style:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Supply position, scale or style")
+ point = sheet_point(position) if position is not None else None
+ scale = finite(scale, "scale", True) if scale is not None else None
+ obj = self._drawing_object(view, "drawing_view")
+ self._view_sheet(obj).Open()
+ uf = U.UFSession.GetUFSession().Draw
+ self._require_api(uf, "SetViewScale", "MoveView")
+ if scale is not None:
+ uf.SetViewScale(obj.Tag, scale)
+ if point is not None:
+ uf.MoveView(obj.Tag, point)
+ if style:
+ self._view_style(view, {k: v for k, v in style.items() if v is not None})
+ self._work_part().DraftingViews.UpdateViews([obj])
+ self._refresh_detail_boundaries()
+ result = self._drawing_view_info(view)
+ if (scale is not None and not math.isclose(result["scale"], scale, abs_tol=1e-8)) or (
+ point is not None and math.dist(result["position"], point) > 1e-6
+ ):
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED",
+ "View alignment or scale prevented the requested placement; rolling back",
+ )
+ result["modified"] = [result["object"]]
+ return result
+
+ def _add_section_drawing_view(
+ self,
+ parent_view,
+ cut_object,
+ position,
+ step_direction,
+ arrow_direction,
+ scale=1.0,
+ cut_association="start",
+ ):
+ import NXOpen.UF as U
+
+ from nx_mcp.hardened import dot
+ from nx_mcp.visual_tools import unit_normal
+
+ point = sheet_point(position)
+ scale = finite(scale, "scale", True)
+ step, arrow = (
+ unit_normal(step_direction, "step_direction"),
+ unit_normal(arrow_direction, "arrow_direction"),
+ )
+ if abs(step[2]) > 1e-8 or abs(arrow[2]) > 1e-8 or abs(dot(step, arrow)) > 1e-8:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Step and arrow must be perpendicular vectors in the sheet XY plane",
+ )
+ parent = self._drawing_object(parent_view, "drawing_view")
+ edge = self._engineering_owned(cut_object, "edge")
+ sheet = self._view_sheet(parent)
+ uf = U.UFSession.GetUFSession().Draw
+ self._require_api(uf, "CreateSimpleSxview")
+ sheet.Open()
+ cut = U.Drf.Object()
+ cut.ObjectTag = edge.Tag
+ cut.ObjectViewTag = parent.Tag
+ if cut_association not in {"start", "end", "arc_center"}:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "cut_association must be start, end or arc_center"
+ )
+ cut.ObjectAssocType = (
+ U.Drf.AssocType.ARC_CENTER
+ if cut_association == "arc_center"
+ else U.Drf.AssocType.END_POINT
+ )
+ cut.ObjectAssocModifier = (
+ 0 if cut_association == "arc_center" else 1 if cut_association == "start" else 2
+ )
+ tag = uf.CreateSimpleSxview(sheet.Tag, scale, step, arrow, parent.Tag, cut, point)
+ obj = self.nxopen.TaggedObjectManager.GetTaggedObject(tag)
+ self._work_part().DraftingViews.UpdateViews([obj])
+ ref = self._reference(obj, "drawing_view", self._work_part(), "Section view")
+ return {
+ **self._edit_drawing_view(ref["id"], scale=scale),
+ "created": [ref],
+ "parent": parent_view,
+ "association": "native simple section anchored to edge",
+ }
+
+ def _add_detail_drawing_view(self, parent_view, center, radius, position, scale=2.0):
+ import NXOpen.Drawings as D
+
+ from nx_mcp.freeform import points3
+
+ center = points3([center])[0]
+ radius = finite(radius, "radius", True)
+ point = sheet_point(position)
+ scale = finite(scale, "scale", True)
+ parent = self._drawing_object(parent_view, "drawing_view")
+ sheet = self._view_sheet(parent)
+ sheet.Open()
+ part = self._work_part()
+ self._require_api(part.DraftingViews, "CreateDetailViewBuilder")
+ b = part.DraftingViews.CreateDetailViewBuilder(None)
+ try:
+ b.Parent.View.Value = parent
+ b.Type = D.DetailViewBuilder.Types.Circular
+ mapped = self._detail_boundary_points(parent, sheet, center, radius)
+ b.BoundaryPoint1 = part.Points.CreatePoint(mapped[0])
+ b.BoundaryPoint2 = part.Points.CreatePoint(mapped[1])
+ b.BoundaryPoint1.Blank()
+ b.BoundaryPoint2.Blank()
+ b.Scale.ScaleType = D.ViewScaleBuilder.Type.Ratio
+ b.Scale.Numerator = scale
+ b.Scale.Denominator = 1.0
+ b.Origin.Placement.SetValue(None, None, self._sheet_point3d(sheet, point))
+ if not b.Validate():
+ raise NXToolError("NX_DRAWING_INVALID", "Detail view did not validate")
+ obj = b.Commit()
+ finally:
+ b.Destroy()
+ import json
+
+ import NXOpen.UF as U
+
+ obj.SetAttribute(
+ "NX_MCP_DETAIL_V1",
+ json.dumps(
+ {
+ "parent": U.UFSession.GetUFSession().Tag.AskHandleFromTag(parent.Tag),
+ "center": center,
+ "radius": radius,
+ }
+ ),
+ )
+ ref = self._reference(obj, "drawing_view", part, "Detail view")
+ return {
+ **self._edit_drawing_view(ref["id"], scale=scale),
+ "created": [ref],
+ "parent": parent_view,
+ "boundary_frame": "work_part",
+ "boundary_units": self._units(),
+ }
+
+ def _geometry_anchor(self, object):
+ import NXOpen.UF as U
+
+ obj = self._resolve(object, {"body", "face", "edge"})
+ if obj.IsOccurrence or obj.OwningPart != self._work_part():
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH", "Anchor owned prototype geometry in its work part"
+ )
+ kind = (
+ "body"
+ if isinstance(obj, self.nxopen.Body)
+ else "face"
+ if isinstance(obj, self.nxopen.Face)
+ else "edge"
+ )
+ part = self._work_part()
+ if not part.FullPath:
+ raise NXToolError("NX_UNSAVED_PART", "Save the part before creating persistent anchors")
+ return {
+ "anchor": {
+ "version": 1,
+ "owner_part": part.FullPath,
+ "kind": kind,
+ "handle": U.UFSession.GetUFSession().Tag.AskHandleFromTag(obj.Tag),
+ },
+ "semantics": "native persistent identity, survives save/reopen while entity survives; no geometric nearest-neighbor fallback",
+ }
+
+ def _resolve_geometry_anchor(self, anchor):
+ import NXOpen.UF as U
+
+ if (
+ not isinstance(anchor, dict)
+ or set(anchor) != {"version", "owner_part", "kind", "handle"}
+ or anchor["version"] != 1
+ or anchor["kind"] not in {"body", "face", "edge"}
+ or not all(isinstance(anchor[k], str) and anchor[k] for k in ["owner_part", "handle"])
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use an intact native geometry anchor")
+ part = self._work_part()
+ if anchor["owner_part"].casefold() != part.FullPath.casefold():
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Anchor belongs to another part")
+ try:
+ tag = int(U.UFSession.GetUFSession().Tag.AskTagOfHandle(anchor["handle"]))
+ objects = (
+ list(part.Bodies)
+ if anchor["kind"] == "body"
+ else [
+ x
+ for b in part.Bodies
+ for x in (b.GetFaces() if anchor["kind"] == "face" else b.GetEdges())
+ ]
+ )
+ obj = next(o for o in objects if int(o.Tag) == tag)
+ except Exception as error:
+ raise NXToolError(
+ "NX_STALE_REFERENCE",
+ "Anchored entity no longer exists; explicitly select replacement geometry",
+ ) from error
+ return {
+ "object": self._reference(obj, anchor["kind"], part, "Anchored geometry"),
+ "anchor": anchor,
+ "resolution": "native persistent handle and owner/type verification",
+ }
+
+ def _drawing_table(self, drawing, kind, rows, widths, position, table=None, row_height=7.0):
+ import NXOpen.UF as U
+
+ if (
+ kind not in {"title_block", "revision"}
+ or not isinstance(rows, list)
+ or not 1 <= len(rows) <= 100
+ or not isinstance(widths, list)
+ or not 1 <= len(widths) <= 20
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Use title_block or revision with 1..100 rows and 1..20 columns",
+ )
+ if any(
+ not isinstance(r, list)
+ or len(r) != len(widths)
+ or any(not isinstance(c, str) or len(c) > 1024 for c in r)
+ for r in rows
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Rows must be rectangular strings, maximum 1024 characters per cell",
+ )
+ widths = [finite(w, "width", True) for w in widths]
+ height = finite(row_height, "row_height", True)
+ point = sheet_point(position)
+ sheet = self._drawing_object(drawing, "drawing_sheet")
+ part = self._work_part()
+ section = self._engineering_owned(table, "annotation") if table else None
+ attr = "NX_MCP_DRAWING_TABLE_V1"
+ guid_attr = "NX_MCP_TABLE_SHEET_GUID_V2"
+ string_type = self.nxopen.NXObject.AttributeType.String
+ sheet_guid = (
+ sheet.GetStringAttribute(guid_attr)
+ if sheet.HasUserAttribute(guid_attr, string_type, -1)
+ else None
+ )
+ if sheet_guid:
+ matches = [
+ s
+ for s in part.DrawingSheets
+ if s.HasUserAttribute(guid_attr, string_type, -1)
+ and s.GetStringAttribute(guid_attr) == sheet_guid
+ ]
+ if len(matches) != 1:
+ raise NXToolError(
+ "NX_OBJECT_OWNER_AMBIGUOUS",
+ "Drawing sheets share the same managed identity; resolve duplicated sheet metadata before editing tables",
+ )
+ if section is not None:
+ kind_ok = (
+ section.HasUserAttribute(attr, string_type, -1)
+ and section.GetStringAttribute(attr) == kind
+ )
+ if section.HasUserAttribute(guid_attr, string_type, -1):
+ owner_ok = (
+ sheet_guid is not None and section.GetStringAttribute(guid_attr) == sheet_guid
+ )
+ else:
+ try:
+ owner_ok = (
+ U.UFSession.GetUFSession().Tag.AskTagOfHandle(
+ section.GetStringAttribute("NX_MCP_TABLE_SHEET_V1")
+ )
+ == sheet.Tag
+ )
+ except Exception:
+ owner_ok = False
+ if not kind_ok or not owner_ok:
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH",
+ "Edit a managed table of the same kind on this sheet",
+ )
+ if sheet_guid is None:
+ sheet_guid = uuid.uuid4().hex
+ sheet.SetAttribute(guid_attr, sheet_guid)
+ sheet.Open()
+ if section is None:
+ b = part.Annotations.TableSections.CreateTableSectionBuilder(None)
+ try:
+ b.NumberOfRows = len(rows)
+ b.NumberOfColumns = len(widths)
+ b.RowHeight = height
+ b.ColumnWidth = widths[0]
+ b.Origin.Origin.SetValue(None, None, self._sheet_point3d(sheet, point))
+ section = b.Commit()
+ finally:
+ b.Destroy()
+ section.SetAttribute(attr, kind)
+ section.SetAttribute(
+ "NX_MCP_TABLE_SHEET_V1", U.UFSession.GetUFSession().Tag.AskHandleFromTag(sheet.Tag)
+ )
+ section.SetAttribute(guid_attr, sheet_guid)
+ tab = U.UFSession.GetUFSession().Tabnot
+ tag = (
+ U.UFSession.GetUFSession().Tag.AskTagOfHandle(
+ section.GetStringAttribute("NX_MCP_NATIVE_TABLE_V1")
+ )
+ if type(section).__name__ == "TitleBlock"
+ else tab.AskTabularNoteOfSection(section.Tag)
+ )
+ if tab.AskNmColumns(tag) != len(widths):
+ raise NXToolError(
+ "NX_UNSUPPORTED_EDIT", "Keep the existing column count when editing a table"
+ )
+ while tab.AskNmRows(tag) < len(rows):
+ tab.AddRow(tag, tab.CreateRow(height), tab.AskNmRows(tag))
+ while tab.AskNmRows(tag) > len(rows):
+ old = tab.AskNthRow(tag, tab.AskNmRows(tag) - 1)
+ tab.RemoveRow(old)
+ U.UFSession.GetUFSession().Obj.DeleteObject(old)
+ for i, values in enumerate(rows):
+ row = tab.AskNthRow(tag, i)
+ tab.SetRowHeight(row, height)
+ for j, value in enumerate(values):
+ col = tab.AskNthColumn(tag, j)
+ tab.SetColumnWidth(col, widths[j])
+ tab.SetCellText(tab.AskCellAtRowCol(row, col), value)
+ section.AnnotationOrigin = self._sheet_point3d(sheet, point)
+ title = None
+ if kind == "title_block" and table is None:
+ b = part.DraftingManager.TitleBlocks.CreateDefineTitleBlockBuilder(
+ self.nxopen.Annotations.TitleBlock.Null
+ )
+ try:
+ b.Components.Add(section)
+ b.UpdateCells()
+ title = b.Commit()
+ finally:
+ b.Destroy()
+ section = title
+ section.AnnotationOrigin = self._sheet_point3d(sheet, point)
+ section.SetAttribute(attr, kind)
+ section.SetAttribute(
+ "NX_MCP_TABLE_SHEET_V1", U.UFSession.GetUFSession().Tag.AskHandleFromTag(sheet.Tag)
+ )
+ section.SetAttribute(
+ "NX_MCP_NATIVE_TABLE_V1", U.UFSession.GetUFSession().Tag.AskHandleFromTag(tag)
+ )
+ section.SetAttribute(guid_attr, sheet_guid)
+ self._update_model()
+ ref = self._reference(section, "annotation", part, "Drawing table")
+ actual = [
+ [
+ tab.AskEvaluatedCellText(
+ tab.AskCellAtRowCol(tab.AskNthRow(tag, i), tab.AskNthColumn(tag, j))
+ )
+ for j in range(len(widths))
+ ]
+ for i in range(len(rows))
+ ]
+ return {
+ "table": ref,
+ "kind": kind,
+ "position": point,
+ "position_anchor": "lower_right" if kind == "title_block" else "upper_left",
+ "rows": actual,
+ "row_count": len(actual),
+ "column_count": len(widths),
+ "native_title_block": type(section).__name__ if kind == "title_block" else None,
+ "created": [ref] if table is None else [],
+ "modified": [ref] if table else [],
+ "units": self._sheet_units(sheet),
+ "coordinate_frame": "drawing_sheet",
+ "semantics": "Editable native table; revision entries are explicitly supplied, not inferred release approvals",
+ }
+
+ def _update_assembly_documentation(self):
+ import NXOpen.Positioning as P
+ import NXOpen.UF as U
+
+ part = self._explosion_context()
+ constraints = self._assembly_constraints(part)
+ if constraints:
+ positioner = part.ComponentAssembly.Positioner
+ positioner.BeginAssemblyConstraints()
+ try:
+ network = positioner.EstablishNetwork()
+ network.MoveObjectsState = True
+ network.Solve()
+ network.ApplyToModel()
+ self._update_model()
+ if any(
+ c.GetConstraintStatus() != P.Constraint.SolverStatus.Solved for c in constraints
+ ):
+ raise NXToolError(
+ "NX_CONSTRAINT_UNSATISFIED",
+ "Assembly constraints did not solve; documentation refresh rolled back",
+ )
+ finally:
+ positioner.ClearNetwork()
+ positioner.EndAssemblyConstraints()
+ self._update_model()
+ for ex in self._explosions(part):
+ self._refresh_explosion_traces(ex)
+ uf = U.UFSession.GetUFSession()
+ boms = list(part.Annotations.PartsLists)
+ for bom in boms:
+ uf.Plist.Update(bom.Tag)
+ annotation_result = self._refresh_annotations()
+ views = [v for s in part.DrawingSheets for v in s.GetDraftingViews()]
+ if views:
+ with self._drawing_save_context(part, force_display=True):
+ for sheet in part.DrawingSheets:
+ sheet.Open()
+ owned_views = list(sheet.GetDraftingViews())
+ if owned_views:
+ part.DraftingViews.UpdateViews(owned_views)
+ for view in owned_views:
+ view.UpdateAutomaticViewBound()
+ retained_annotations = [
+ self._reference(a, "annotation", part, "Retained annotation")
+ for a in self._documentation_annotations(part)
+ if getattr(a, "IsRetained", False)
+ ]
+ retained_dimensions = any(d.IsRetained for d in part.Dimensions)
+ return {
+ "constraints": [self._assembly_constraint_record(c) for c in constraints],
+ "parts_lists": [
+ self._parts_list_info(self._reference(b, "annotation", part, "BOM")["id"])
+ for b in boms
+ ],
+ "views": [
+ self._drawing_view_info(self._reference(v, "drawing_view", part, "View")["id"])
+ for v in views
+ ],
+ "annotations": annotation_result,
+ "dimensions": self._list_dimensions()["dimensions"],
+ "retained_annotations": retained_annotations,
+ "documentation_complete": not retained_dimensions and not retained_annotations,
+ "warnings": [
+ "Retained dimensions or annotations require explicit reassociation or replacement; use nx_add_dimension(dimension=...) for dimensions."
+ ]
+ if retained_dimensions or retained_annotations
+ else [],
+ "health": self._model_health(scope="assembly"),
+ "semantics": "Explicit assembly update after prototype edits/replacement: solves constraints, refreshes anchored traces, BOMs and native drawing views. Missing managed anchors or unsolved mates roll back this refresh; previous prototype edits remain separate operations.",
+ }
+
+ def _drawing_coordinates(self, sheet, value, key="position"):
+ units = self._sheet_units(sheet)
+ return {
+ key: value,
+ key + "_mm": [v * (25.4 if units == "in" else 1) for v in value],
+ "sheet_units": units,
+ "coordinate_frame": "drawing_sheet",
+ }
+
+ def _sheet_point3d(self, sheet, values):
+ """NXOpen placement points use part units even on a differently sized sheet."""
+ factor = (25.4 if self._sheet_units(sheet) == "in" else 1.0) / (
+ 25.4 if self._units() == "inch" else 1.0
+ )
+ return self.nxopen.Point3d(*(float(v) * factor for v in values), 0.0)
+
+ def _place_drawing_view(self, view, sheet, point, axes=(0, 1)):
+ import NXOpen.UF as U
+
+ U.UFSession.GetUFSession().Draw.MoveView(view.Tag, point)
+ self._work_part().DraftingViews.UpdateViews([view])
+ actual = view.GetDrawingReferencePoint()
+ if any(abs([actual.X, actual.Y][i] - point[i]) > 1e-6 for i in axes):
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED",
+ f"Native view placement differs from requested sheet coordinates: actual {[actual.X, actual.Y]}, requested {point}",
+ )
+
+ def _detail_boundary_points(self, parent, sheet, center, radius):
+ import NXOpen.UF as U
+
+ matrix = parent.Matrix
+ other = [
+ c + radius * d for c, d in zip(center, [matrix.Xx, matrix.Xy, matrix.Xz], strict=True)
+ ]
+ mapper = U.UFSession.GetUFSession().View.MapModelToDrawing
+ return [self._sheet_point3d(sheet, mapper(parent.Tag, p)) for p in [center, other]]
+
+ def _refresh_detail_boundaries(self):
+ import json
+
+ import NXOpen.UF as U
+
+ part = self._work_part()
+ managed = [
+ (sheet, view)
+ for sheet in getattr(part, "DrawingSheets", [])
+ for view in sheet.GetDraftingViews()
+ if view.HasUserAttribute(
+ "NX_MCP_DETAIL_V1", self.nxopen.NXObject.AttributeType.String, -1
+ )
+ ]
+ if not managed:
+ return
+ with self._drawing_save_context(part, force_display=True):
+ for sheet, view in managed:
+ sheet.Open()
+ record = json.loads(view.GetStringAttribute("NX_MCP_DETAIL_V1"))
+ tag = int(U.UFSession.GetUFSession().Tag.AskTagOfHandle(record["parent"]))
+ parent = next((v for v in sheet.GetDraftingViews() if int(v.Tag) == tag), None)
+ if parent is None:
+ raise NXToolError(
+ "NX_STALE_ANNOTATION_SOURCE", "Detail-view parent no longer resolves"
+ )
+ points = self._detail_boundary_points(
+ parent, sheet, record["center"], record["radius"]
+ )
+ builder = part.DraftingViews.CreateDetailViewBuilder(view)
+ try:
+ builder.BoundaryPoint1.SetCoordinates(points[0])
+ builder.BoundaryPoint2.SetCoordinates(points[1])
+ builder.Commit()
+ finally:
+ builder.Destroy()
+
+ def _drawing_dimension_edge(self, reference):
+ edge = self._resolve(reference, {"edge"})
+ if edge.IsOccurrence:
+ if edge.OwningComponent not in [c for c, _ in self._walk_components(self._work_part())]:
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH", "Select geometry in the work assembly"
+ )
+ elif edge.OwningPart != self._work_part():
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Select owned work-part geometry")
+ return edge
+
+ def _list_dimensions(self):
+ from nx_mcp.hardened import xyz
+
+ part = self._work_part()
+ return {
+ "dimensions": [
+ {
+ "object": self._reference(d, "dimension", part, "Dimension"),
+ "native_type": type(d).__name__,
+ "computed_value": d.ComputedSize,
+ "retained": bool(d.IsRetained),
+ "measurement_valid": not bool(d.IsRetained),
+ "origin": xyz(d.AnnotationOrigin),
+ }
+ for d in part.Dimensions
+ ],
+ "units": self._units(),
+ "coordinate_frame": "native annotation frame",
+ }
diff --git a/src/nx_mcp/result_retention.py b/src/nx_mcp/result_retention.py
new file mode 100644
index 0000000..8eb475a
--- /dev/null
+++ b/src/nx_mcp/result_retention.py
@@ -0,0 +1,59 @@
+"""Retention of disposable response snapshots, never mutation recovery receipts."""
+
+import os
+import re
+import time
+from pathlib import Path
+from threading import RLock
+
+LOCK = RLock()
+PATTERN = re.compile(r"result_[a-f0-9]{32}\.json")
+
+
+def settings():
+ values = {
+ "max_age_seconds": int(os.environ.get("NX_MCP_RESULT_MAX_AGE_SECONDS", "604800")),
+ "max_bytes": int(os.environ.get("NX_MCP_RESULT_MAX_BYTES", "268435456")),
+ }
+ if any(v <= 0 for v in values.values()):
+ raise ValueError("Snapshot retention age and byte limits must be positive integers")
+ return values
+
+
+def maintain(root, *, apply=False, max_age_seconds=None, max_bytes=None, protect=None):
+ root = Path(root)
+ config = settings()
+ for key, value in {"max_age_seconds": max_age_seconds, "max_bytes": max_bytes}.items():
+ if value is not None:
+ if type(value) is not int or value <= 0:
+ raise ValueError("Retention limits must be positive integers")
+ config[key] = value
+ with LOCK:
+ records = []
+ if root.exists():
+ for p in root.iterdir():
+ if PATTERN.fullmatch(p.name) and p.is_file() and not p.is_symlink():
+ stat = p.stat()
+ records.append((stat.st_mtime, stat.st_size, p))
+ records.sort(key=lambda x: (x[0], x[2].name))
+ total = sum(size for _, size, _ in records)
+ selected = []
+ now = time.time()
+ for modified, size, path in records:
+ if path.stem == protect:
+ continue
+ if now - modified > config["max_age_seconds"] or total > config["max_bytes"]:
+ selected.append(path)
+ total -= size
+ if apply:
+ for path in selected:
+ path.unlink(missing_ok=True)
+ return {
+ "policy": config,
+ "snapshot_count": len(records),
+ "bytes_before": sum(x[1] for x in records),
+ "selected_count": len(selected),
+ "bytes_after_cleanup": total,
+ "applied": apply,
+ "recovery_receipts_affected": False,
+ }
diff --git a/src/nx_mcp/result_transport.py b/src/nx_mcp/result_transport.py
new file mode 100644
index 0000000..3da8ceb
--- /dev/null
+++ b/src/nx_mcp/result_transport.py
@@ -0,0 +1,174 @@
+"""Bounded bridge receipts with immutable, independently pageable full results."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import uuid
+from pathlib import Path
+
+from nx_mcp.result_retention import LOCK, maintain, settings
+from nx_mcp.runtime import NXToolError
+
+
+def encoded(value):
+ return json.dumps(value, ensure_ascii=False, allow_nan=False).encode("utf-8")
+
+
+def preview(value, path="", omitted=None, budget=None):
+ omitted = {} if omitted is None else omitted
+ budget = [2000] if budget is None else budget
+ budget[0] -= 1
+ if budget[0] <= 0:
+ omitted.setdefault(path, {"reason": "Read this field separately"})
+ return None
+ if isinstance(value, dict):
+ if len(value) > 100:
+ omitted[path] = {"total_keys": len(value), "returned_keys": 100}
+ return {
+ k: preview(v, path + "/" + k.replace("~", "~0").replace("/", "~1"), omitted, budget)
+ for k, v in list(value.items())[:100]
+ }
+ if isinstance(value, list):
+ if len(value) > 5:
+ omitted[path] = {"total_count": len(value), "returned_count": 5}
+ return [preview(v, path + "/" + str(i), omitted, budget) for i, v in enumerate(value[:5])]
+ if isinstance(value, str) and len(value) > 2048:
+ omitted[path] = {"total_characters": len(value), "returned_characters": 2048}
+ return value[:2048]
+ return value
+
+
+def bound_result(result, directory):
+ raw = encoded(result)
+ if len(raw) < 512 * 1024:
+ return result
+ if directory is None:
+ raise NXToolError(
+ "NX_RESPONSE_TOO_LARGE",
+ "Bridge result exceeds the delivery budget",
+ details={
+ "operation_id": result.get("operation_id"),
+ "mutation_outcome": result.get("mutation_outcome", "unknown"),
+ },
+ )
+ root = Path(directory)
+ identifier = "result_" + uuid.uuid4().hex
+ try:
+ root.mkdir(parents=True, exist_ok=True)
+ with LOCK:
+ config = settings()
+ age, budget = config["max_age_seconds"], config["max_bytes"]
+ if len(raw) > budget:
+ raise OSError("Result exceeds snapshot storage budget")
+ p = root / (identifier + ".json")
+ tmp = p.with_suffix(".tmp")
+ try:
+ tmp.write_bytes(raw)
+ tmp.replace(p)
+ finally:
+ tmp.unlink(missing_ok=True)
+ maintain(root, apply=True, max_age_seconds=age, max_bytes=budget, protect=p.stem)
+ except OSError as error:
+ raise NXToolError(
+ "NX_RESULT_STORAGE_FAILED",
+ "Result delivery failed after execution; inspect durable operation status before retrying",
+ details={
+ "operation_id": result.get("operation_id"),
+ "mutation_outcome": result.get("mutation_outcome", "unknown"),
+ "restore_id": result.get("restore_id"),
+ "cause": str(error),
+ },
+ ) from error
+ omitted = {}
+ summary = preview(result, omitted=omitted)
+ if len(encoded({"value": summary, "omitted": omitted})) > 400 * 1024:
+ summary = {
+ k: result[k]
+ for k in (
+ "status",
+ "operation_id",
+ "session_id",
+ "mutation_outcome",
+ "restore_id",
+ "body_count",
+ "object_count",
+ "expanded_count",
+ )
+ if k in result
+ }
+ omitted = {"/": {"reason": "Full result requires pagination"}}
+ summary["full_result"] = {
+ "id": identifier,
+ "sha256": hashlib.sha256(raw).hexdigest(),
+ "size": len(raw),
+ "tool": "nx_read_result",
+ "immutable": True,
+ }
+ summary["omitted"] = omitted
+ return summary
+
+
+def read_result(root, result_id, field="", offset=0, limit=20):
+ if not re.fullmatch(r"result_[0-9a-f]{32}", result_id) or offset < 0 or not 1 <= limit <= 100:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Use a returned result ID, offset >=0 and limit 1..100"
+ )
+ p = Path(root) / (result_id + ".json")
+ with LOCK:
+ if not p.is_file() or p.is_symlink():
+ raise NXToolError("NX_RESULT_EXPIRED", "Result snapshot is unavailable")
+ value = json.loads(p.read_text(encoding="utf-8"))
+ if field and not field.startswith("/"):
+ raise NXToolError("NX_INVALID_ARGUMENT", "field must be a JSON pointer")
+ try:
+ for key in field.split("/")[1:]:
+ key = key.replace("~1", "/").replace("~0", "~")
+ value = value[int(key)] if isinstance(value, list) else value[key]
+ except (KeyError, IndexError, ValueError, TypeError) as error:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown result field") from error
+ total = len(value) if isinstance(value, (list, str, dict)) else None
+ selected = (
+ dict(list(value.items())[offset : offset + limit])
+ if isinstance(value, dict)
+ else value[offset : offset + limit]
+ if total is not None
+ else value
+ )
+ omitted = {}
+ data = (
+ [preview(v, field + "/" + str(offset + i), omitted) for i, v in enumerate(selected)]
+ if isinstance(selected, list)
+ else preview(selected, field, omitted)
+ )
+ if len(encoded({"value": data, "omitted": omitted})) > 400 * 1024:
+ if isinstance(selected, list):
+ data = [
+ {"field": field + "/" + str(offset + i), "read_separately": True}
+ for i in range(len(selected))
+ ]
+ elif isinstance(selected, dict):
+ data = {
+ k: {
+ "field": field + "/" + k.replace("~", "~0").replace("/", "~1"),
+ "read_separately": True,
+ }
+ for k in list(selected)[:100]
+ }
+ else:
+ data = None
+ omitted = {field: {"reason": "Read child fields separately"}}
+ return {
+ "status": "success",
+ "result_id": result_id,
+ "field": field,
+ "value": data,
+ "total_count": total,
+ "offset": offset,
+ "next_offset": min(offset + limit, total)
+ if total is not None and offset + limit < total
+ else None,
+ "omitted": omitted,
+ "mutation_outcome": "not_applicable",
+ }
diff --git a/src/nx_mcp/review_tools.py b/src/nx_mcp/review_tools.py
new file mode 100644
index 0000000..a340eec
--- /dev/null
+++ b/src/nx_mcp/review_tools.py
@@ -0,0 +1,552 @@
+"""Model summaries, reproducible views, inspection artifacts and reversible previews."""
+
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import inspect
+import json
+import uuid
+import zipfile
+from pathlib import Path
+from tempfile import TemporaryDirectory
+
+from nx_mcp.authoring import finite, page
+from nx_mcp.runtime import NXToolError
+
+
+class ReviewToolsMixin:
+ def _set_camera(self, rotation, origin, scale):
+ from nx_mcp.hardened import vector
+
+ view = self._visual_part().ModelingViews.WorkView
+ matrix = self._validate_rotation(rotation)
+ point = vector(origin, "origin")
+ value = finite(scale, "scale", True)
+ self._require_api(view, "SetRotationTranslationScale")
+ view.SetRotationTranslationScale(
+ self._nx_matrix(matrix), self.nxopen.Point3d(*point), value
+ )
+ view.UpdateDisplay()
+ return self._view_info()
+
+ def _restore_camera(self, camera):
+ self._set_camera(camera["rotation"], camera["origin"], camera["scale"])
+ styles = {
+ "shaded": "Shaded",
+ "shaded_with_edges": "ShadedWithEdges",
+ "wireframe": "StaticWireframe",
+ }
+ if camera["rendering_style"] in styles:
+ self._work_part().ModelingViews.WorkView.RenderingStyle = getattr(
+ self.nxopen.View.RenderingStyleType, styles[camera["rendering_style"]]
+ )
+
+ @contextlib.contextmanager
+ def _temporary_view(self):
+ camera = self._view_info()
+ snapshot_keys = set(getattr(self, "_display_snapshots", {}))
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Invisible, "NX MCP temporary presentation"
+ )
+ try:
+ yield
+ finally:
+ try:
+ self.session.UndoToMark(mark, None)
+ self.session.DeleteUndoMark(mark, None)
+ except Exception as exc:
+ raise NXToolError(
+ "NX_ROLLBACK_FAILED",
+ "Temporary presentation cleanup failed: " + str(exc),
+ details={"mutation_outcome": "partial"},
+ ) from exc
+ finally:
+ for key in set(getattr(self, "_display_snapshots", {})) - snapshot_keys:
+ self._display_snapshots.pop(key, None)
+ self._restore_camera(camera)
+ self._clear_highlights()
+
+ def _locator(self, obj, kind):
+ return {
+ "kind": kind,
+ "journal_id": str(obj.JournalIdentifier),
+ "owner_part": self._work_part().FullPath,
+ }
+
+ def _locate(self, locator):
+ part = self._work_part()
+ if locator["owner_part"].casefold() != part.FullPath.casefold():
+ raise NXToolError("NX_OBJECT_OWNER_MISMATCH", "Saved reference belongs to another part")
+ kind = locator["kind"]
+ if kind == "expression":
+ candidates = list(part.Expressions)
+ elif kind == "component":
+ candidates = [c for c, _ in self._walk_components(part)]
+ elif kind == "feature":
+ candidates = list(part.Features)
+ elif kind == "sketch":
+ candidates = list(part.Sketches)
+ elif kind in {"face", "edge"}:
+ bodies = self._geometry(scope="assembly")
+ candidates = [
+ v for b in bodies for v in (b.GetFaces() if kind == "face" else b.GetEdges())
+ ]
+ elif kind in {"curve", "body"}:
+ candidates = list(part.Curves if kind == "curve" else part.Bodies)
+ else:
+ candidates = []
+ matches = [c for c in candidates if c.JournalIdentifier == locator["journal_id"]]
+ if len(matches) == 1:
+ return matches[0]
+ # Occurrence faces/bodies have assembly-context journal identifiers.
+ try:
+ obj = part.FindObject(locator["journal_id"])
+ except Exception as exc:
+ raise NXToolError(
+ "NX_SAVED_REFERENCE_STALE",
+ "Saved geometry no longer resolves: " + locator["journal_id"],
+ ) from exc
+ if obj is None:
+ raise NXToolError("NX_SAVED_REFERENCE_STALE", "Saved reference is unavailable")
+ valid = {
+ "body": self.nxopen.Body,
+ "face": self.nxopen.Face,
+ "edge": self.nxopen.Edge,
+ "sketch": self.nxopen.Sketch,
+ }
+ if kind in valid and not isinstance(obj, valid[kind]):
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Saved reference changed kind")
+ return obj
+
+ def _save_presentation(self, path):
+ part = self._visual_part()
+ file = self.workspace.ensure_inside(path)
+ if file.suffix.lower() != ".json" or file.exists():
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Choose a new workspace .json presentation path"
+ )
+ try:
+ bodies = self._geometry(scope="assembly")
+ except NXToolError as err:
+ if err.code != "NX_NO_TARGET_BODY":
+ raise
+ bodies = []
+ values = bodies + [c for c, _ in self._walk_components(part)]
+ records = self._display_records(values, True)
+ saved = []
+ for row in records:
+ obj = self._resolve(row["object"]["id"])
+ saved.append(
+ {
+ **{k: v for k, v in row.items() if k != "object"},
+ "locator": self._locator(obj, row["object"]["kind"]),
+ }
+ )
+ sections = self._list_sections()
+ data = {
+ "format": "nx-mcp-presentation",
+ "version": 1,
+ "part_path": part.FullPath,
+ "camera": self._view_info(),
+ "display": saved,
+ "section": next(
+ (
+ {k: r[k] for k in ("origin", "normal", "cap")}
+ for r in sections["sections"]
+ if r["active"]
+ ),
+ None,
+ ),
+ "sectioning_enabled": sections["view_sectioning_enabled"],
+ }
+ file.parent.mkdir(parents=True, exist_ok=True)
+ with file.open("x", encoding="utf-8") as stream:
+ json.dump(data, stream, indent=2)
+ return {
+ "path": str(file),
+ "artifact_path": str(file.relative_to(self.workspace.root)),
+ "sha256": hashlib.sha256(file.read_bytes()).hexdigest(),
+ "size": file.stat().st_size,
+ "objects_saved": len(saved),
+ "warnings": [
+ "Restores explicit appearance attributes. Across revisions, journal identifiers must still identify the intended geometry."
+ ],
+ }
+
+ def _restore_presentation(self, path):
+ file = self.workspace.ensure_inside(path)
+ if file.stat().st_size > 8 * 1024 * 1024:
+ raise NXToolError("NX_OBJECT_LIMIT", "Presentation exceeds 8 MiB")
+ data = json.loads(file.read_text(encoding="utf-8"))
+ if data.get("format") != "nx-mcp-presentation" or data.get("version") != 1:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported presentation format")
+ if data["part_path"].casefold() != self._visual_part().FullPath.casefold():
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH",
+ "Activate the presentation owner as work and display part",
+ )
+ if len(data["display"]) > 10000:
+ raise NXToolError("NX_OBJECT_LIMIT", "Presentation exceeds 10000 display records")
+ resolved = [(self._locate(r["locator"]), r) for r in data["display"]]
+ # Preflight all saved entities and camera values before changing the viewport.
+ self._validate_rotation(data["camera"]["rotation"])
+ finite(data["camera"]["scale"], "scale", True)
+ before = self._view_info()
+ try:
+ for obj, row in resolved:
+ if "color_index" in row:
+ self._apply_appearance([obj], row["color_index"], row.get("transparency"))
+ (obj.Blank if row["blanked"] else obj.Unblank)()
+ view = self._work_part().ModelingViews.WorkView
+ section = data["section"]
+ if section:
+ current = view.ActiveDynamicSection
+ ref = (
+ self._reference(current, "section", self._work_part(), "Section")["id"]
+ if current
+ else None
+ )
+ self._section_view(**section, section=ref, name="Presentation section")
+ view.DisplaySectioningToggle = bool(data["sectioning_enabled"])
+ self._restore_camera(data["camera"])
+ except Exception:
+ self._restore_camera(before)
+ raise
+ return {
+ "path": str(file),
+ "restored_objects": len(resolved),
+ "camera": self._view_info(),
+ "geometry_changed": False,
+ "modified": None,
+ }
+
+ def _model_summary(self, section="overview", offset=0, limit=50):
+ part = self._work_part()
+ if section not in {"overview", "components", "features", "expressions", "sketches"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported summary section")
+ if section == "expressions":
+ return self._list_expressions(offset=offset, limit=limit)
+ if section == "components":
+ return page(self._list_components()["components"], offset, limit)
+ if section == "features":
+ result = page(list(part.Features), offset, limit)
+ result["items"] = [
+ {
+ "object": self._reference(f, "feature", part, "Feature"),
+ "type": f.FeatureType,
+ "suppressed": bool(f.Suppressed),
+ "parents": [
+ self._reference(v, "feature", part, "Feature") for v in f.GetParents()
+ ],
+ "expressions": [
+ {"name": e.Name, "formula": e.RightHandSide} for e in f.GetExpressions()
+ ],
+ }
+ for f in result["items"]
+ ]
+ return result
+ if section == "sketches":
+ result = page(list(part.Sketches), offset, limit)
+ result["items"] = [
+ {
+ "object": self._reference(s, "sketch", part, "Sketch"),
+ "curve_count": len(s.GetAllGeometry()),
+ "frame": self._sketch_frame(s),
+ }
+ for s in result["items"]
+ ]
+ return result
+ try:
+ bounds = self._get_bounding_box()
+ except NXToolError as err:
+ if err.code != "NX_NO_TARGET_BODY":
+ raise
+ bounds = None
+ return {
+ "part": self._reference(part, "part", part, "Part"),
+ "modified": bool(part.IsModified),
+ "units": self._units(),
+ "counts": {
+ "bodies": len(list(part.Bodies)),
+ "features": len(list(part.Features)),
+ "sketches": len(list(part.Sketches)),
+ "expressions": len(list(part.Expressions)),
+ "component_occurrences": len(self._walk_components(part)),
+ },
+ "bounds": {
+ k: bounds[k]
+ for k in ("min", "max", "dimensions", "bounds_type", "coordinate_frame")
+ }
+ if bounds
+ else None,
+ "diagnostics": self._model_health(limit=10),
+ "sections": ["components", "features", "expressions", "sketches"],
+ "warning": "Counts of owned bodies differ from recursive assembly geometry.",
+ }
+
+ def _inspection_report(
+ self,
+ path,
+ objects=None,
+ minimum_clearance=0.0,
+ max_pairs=100,
+ include_clear=False,
+ capture=True,
+ section_planes=None,
+ ):
+ import html
+
+ from nx_mcp.hardened import vector
+ from nx_mcp.visual_tools import unit_normal
+
+ file = self.workspace.ensure_inside(path)
+ if file.suffix.lower() != ".zip" or file.exists():
+ raise NXToolError("NX_INVALID_ARGUMENT", "Choose a new workspace .zip report path")
+ planes = section_planes or []
+ if len(planes) > 6:
+ raise NXToolError("NX_INVALID_ARGUMENT", "At most six section planes")
+ for plane in planes:
+ if set(plane) != {"origin", "normal"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Section requires origin and normal only")
+ vector(plane["origin"])
+ unit_normal(plane["normal"])
+ if planes and not capture:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Section screenshots require capture=true")
+ file.parent.mkdir(parents=True, exist_ok=True)
+ with TemporaryDirectory(prefix="inspection-", dir=self.workspace.root) as tmp:
+ root = Path(tmp)
+ checks = self._check_clearance(objects, minimum_clearance, max_pairs, include_clear)
+ report = {
+ "format": "nx-mcp-inspection",
+ "version": 1,
+ "summary": self._model_summary(),
+ "clearance": checks,
+ "captures": [],
+ }
+ if capture:
+ with self._temporary_view():
+ report["captures"].append(
+ self._capture_view(str(root / "overview.png"), fit=True)
+ )
+ # Capture bounded close-ups for actual flagged pairs, not every clear envelope.
+ pairs = [
+ p
+ for p in checks["pairs"]
+ if p["classification"] in {"penetration", "contact", "below_clearance"}
+ ][:8]
+ for i, pair in enumerate(pairs):
+ refs = [r["id"] for r in pair["objects"]]
+ with self._temporary_view():
+ self._set_visibility(refs, "isolate")
+ self._highlight_objects(refs)
+ report["captures"].append(
+ self._capture_view(str(root / f"pair-{i + 1}.png"), fit=True)
+ )
+ for i, plane in enumerate(planes):
+ with self._temporary_view():
+ current = self._work_part().ModelingViews.WorkView.ActiveDynamicSection
+ ref = (
+ self._reference(current, "section", self._work_part(), "Section")[
+ "id"
+ ]
+ if current
+ else None
+ )
+ self._section_view(**plane, section=ref)
+ report["captures"].append(
+ self._capture_view(str(root / f"section-{i + 1}.png"), fit=True)
+ )
+ for image in report["captures"]:
+ image["path"] = Path(image["path"]).name
+ (root / "report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
+ body = (
+ "NX inspection report
Units: "
+ + html.escape(self._units())
+ + "
"
+ + html.escape(json.dumps(checks, indent=2))
+ + "
"
+ )
+ body += "".join(
+ '
'
+ + html.escape(r["path"])
+ + ""
+ for r in report["captures"]
+ )
+ (root / "index.html").write_text(
+ 'NX inspection' + body,
+ encoding="utf-8",
+ )
+ manifest = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in root.iterdir()}
+ (root / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+ # Exclusive creation prevents overwriting reports; partial ZIPs are removed on failure.
+ created = False
+ try:
+ with file.open("xb") as stream:
+ created = True
+ with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
+ for p in sorted(root.iterdir()):
+ archive.write(p, p.name)
+ except Exception:
+ if created:
+ file.unlink(missing_ok=True)
+ raise
+ return {
+ "path": str(file),
+ "artifact_path": str(file.relative_to(self.workspace.root)),
+ "size": file.stat().st_size,
+ "sha256": hashlib.sha256(file.read_bytes()).hexdigest(),
+ "capture_count": len(report["captures"]),
+ "clearance": checks,
+ "geometry_changed": False,
+ "warnings": [
+ "PNG views are native viewport captures, not photorealistic renders. Pair close-ups are capped at eight."
+ ],
+ }
+
+ def _preview_plan(self, operations):
+ allowed = {
+ "nx_set_expression",
+ "nx_bind_parameter",
+ "nx_edit_feature",
+ "nx_edit_sketch",
+ "nx_set_component_transform",
+ "nx_reposition_component",
+ }
+ if not isinstance(operations, list) or not 1 <= len(operations) <= 25:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Preview requires 1–25 supported edits")
+ locators = {}
+
+ def visit(value):
+ if isinstance(value, str) and value.startswith("obj_"):
+ obj = self._resolve(value)
+ kind = self.objects._objects[value].reference.kind
+ locators[value] = self._locator(obj, kind)
+ elif isinstance(value, dict):
+ for v in value.values():
+ visit(v)
+ elif isinstance(value, list):
+ for v in value:
+ visit(v)
+
+ for op in operations:
+ if set(op) != {"method", "params"} or op["method"] not in allowed:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Preview supports expression, parameter, feature, sketch and placement edits only",
+ )
+ inspect.signature(self._handlers[op["method"]]).bind(**op["params"])
+ visit(op["params"])
+ return locators
+
+ def _preview_snapshot(self):
+ result = self._model_summary()
+ result["parameters"] = [
+ {k: self._expression_record(e)[k] for k in ("name", "formula", "value", "units")}
+ for e in self._work_part().Expressions
+ if e.Type == "Number"
+ ]
+ try:
+ result["volume"] = self._measure_volume()
+ except NXToolError as err:
+ if err.code != "NX_NO_TARGET_BODY":
+ raise
+ result["volume"] = None
+ return result
+
+ def _preview_change(self, operations, capture=True):
+ import copy
+
+ locators = self._preview_plan(operations)
+ before = self._preview_snapshot()
+ part = self._work_part()
+ pid = self._part_id(part)
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Invisible, "NX MCP change preview"
+ )
+ previous = self._active_mark
+ camera = self._view_info() if capture else None
+ try:
+ self._active_mark = mark
+ for op in operations:
+ self._handlers[op["method"]](**op["params"])
+ after = self._preview_snapshot()
+ image = self._capture_view(fit=True) if capture else None
+ finally:
+ self._active_mark = previous
+ try:
+ self.session.UndoToMark(mark, None)
+ self.session.DeleteUndoMark(mark, None)
+ self.objects.invalidate_part(pid)
+ except Exception as exc:
+ raise NXToolError(
+ "NX_ROLLBACK_FAILED",
+ "Preview rollback failed: " + str(exc),
+ details={"mutation_outcome": "partial"},
+ ) from exc
+ finally:
+ if camera:
+ self._restore_camera(camera)
+ token = "preview_" + uuid.uuid4().hex
+ if not hasattr(self, "_previews"):
+ self._previews = {}
+ if len(self._previews) >= 20:
+ self._previews.pop(next(iter(self._previews)))
+ self._previews[token] = {
+ "operations": copy.deepcopy(operations),
+ "locators": locators,
+ "part_id": pid,
+ "epoch": getattr(self, "_review_epoch", 0),
+ }
+ return {
+ "preview_id": token,
+ "before": before,
+ "after": after,
+ "capture": image,
+ "model_outcome": "rolled_back",
+ "warnings": [
+ "Preview geometry has already been rolled back; returned geometry references are stale. Accept reapplies the stored edits only if no intervening mutation or manual handoff occurred."
+ ],
+ }
+
+ def _finish_preview(self, preview_id, action):
+ if action not in {"accept", "discard"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "action must be accept or discard")
+ preview = getattr(self, "_previews", {}).get(preview_id)
+ if preview is None:
+ raise NXToolError("NX_PREVIEW_STALE", "Unknown or consumed preview")
+ if action == "discard":
+ del self._previews[preview_id]
+ return {"preview_id": preview_id, "action": "discard", "geometry_changed": False}
+ if preview["epoch"] != getattr(self, "_review_epoch", 0) or preview[
+ "part_id"
+ ] != self._part_id(self._work_part()):
+ raise NXToolError("NX_PREVIEW_STALE", "Model/session changed; create a fresh preview")
+ replacements = {
+ key: self._reference(self._locate(v), v["kind"], self._work_part(), "Preview target")[
+ "id"
+ ]
+ for key, v in preview["locators"].items()
+ }
+
+ def translate(value):
+ if isinstance(value, str):
+ return replacements.get(value, value)
+ if isinstance(value, dict):
+ return {k: translate(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [translate(v) for v in value]
+ return value
+
+ results = [
+ self._handlers[op["method"]](**translate(op["params"])) for op in preview["operations"]
+ ]
+ del self._previews[preview_id]
+ return {
+ "preview_id": preview_id,
+ "action": "accept",
+ "results": results,
+ "geometry_changed": True,
+ }
diff --git a/src/nx_mcp/runtime.py b/src/nx_mcp/runtime.py
index 72fccbb..d2fb766 100644
--- a/src/nx_mcp/runtime.py
+++ b/src/nx_mcp/runtime.py
@@ -5,7 +5,31 @@
from dataclasses import asdict, dataclass
from typing import Any, Literal
-ObjectKind = Literal["part", "sketch", "curve", "feature", "body"]
+ObjectKind = Literal[
+ "reference_set",
+ "datum",
+ "part",
+ "sketch",
+ "curve",
+ "point",
+ "feature",
+ "body",
+ "component",
+ "face",
+ "edge",
+ "section",
+ "constraint",
+ "expression",
+ "component_pattern",
+ "assembly_constraint",
+ "drawing_sheet",
+ "drawing_view",
+ "dimension",
+ "annotation",
+ "traceline",
+ "explosion",
+ "modeling_view",
+]
@dataclass(frozen=True)
diff --git a/src/nx_mcp/save_audit.py b/src/nx_mcp/save_audit.py
new file mode 100644
index 0000000..693e2cf
--- /dev/null
+++ b/src/nx_mcp/save_audit.py
@@ -0,0 +1,90 @@
+"""Verify work-part saves against loaded-part state and disk fingerprints."""
+
+import hashlib
+from pathlib import Path
+
+from nx_mcp.runtime import NXToolError
+
+
+def fingerprint(path):
+ if not path or not Path(path).is_file():
+ return {"exists": False}
+ file = Path(path)
+ before = file.stat()
+ with file.open("rb") as stream:
+ hasher = hashlib.sha256()
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ hasher.update(chunk)
+ digest = hasher.hexdigest()
+ stat = file.stat()
+ if (before.st_size, before.st_mtime_ns) != (stat.st_size, stat.st_mtime_ns):
+ raise OSError(f"File changed while verifying: {path}")
+ return {"exists": True, "sha256": digest, "size": stat.st_size, "mtime_ns": stat.st_mtime_ns}
+
+
+def snapshot(session):
+ return {
+ int(p.Tag): {
+ "path": p.FullPath,
+ "modified": bool(p.IsModified),
+ "file": fingerprint(p.FullPath),
+ }
+ for p in session.Parts
+ }
+
+
+def verify(before, after, target_tag, native_errors):
+ target = after.get(target_tag)
+ unrelated = []
+ unexpected = []
+ for tag, row in before.items():
+ if tag == target_tag:
+ continue
+ current = after.get(tag)
+ unchanged = current == row
+ if row["modified"]:
+ unrelated.append(
+ {
+ "path": row["path"],
+ "modified_before": True,
+ "modified_after": current["modified"] if current else None,
+ "unchanged": unchanged,
+ }
+ )
+ if not unchanged:
+ unexpected.append({"path": row["path"], "before": row, "after": current})
+ new_parts = [row for tag, row in after.items() if tag not in before]
+ saved = bool(
+ target
+ and target["path"] == before[target_tag]["path"]
+ and not target["modified"]
+ and target["file"]["exists"]
+ and not native_errors
+ )
+ result = {
+ "requested_file": before[target_tag]["path"],
+ "saved_files": [target["path"]] if saved else [],
+ "observed_changed_files": [
+ row["path"]
+ for tag, row in after.items()
+ if tag not in before or row["file"] != before[tag]["file"]
+ ],
+ "save_scope": "work_part_only",
+ "verification_scope": "loaded_part_flags_and_files",
+ "verified_part_count": len(before),
+ "target_file": {
+ "before": before[target_tag]["file"],
+ "after": target["file"] if target else None,
+ },
+ "unrelated_modified_parts": unrelated,
+ "unexpected_changes": unexpected,
+ "unexpected_new_parts": new_parts,
+ "native_save_errors": native_errors,
+ }
+ if not saved or unexpected or new_parts:
+ raise NXToolError(
+ "NX_SAVE_VERIFICATION_FAILED",
+ "Save did not satisfy its file/state contract; inspect the receipt before retrying",
+ details={**result, "mutation_outcome": "partial"},
+ )
+ return result
diff --git a/src/nx_mcp/schema_types.py b/src/nx_mcp/schema_types.py
new file mode 100644
index 0000000..7a05d8e
--- /dev/null
+++ b/src/nx_mcp/schema_types.py
@@ -0,0 +1,19 @@
+"""Schema metadata is optional in NX's standard-library-only embedded Python.
+
+The sidecar requires Pydantic and builds/validates actual models. Native handlers
+only import contract names and effect sets; they never instantiate these models.
+"""
+
+try:
+ from pydantic import BaseModel, ConfigDict, Field
+except ModuleNotFoundError as error:
+ if error.name != "pydantic":
+ raise
+
+ class BaseModel: # type: ignore[no-redef]
+ pass
+
+ ConfigDict = dict # type: ignore[misc,assignment]
+
+ def Field(*args, **kwargs): # type: ignore[no-redef]
+ return None
diff --git a/src/nx_mcp/server.py b/src/nx_mcp/server.py
index 8922be2..1f79e3a 100644
--- a/src/nx_mcp/server.py
+++ b/src/nx_mcp/server.py
@@ -23,6 +23,7 @@ def create_server(
*,
enable_experimental: bool | None = None,
enable_journal: bool | None = None,
+ surface: str | None = None,
) -> FastMCP:
"""Create the certified v0.2 MCP server."""
if workspace is None and (workspace_root := os.environ.get("NX_MCP_WORKSPACE")):
@@ -31,13 +32,24 @@ def create_server(
enable_experimental = os.environ.get("NX_MCP_ENABLE_EXPERIMENTAL") == "1"
if enable_journal is None:
enable_journal = os.environ.get("NX_MCP_ENABLE_JOURNAL") == "1"
- return create_certified_server(
+ surface = surface or os.environ.get("NX_MCP_SURFACE", "full")
+ if surface not in {"full", "agent"}:
+ raise ValueError("NX_MCP_SURFACE must be full or agent")
+ if surface == "agent" and (not enable_experimental or workspace is None):
+ raise ValueError("Agent surface requires experimental integration and workspace")
+ server = create_certified_server(
bridge or DescriptorBridgeClient(),
workspace,
enable_experimental=enable_experimental,
enable_journal=enable_experimental and enable_journal,
)
+ if surface == "agent":
+ from nx_mcp.agent_surface import configure
+
+ configure(server, workspace)
+ return server
+
async def async_main() -> None:
"""Run the MCP server with stdio transport."""
diff --git a/src/nx_mcp/sheet_metal.py b/src/nx_mcp/sheet_metal.py
new file mode 100644
index 0000000..9875e46
--- /dev/null
+++ b/src/nx_mcp/sheet_metal.py
@@ -0,0 +1,1165 @@
+"""Native sheet-metal authoring through reviewed NX 2606 builder contracts."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from nx_mcp.authoring import finite, page
+from nx_mcp.runtime import NXToolError
+from nx_mcp.visual_tools import enum_name, unit_normal
+
+CATALOG = json.loads(Path(__file__).with_name("sheet_metal_catalog.json").read_text())
+APPLICATION = "UG_APP_SBSM"
+
+
+def field_schema(field):
+ kind = field["kind"]
+ if kind == "section":
+ return {
+ "oneOf": [
+ {"type": "string", "description": "Finished work-part sketch ID"},
+ *[
+ {
+ "type": "object",
+ "properties": {
+ objects: field_schema({"kind": "reference_list"}),
+ "help_point": field_schema({"kind": "point3d"}),
+ },
+ "required": [objects, "help_point"],
+ "additionalProperties": False,
+ }
+ for objects in ("edges", "curves")
+ ],
+ ]
+ }
+ if kind in {"expression", "number"}:
+ return {"type": "number"}
+ if kind in {"boolean", "integer", "string"}:
+ return {"type": kind}
+ if kind == "enum":
+ return {"type": "string", "enum": field["values"]}
+ if kind == "object":
+ return fields_schema(field["fields"])
+ if kind in {"flange_list", "joggle_list"}:
+ return {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": fields_schema(
+ field["fields"],
+ field["required"],
+ ),
+ }
+ if kind == "point_section":
+ return {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": field_schema({"kind": "point3d"}),
+ }
+ if kind == "object_list":
+ return {
+ "type": "array",
+ "minItems": 0,
+ "maxItems": 100,
+ "items": fields_schema(field["fields"], field["required"]),
+ }
+ if kind == "face_pairs":
+ return {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 100,
+ "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "string"}},
+ }
+ if kind in {"point", "point3d", "direction"}:
+ return {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "number"}}
+ if kind in {"plane", "csys"}:
+ props = {"origin": field_schema({"kind": "point3d"})}
+ props.update(
+ {
+ key: field_schema({"kind": "direction"})
+ for key in (["normal"] if kind == "plane" else ["x_axis", "y_axis"])
+ }
+ )
+ return {
+ "type": "object",
+ "properties": props,
+ "required": list(props),
+ "additionalProperties": False,
+ }
+ if kind in {"collector", "reference_list", "select_faces", "select_edges", "select_bodies"}:
+ return {
+ "type": "array",
+ "minItems": 1,
+ "maxItems": 1000,
+ "uniqueItems": True,
+ "items": {
+ "type": "string",
+ "description": "Typed work-part " + field.get("objects", "geometry") + " ID",
+ },
+ }
+ return {
+ "type": "string",
+ "description": "Finished work-part sketch ID"
+ if kind == "section"
+ else "Typed work-part " + kind + " ID",
+ }
+
+
+def fields_schema(fields, required=()):
+ return {
+ "type": "object",
+ "properties": {
+ k: {
+ **field_schema(v),
+ **({"description": v["description"]} if "description" in v else {}),
+ }
+ for k, v in fields.items()
+ },
+ "required": list(required),
+ "additionalProperties": False,
+ }
+
+
+class SheetMetalMixin:
+ def _create_path_sketch(
+ self,
+ edges,
+ help_point,
+ percent=0,
+ orienting_face=None,
+ reverse_normal=False,
+ reverse_axis=False,
+ name=None,
+ ):
+ import NXOpen.GeometricUtilities as G
+
+ part = self._work_part()
+ if self.session.ActiveSketch:
+ raise NXToolError("NX_SKETCH_ACTIVE", "Finish the active sketch first")
+ if not self.session.IsBatch and self.session.Parts.Display != part:
+ raise NXToolError("NX_PART_CONTEXT", "Activate the same work and display part")
+ values = self._sm_validate(
+ {
+ "edges": {"kind": "reference_list", "objects": "edge"},
+ "help_point": {"kind": "point3d"},
+ "percent": {"kind": "number"},
+ "reverse_normal": {"kind": "boolean"},
+ "reverse_axis": {"kind": "boolean"},
+ },
+ {
+ "edges": edges,
+ "help_point": help_point,
+ "percent": percent,
+ "reverse_normal": reverse_normal,
+ "reverse_axis": reverse_axis,
+ },
+ )
+ if not 0 <= values["percent"] <= 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Path percentage must be within 0..100")
+ if name is not None and (
+ not isinstance(name, str)
+ or not name
+ or len(name) > 132
+ or any(ord(c) < 32 for c in name)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Use a nonempty sketch name without control characters"
+ )
+ face = self._sm_reference(orienting_face, "face") if orienting_face else None
+ self._require_api(part.Sketches, "CreateSketchAlongPathBuilder")
+ builder = part.Sketches.CreateSketchAlongPathBuilder(None)
+ try:
+ self._sm_section(
+ builder.Section, {"edges": values["edges"], "help_point": values["help_point"]}
+ )
+ builder.PlaneOrientation = (
+ self.nxopen.SketchAlongPathBuilder.PlaneOrientationType.NormalToPath
+ )
+ builder.SketchOrient = getattr(
+ self.nxopen.SketchAlongPathBuilder.SketchOrientationType,
+ "RelativeToFace" if face else "Automatic",
+ )
+ if face:
+ builder.OrientingFace.ReplaceRules(
+ [part.ScRuleFactory.CreateRuleFaceDumb([face])], False
+ )
+ location = builder.PlaneLocation
+ location.IsParameterUsed = False
+ location.IsPercentUsed = True
+ location.Expression.RightHandSide = str(values["percent"])
+ location.Update(G.OnPathDimensionBuilder.UpdateReason.Path)
+ builder.ReversePlaneNormal = values["reverse_normal"]
+ builder.ReverseAxis = values["reverse_axis"]
+ if not builder.Validate():
+ raise NXToolError("NX_SKETCH_INVALID", "Native path sketch validation failed")
+ sketch = builder.Commit()
+ if name:
+ sketch.SetName(name)
+ self._update_model()
+ sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue)
+ return {
+ "object": self._reference(sketch, "sketch", part, "Path sketch"),
+ "frame": self._sketch_frame(sketch),
+ "percent": values["percent"],
+ "position_convention": "arc_length_percent",
+ "units": self._units(),
+ }
+ finally:
+ builder.Destroy()
+
+ def _sm_manager(self):
+ part = self._work_part()
+ self._require_api(part.Features, "SheetmetalManager")
+ return part.Features.SheetmetalManager
+
+ def _sm_prepare(self):
+ part = self._work_part()
+ if not self.session.IsBatch and self.session.Parts.Display != part:
+ raise NXToolError("NX_PART_CONTEXT", "Activate the same work and display part")
+ if self.session.ActiveSketch:
+ raise NXToolError("NX_SKETCH_ACTIVE", "Finish the sketch before sheet-metal authoring")
+ if not self.session.IsBatch and self.session.ApplicationName != APPLICATION:
+ self.session.ApplicationSwitchImmediate(APPLICATION)
+ if self.session.ApplicationName != APPLICATION:
+ raise NXToolError("NX_APPLICATION_UNAVAILABLE", "NX did not enter Sheet Metal")
+ return part
+
+ def _sheet_metal_context(self):
+ self._sm_prepare()
+ return {
+ "application": "batch" if self.session.IsBatch else self.session.ApplicationName,
+ "recovery": self._checkpoint_state(),
+ "message": "Native Sheet Metal context is active",
+ }
+
+ def _sm_require_context(self):
+ part = self._work_part()
+ if self.session.ActiveSketch:
+ raise NXToolError("NX_SKETCH_ACTIVE", "Finish the sketch before sheet-metal authoring")
+ if not self.session.IsBatch and (
+ self.session.ApplicationName != APPLICATION or self.session.Parts.Display != part
+ ):
+ raise NXToolError(
+ "NX_PART_CONTEXT",
+ "Activate the work/display part and call nx_sheet_metal_context first",
+ )
+
+ def _sheet_metal_schema(self, operation=None):
+ if operation is None:
+ return {
+ "operations": [
+ {
+ "operation": op,
+ "status": spec["native_status"],
+ "builder": spec["builder"],
+ "tested_on": spec.get("tested_on"),
+ "edit_status": spec.get("edit_status", "experimental"),
+ }
+ for op, spec in CATALOG.items()
+ ],
+ "units": None,
+ "unit_conventions": "Lengths use work-part units; expression angles are degrees; neutral factor is unitless",
+ "unavailable": [
+ {
+ "operation": "remove_bends",
+ "reason": "NX v2606 installation feature toggle is disabled",
+ }
+ ],
+ "coordinate_frame": "work_part",
+ "not_exposed": ["metaform", "nesting"],
+ }
+ if operation not in CATALOG:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown sheet-metal operation")
+ spec = CATALOG[operation]
+ return {
+ "operation": operation,
+ "parameters_schema": fields_schema(spec["fields"], spec["required"]),
+ "edit_parameters_schema": fields_schema(spec["fields"]),
+ "native_builder": spec["builder"],
+ "validation_status": spec["native_status"],
+ "tested_on": spec.get("tested_on"),
+ "validation_scope": spec.get("validation_scope"),
+ "edit_status": spec.get("edit_status", "experimental"),
+ "example_parameters": spec.get("example_parameters"),
+ "example_evidence": spec.get("example_evidence"),
+ "example_note": "$input_N values are placeholders; select matching geometry from your own fixture",
+ "defaults": "Unspecified properties retain native part/builder defaults; read feature parameters after creation.",
+ "units": None,
+ "coordinate_frame": "work_part",
+ "unit_conventions": "Lengths use work-part units; expression angles are degrees; neutral factor is unitless",
+ "prerequisites": [
+ "Activate the owning work/display part and call nx_sheet_metal_context before authoring.",
+ "Select work-part geometry IDs with nx_find_geometry or nx_list_topology; assembly occurrences are rejected.",
+ "Finish section sketches before passing their IDs. Operation-specific geometry must match the native builder and tested example.",
+ ]
+ + spec.get("prerequisites", []),
+ }
+
+ def _sm_reference(self, reference, kind):
+ if not isinstance(reference, str):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use typed object IDs")
+ kinds = {"face", "edge"} if kind == "face_or_edge" else {kind}
+ obj = self._resolve(reference, kinds)
+ if obj.IsOccurrence or obj.OwningPart != self._work_part():
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH", "Sheet-metal inputs must belong to the work part"
+ )
+ if kind == "sketch" and obj == self.session.ActiveSketch:
+ raise NXToolError("NX_SKETCH_ACTIVE", "Finish the input sketch")
+ return obj
+
+ def _sm_validate(self, fields, parameters, required=()):
+ if (
+ not isinstance(parameters, dict)
+ or set(parameters) - set(fields)
+ or set(required) - set(parameters)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Missing or unsupported sheet-metal parameters",
+ details={"required": list(required), "supported": list(fields)},
+ )
+ result = {}
+ for key, value in parameters.items():
+ f = fields[key]
+ kind = f["kind"]
+ if kind == "section" and isinstance(value, dict):
+ source = "edges" if "edges" in value else "curves"
+ value = self._sm_validate(
+ {
+ source: {"kind": "reference_list", "objects": source[:-1]},
+ "help_point": {"kind": "point3d"},
+ },
+ value,
+ (source, "help_point"),
+ )
+ elif kind in {"expression", "number"}:
+ value = finite(value, key)
+ if "neutral_factor" in key and not 0 <= value <= 1:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Neutral factor must be within 0–1")
+ if "thickness" in key and value <= 0:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Thickness must be positive")
+ if "radius" in key and value < 0:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Radius cannot be negative")
+ elif kind == "integer":
+ if type(value) is not int:
+ raise NXToolError("NX_INVALID_ARGUMENT", key + " must be an integer")
+ elif kind == "boolean":
+ if type(value) is not bool:
+ raise NXToolError("NX_INVALID_ARGUMENT", key + " must be boolean")
+ elif kind == "enum":
+ if not isinstance(value, str) or value not in f["values"]:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", key + " must be one of " + ", ".join(f["values"])
+ )
+ elif kind == "string":
+ if (
+ not isinstance(value, str)
+ or len(value) > 256
+ or any(ord(c) < 32 for c in value)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ key + " must be a short string without control characters",
+ )
+ elif kind == "object":
+ value = self._sm_validate(f["fields"], value)
+ elif kind == "point_section":
+ if not isinstance(value, list) or not 1 <= len(value) <= 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use 1–100 point coordinates")
+ value = [
+ self._sm_validate({"point": {"kind": "point3d"}}, {"point": p})["point"]
+ for p in value
+ ]
+ elif kind == "object_list":
+ if not isinstance(value, list) or len(value) > 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", key + " requires at most 100 entries")
+ value = [self._sm_validate(f["fields"], item, f["required"]) for item in value]
+ elif kind in {"flange_list", "joggle_list"}:
+ if not isinstance(value, list) or not 1 <= len(value) <= 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", key + " requires 1–100 entries")
+ required_item = f["required"]
+ value = [self._sm_validate(f["fields"], item, required_item) for item in value]
+ elif kind in {
+ "collector",
+ "reference_list",
+ "select_faces",
+ "select_edges",
+ "select_bodies",
+ "face_pairs",
+ }:
+ if not isinstance(value, list) or not 1 <= len(value) <= 1000:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", key + " requires a nonempty reference list"
+ )
+ if kind == "face_pairs":
+ if len(value) > 100:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use at most 100 face pairs")
+ if any(
+ not isinstance(pair, list) or len(pair) != 2 or pair[0] == pair[1]
+ for pair in value
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use distinct face pairs")
+ value = [[self._sm_reference(r, "face") for r in pair] for pair in value]
+ else:
+ object_kind = (
+ f.get("objects")
+ or {
+ "select_faces": "face",
+ "select_edges": "edge",
+ "select_bodies": "body",
+ }[kind]
+ )
+ if any(not isinstance(r, str) for r in value) or len(set(value)) != len(value):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "References must be unique strings"
+ )
+ value = [self._sm_reference(r, object_kind) for r in value]
+ elif kind in {"point", "point3d", "direction"}:
+ if not isinstance(value, list) or len(value) != 3:
+ raise NXToolError("NX_INVALID_ARGUMENT", key + " requires three numbers")
+ value = [finite(v, key) for v in value]
+ if kind == "direction":
+ value = unit_normal(value)
+ elif kind in {"plane", "csys"}:
+ keys = ["origin", "normal"] if kind == "plane" else ["origin", "x_axis", "y_axis"]
+ if not isinstance(value, dict) or set(value) != set(keys):
+ raise NXToolError("NX_INVALID_ARGUMENT", key + " requires " + ", ".join(keys))
+ value = self._sm_validate(
+ {k: {"kind": "point3d" if k == "origin" else "direction"} for k in keys}, value
+ )
+ if kind == "csys":
+ from nx_mcp.hardened import dot
+
+ if abs(dot(value["x_axis"], value["y_axis"])) > 1e-8:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Coordinate-system axes must be orthogonal"
+ )
+ else:
+ reference_kind = "sketch" if kind == "section" else kind.removeprefix("select_")
+ value = self._sm_reference(value, reference_kind)
+ result[key] = value
+ return result
+
+ def _sm_section(self, section, sketch):
+ part = self._work_part()
+ if isinstance(sketch, dict):
+ source = "edges" if "edges" in sketch else "curves"
+ factory = getattr(part.ScRuleFactory, "CreateRule" + source[:-1].title() + "Dumb")
+ rule = factory(sketch[source])
+ section.Clear()
+ section.AddToSection(
+ [rule],
+ sketch[source][0],
+ None,
+ None,
+ self.nxopen.Point3d(*sketch["help_point"]),
+ self.nxopen.Section.Mode.Create,
+ False,
+ )
+ return
+ options = part.ScRuleFactory.CreateRuleOptions()
+ try:
+ rule = part.ScRuleFactory.CreateRuleCurveFeature([sketch.Feature], None, options)
+ finally:
+ options.Dispose()
+ section.Clear()
+ section.AddToSection(
+ [rule], None, None, None, sketch.Origin, self.nxopen.Section.Mode.Create, False
+ )
+
+ def _sm_apply(self, builder, fields, values):
+ import NXOpen.Features.SheetMetal as SM
+
+ part = self._work_part()
+ for key, value in values.items():
+ f = fields[key]
+ kind = f["kind"]
+ if kind == "reference_list":
+ getattr(builder, f["method"])(value)
+ continue
+ if kind == "face_pairs":
+ count = (
+ builder.GetNumberOfFacePairs()
+ if hasattr(builder, "GetNumberOfFacePairs")
+ else builder.NumberOfFacePairs
+ )
+ pairs = [builder.GetFacePair(i) for i in range(count)]
+ for pair in pairs:
+ builder.RemoveFacePair(*pair)
+ for pair in value:
+ builder.AddFacePair(*pair)
+ continue
+ if kind in {"flange_list", "joggle_list", "object_list"}:
+ if kind == "object_list":
+ container = getattr(builder, f["container"]) if f["container"] else builder
+ sequence = getattr(container, f["sequence"])
+ create = getattr(container, f["creator"])
+ elif kind == "flange_list":
+ container = builder.FlangePropertiesList
+ sequence = container.FeatureBendPropertiesList
+ create = container.CreateFlangeBendProperties
+ else:
+ sequence = builder.InputList
+ create = builder.CreateJoggleInputListItem
+ sequence.Clear(self.nxopen.ObjectList.DeleteOption.Delete)
+ for item in value:
+ entry = create()
+ sequence.Append(entry)
+ self._sm_apply(entry, f["fields"], item)
+ continue
+ path = f["path"]
+ # Some assignable native sections throw when read before initialization.
+ # Only read properties whose existing builder object we actually mutate.
+ needs_target = kind in {
+ "expression",
+ "object",
+ "section",
+ "point_section",
+ "collector",
+ "select_faces",
+ "select_edges",
+ "select_bodies",
+ } or kind.startswith("select_")
+ target = getattr(builder, path) if needs_target and not f.get("assign") else None
+ if f.get("getter"):
+ target = target()
+ if kind == "expression":
+ target.RightHandSide = str(value)
+ elif kind == "object":
+ self._sm_apply(target, f["fields"], value)
+ elif kind == "enum":
+ setattr(builder, path, getattr(getattr(SM, f["enum_type"]), value))
+ elif kind == "section":
+ if f.get("assign"):
+ if isinstance(value, dict):
+ section = part.Sections.CreateSection()
+ setattr(builder, path, section)
+ self._sm_section(section, value)
+ else:
+ setattr(builder, path, self._engineering_section(value))
+ else:
+ self._sm_section(target, value)
+ elif kind == "point_section":
+ points = [part.Points.CreatePoint(self.nxopen.Point3d(*p)) for p in value]
+ for point in points:
+ point.Blank()
+ rule = part.ScRuleFactory.CreateRuleCurveDumbFromPoints(points)
+ target.Clear()
+ target.AddToSection(
+ [rule],
+ None,
+ None,
+ None,
+ self.nxopen.Point3d(*value[0]),
+ self.nxopen.Section.Mode.Create,
+ False,
+ )
+ elif kind == "collector":
+ if f.get("assign"):
+ target = part.ScCollectors.CreateCollector()
+ setattr(builder, path, target)
+ factory = getattr(part.ScRuleFactory, "CreateRule" + f["objects"].title() + "Dumb")
+ target.ReplaceRules([factory(value)], False)
+ elif kind in {"select_faces", "select_edges", "select_bodies"}:
+ target.Clear()
+ target.Add(value)
+ elif kind.startswith("select_"):
+ target.Value = value
+ elif kind == "point3d":
+ setattr(builder, path, self.nxopen.Point3d(*value))
+ elif kind == "point":
+ setattr(builder, path, part.Points.CreatePoint(self.nxopen.Point3d(*value)))
+ elif kind == "direction":
+ setattr(builder, path, self._engineering_direction(value))
+ elif kind == "plane":
+ plane = part.Planes.CreatePlane(
+ self.nxopen.Point3d(*value["origin"]),
+ self.nxopen.Vector3d(*value["normal"]),
+ self.nxopen.SmartObject.UpdateOption.WithinModeling,
+ )
+ setattr(builder, path, plane)
+ elif kind == "csys":
+ from nx_mcp.hardened import cross, transpose
+
+ matrix = self._nx_matrix(
+ transpose(
+ [value["x_axis"], value["y_axis"], cross(value["x_axis"], value["y_axis"])]
+ )
+ )
+ csys = part.CoordinateSystems.CreateCoordinateSystem(
+ self.nxopen.Point3d(*value["origin"]),
+ matrix,
+ False,
+ )
+ setattr(builder, path, csys)
+ else:
+ setattr(builder, path, value)
+
+ def _sheet_metal_feature(self, operation, parameters, feature=None):
+ if operation not in CATALOG:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown sheet-metal operation")
+ spec = CATALOG[operation]
+ self._sm_require_context()
+ original = self._sm_reference(feature, "feature") if feature else None
+ if original is not None:
+ import NXOpen.Features.SheetMetal as SM
+
+ native_type = getattr(SM, spec["builder"].removesuffix("Builder"), None)
+ expected_type = {
+ "tab": {"Base Tab", "Secondary Tab"},
+ "flat_pattern": {"FLAT_PATTERN"},
+ }.get(operation)
+ if (native_type is not None and not isinstance(original, native_type)) or (
+ expected_type is not None and original.FeatureType not in expected_type
+ ):
+ raise NXToolError(
+ "NX_OBJECT_TYPE_MISMATCH", "Feature does not match this sheet-metal operation"
+ )
+ values = self._sm_validate(spec["fields"], parameters, () if original else spec["required"])
+ if operation == "tab" and (
+ values.get("is_secondary")
+ or (original is not None and original.FeatureType == "Secondary Tab")
+ ):
+ target = values.get("target_body")
+ if target is None:
+ bodies = list(original.GetBodies()) if original is not None else []
+ if len(bodies) != 1:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Secondary tabs require an explicit target_body"
+ )
+ target = bodies[0]
+ if (
+ "thickness" in values
+ and abs(values["thickness"] - self._sm_manager().GetBodyThickness(target)) > 1e-8
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Secondary tab thickness must match its target sheet"
+ )
+ if (
+ operation == "advanced_flange"
+ and original is None
+ and values.get("type") == "ToReference"
+ and not values.get("faces")
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "ToReference requires reference faces on the same sheet-metal body as the selected edges; a valid geometric combination is still required",
+ )
+ if not values:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Supply sheet-metal parameters")
+ manager = self._sm_manager()
+ self._require_api(manager, spec["factory"])
+ import NXOpen.Features.SheetMetal as SM
+
+ b = getattr(manager, spec["factory"])(original)
+ try:
+ if original is None and hasattr(b, "SetApplicationContext"):
+ b.SetApplicationContext(SM.ApplicationContext.NxSheetMetal)
+ elif (
+ original is not None
+ and hasattr(b, "GetApplicationContext")
+ and b.GetApplicationContext() != SM.ApplicationContext.NxSheetMetal
+ ):
+ raise NXToolError(
+ "NX_PART_CONTEXT", "Feature belongs to a different native application context"
+ )
+ self._sm_apply(b, spec["fields"], values)
+ # NX 2606's legacy ValidateBuilderData returned varying nonzero/negative
+ # values for an unchanged valid tab. Use the supported Builder.Validate
+ # contract and transactional native commit instead (native api23 evidence).
+ if not b.Validate():
+ raise NXToolError("NX_SHEET_METAL_INVALID", "Native builder validation failed")
+ f = b.CommitFeature()
+ self._update_model()
+ result = self._engineering_result(f)
+ result.update(
+ operation=operation,
+ native_feature_type=f.FeatureType,
+ requested_parameters=parameters,
+ expressions=[self._expression_record(exp) for exp in f.GetExpressions()],
+ )
+ if operation == "flat_pattern":
+ result["model_view_name"] = b.FlatPatternViewName
+ return result
+ finally:
+ b.Destroy()
+
+ def _sheet_metal_info(self, body=None, offset=0, limit=50):
+ import NXOpen.Features.SheetMetal as SM
+
+ manager = self._sm_manager()
+ part = self._work_part()
+ bodies = [self._sm_reference(body, "body")] if body else list(part.Bodies)
+ result = []
+ for b in bodies:
+ record = {
+ "body": self._reference(b, "body", part, "Body"),
+ "sheet_metal": bool(manager.IsSheetmetalBody(b)),
+ }
+ if record["sheet_metal"]:
+ faces, states = manager.GetInnerBendFaces(b)
+ bends = []
+ for face, state in zip(faces, states, strict=True):
+ params = manager.GetBendParameters(face)
+ bends.append(
+ {
+ "face": self._reference(face, "face", part, "Face"),
+ "state": enum_name(state, SM.SheetmetalBendState),
+ "inner_radius": params.InnerRadius,
+ "angle_degrees": params.BendAngle,
+ "neutral_factor": params.NeutralFactor,
+ }
+ )
+ record.update(
+ thickness=manager.GetBodyThickness(b), bends=bends, bend_count=len(bends)
+ )
+ result.append(record)
+ return {
+ **page(result, offset, limit),
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ "angle_convention": "degrees",
+ }
+
+ def _sheet_metal_defaults(self):
+ import NXOpen.Preferences as P
+
+ manager = self._work_part().Preferences.SheetMetalPreferences
+ values = {}
+ for key, method in {
+ "thickness": "GetMaterialThickness",
+ "bend_radius": "GetBendRadius",
+ "neutral_factor": "GetNeutralFactor",
+ "bend_relief_width": "GetBendReliefWidth",
+ "bend_relief_depth": "GetBendReliefDepth",
+ }.items():
+ self._require_api(manager, method)
+ expression = getattr(manager, method)()
+ values[key] = self._expression_record(expression) if expression else None
+ return {
+ "parameters": values,
+ "parameter_entry": enum_name(
+ manager.GetParameterEntryType(), P.SheetMetalPreferencesBuilder.ParameterEntryTypes
+ ),
+ "bend_definition": enum_name(
+ manager.GetBendDefinitionMethod(),
+ P.SheetMetalPreferencesBuilder.BendDefinitionMethodOptions,
+ ),
+ "bend_table": manager.GetBendTable(),
+ "bend_allowance_formula": manager.GetBendAllowanceFormula(),
+ "bend_deduction_formula": manager.GetBendDeductionFormula(),
+ "material": manager.GetMaterialName(),
+ "tool": manager.GetToolName(),
+ "material_catalog_status": "unavailable",
+ "warnings": [
+ "NX v2606 GetMaterialNames raised a native memory-access error on this installation; automatic catalog enumeration is disabled."
+ ],
+ "units": self._units(),
+ }
+
+ def _set_sheet_metal_defaults(
+ self,
+ parameter_entry=None,
+ thickness=None,
+ bend_radius=None,
+ neutral_factor=None,
+ bend_relief_width=None,
+ bend_relief_depth=None,
+ material=None,
+ tool=None,
+ bend_definition=None,
+ bend_table=None,
+ bend_allowance_formula=None,
+ bend_deduction_formula=None,
+ ):
+ import NXOpen.Preferences as P
+
+ numeric = {
+ "MaterialThickness": thickness,
+ "BendRadius": bend_radius,
+ "NeutralFactor": neutral_factor,
+ "BendReliefWidth": bend_relief_width,
+ "BendReliefDepth": bend_relief_depth,
+ }
+ if all(
+ v is None
+ for v in [
+ *numeric.values(),
+ parameter_entry,
+ material,
+ tool,
+ bend_definition,
+ bend_table,
+ bend_allowance_formula,
+ bend_deduction_formula,
+ ]
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Provide at least one sheet-metal default")
+ for name, value in numeric.items():
+ if value is not None:
+ finite(value, name, name == "MaterialThickness")
+ if value < 0 or name == "NeutralFactor" and value > 1:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Defaults require nonnegative lengths and a neutral factor within 0–1",
+ )
+ manager = self._work_part().Preferences.SheetMetalPreferences
+ for name in (material, tool):
+ if name is not None and (
+ not isinstance(name, str)
+ or not name
+ or len(name) > 256
+ or any(ord(c) < 32 for c in name)
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Use a valid installed material/tool name")
+ for formula in (bend_allowance_formula, bend_deduction_formula):
+ if formula is not None and (
+ not isinstance(formula, str)
+ or not formula
+ or len(formula) > 1024
+ or any(ord(c) < 32 for c in formula)
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Use a nonempty NX bend formula without control characters",
+ )
+ table = self.workspace.resolve(bend_table) if bend_table is not None else None
+ if table is not None and not table.is_file():
+ raise NXToolError("NX_FILE_NOT_FOUND", "Upload the bend table to the workspace first")
+ if parameter_entry is not None and parameter_entry not in {
+ "Value",
+ "MaterialTable",
+ "ToolIdTable",
+ }:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown sheet-metal parameter entry mode")
+ bend_methods = {
+ "NeutralFactorValue",
+ "BendTable",
+ "BendAllowanceFormula",
+ "MaterialTable",
+ "ToolTable",
+ "BendAllowanceTable",
+ "BendDeductionTable",
+ "BendDeductionFormula",
+ "Din6935Formula",
+ }
+ if bend_definition is not None and bend_definition not in bend_methods:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unknown native bend-definition method")
+ b = manager.CreateSheetMetalPreferencesBuilder()
+ try:
+ if parameter_entry is not None:
+ b.ParameterEntryType = getattr(
+ P.SheetMetalPreferencesBuilder.ParameterEntryTypes, parameter_entry
+ )
+ if material is not None:
+ b.SetMaterial(material)
+ if tool is not None:
+ b.SetToolName(tool)
+ if bend_definition is not None:
+ choices = P.SheetMetalPreferencesBuilder.BendDefinitionMethodOptions
+ b.SetBendDefinitionMethod(getattr(choices, bend_definition))
+ if table is not None:
+ b.SetBendTable(str(table))
+ if bend_allowance_formula is not None:
+ b.BendAllowanceFormula = bend_allowance_formula
+ if bend_deduction_formula is not None:
+ b.BendDeductionFormula = bend_deduction_formula
+ for name, value in numeric.items():
+ if value is not None:
+ getattr(b, name).RightHandSide = str(value)
+ b.Commit()
+ self._update_model()
+ finally:
+ b.Destroy()
+ result = self._sheet_metal_defaults()
+ for key, requested in {
+ "material": material,
+ "tool": tool,
+ "parameter_entry": parameter_entry,
+ "bend_definition": bend_definition,
+ }.items():
+ if requested is not None and result[key] != requested:
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED",
+ "Native sheet-metal default did not retain requested " + key,
+ )
+ for key, requested in {
+ "thickness": thickness,
+ "bend_radius": bend_radius,
+ "neutral_factor": neutral_factor,
+ "bend_relief_width": bend_relief_width,
+ "bend_relief_depth": bend_relief_depth,
+ }.items():
+ if requested is not None:
+ actual = result["parameters"][key]["value"]
+ if actual is None or abs(actual - requested) > 1e-9 * max(1, abs(requested)):
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED",
+ "Native sheet-metal default did not retain requested " + key,
+ )
+ return result
+
+ def _export_flat_pattern(
+ self,
+ flat_pattern,
+ path,
+ format="dxf",
+ revision="R2018",
+ bend_up=True,
+ bend_down=True,
+ bend_tangent=False,
+ interior_cutout=True,
+ interior_feature=False,
+ inner_mold=False,
+ outer_mold=False,
+ added_top=False,
+ added_bottom=False,
+ tolerance=0.01,
+ ):
+ import hashlib
+ import uuid
+
+ import NXOpen.Features.SheetMetal as SM
+
+ if format not in {"dxf", "geo"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Flat pattern format must be dxf or geo")
+ destination = self.workspace.resolve(path)
+ if destination.suffix.lower() != "." + format or destination.exists():
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Use an unused matching .dxf or .geo output path"
+ )
+ tolerance = finite(tolerance, "tolerance", True)
+ feature = self._sm_reference(flat_pattern, "feature")
+ if feature.FeatureType != "FLAT_PATTERN":
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a native Flat Pattern feature")
+ options = {
+ "BendUp": bend_up,
+ "BendDown": bend_down,
+ "BendTangent": bend_tangent,
+ "InteriorCutout": interior_cutout,
+ "InteriorFeature": interior_feature,
+ "InnerMold": inner_mold,
+ "OuterMold": outer_mold,
+ "AddedTop": added_top,
+ "AddedBottom": added_bottom,
+ }
+ if any(type(v) is not bool for v in options.values()):
+ raise NXToolError("NX_INVALID_ARGUMENT", "Export options must be boolean")
+ if format == "geo" and (inner_mold or outer_mold or revision != "R2018"):
+ raise NXToolError("NX_INVALID_ARGUMENT", "DXF options are not supported for GEO")
+ revisions = SM.ExportFlatPatternBuilder.DxfRevisionType
+ if revision not in {
+ "R12",
+ "R13",
+ "R14",
+ "R2000",
+ "R2004",
+ "R2005",
+ "R2007",
+ "R20102012",
+ "R20132016",
+ "R2018",
+ }:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Unsupported native DXF revision")
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ temporary = destination.with_name(".nx-export-" + uuid.uuid4().hex + destination.suffix)
+ try:
+ builder = self._sm_manager().CreateExportFlatPatternBuilder()
+ try:
+ builder.FlatPattern.Value = feature
+ builder.OutputFile = str(temporary)
+ builder.Type = getattr(
+ SM.ExportFlatPatternBuilder.FileType, "Dxf" if format == "dxf" else "TrumpfGeo"
+ )
+ builder.ExportLocation = SM.ExportFlatPatternBuilder.ExportLocationOptions.Native
+ if format == "dxf":
+ builder.DxfRevision = getattr(revisions, revision)
+ builder.DeviationalTolerance = tolerance
+ for name, value in options.items():
+ setattr(builder, name, value)
+ builder.Commit()
+ finally:
+ builder.Destroy()
+ if not temporary.is_file() or not temporary.stat().st_size:
+ raise NXToolError(
+ "NX_EXPORT_FAILED", "NX did not produce a nonempty flat-pattern file"
+ )
+ data = temporary.read_bytes()
+ if (
+ format == "dxf"
+ and b"SECTION" not in data
+ and not data.startswith(b"AutoCAD Binary DXF")
+ ):
+ raise NXToolError("NX_EXPORT_FAILED", "Native output is not recognized as DXF")
+ with destination.open("xb") as output:
+ try:
+ output.write(data)
+ output.flush()
+ except BaseException:
+ output.close()
+ destination.unlink(missing_ok=True)
+ raise
+ return {
+ "path": str(destination),
+ "format": format,
+ "revision": revision if format == "dxf" else None,
+ "options": options,
+ "tolerance": tolerance,
+ "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "units": self._units(),
+ "coordinate_frame": "native_flat_pattern",
+ "flat_pattern": self._reference(
+ feature, "feature", self._work_part(), "FlatPattern"
+ ),
+ }
+ finally:
+ temporary.unlink(missing_ok=True)
+
+ def _add_flat_pattern_view(self, drawing, flat_pattern, position=None):
+ part = self._work_part()
+ feature = self._sm_reference(flat_pattern, "feature")
+ if feature.FeatureType != "FLAT_PATTERN":
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Select a native Flat Pattern feature")
+ sheet = self._drawing_object(drawing, "drawing_sheet")
+ point = [100.0, 100.0] if position is None else position
+ if not isinstance(point, list) or len(point) != 2:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "position requires two sheet coordinates in sheet units"
+ )
+ point = [finite(v, "position") for v in point]
+ definition = self._sm_manager().CreateFlatPatternBuilder(feature)
+ try:
+ model_view = part.ModelingViews.FindObject(definition.FlatPatternViewName)
+ finally:
+ definition.Destroy()
+ sheet.Open()
+ builder = part.DraftingViews.CreateBaseViewBuilder(None)
+ try:
+ builder.SelectModelView.SelectedView = model_view
+ builder.Placement.Placement.SetValue(None, None, self._sheet_point3d(sheet, point))
+ view = builder.Commit()
+ finally:
+ builder.Destroy()
+ self._place_drawing_view(view, sheet, point)
+ return {
+ "view": self._reference(view, "drawing_view", part, "Flat pattern view"),
+ "drawing": self._reference(sheet, "drawing_sheet", part, "Drawing sheet"),
+ "flat_pattern": self._reference(feature, "feature", part, "FlatPattern"),
+ "model_view_name": model_view.Name,
+ **self._drawing_coordinates(sheet, point),
+ "units": self._sheet_units(sheet),
+ }
+
+ def _sheet_metal_annotation(
+ self, kind, body, position, faces=None, annotation=None, automatic=False
+ ):
+ import NXOpen.Annotations as A
+
+ if kind not in {"body", "bend"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Annotation kind must be body or bend")
+ target = self._sm_reference(body, "body")
+ if not self._sm_manager().IsSheetmetalBody(target):
+ raise NXToolError("NX_NOT_SHEET_METAL", "Select an actual native sheet-metal body")
+ point = self._sm_validate({"position": {"kind": "point3d"}}, {"position": position})[
+ "position"
+ ]
+ if kind == "body" and faces is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Body annotation does not take bend faces")
+ selected = []
+ if kind == "bend":
+ selected = self._sm_validate({"faces": {"kind": "select_faces"}}, {"faces": faces})[
+ "faces"
+ ]
+ for face in selected:
+ if face.GetBody() != target:
+ raise NXToolError(
+ "NX_OBJECT_OWNER_MISMATCH", "Bend faces must belong to the selected body"
+ )
+ self._sm_manager().GetBendParameters(face)
+ existing = self._sm_reference(annotation, "annotation") if annotation else None
+ units = self._units()
+ if kind == "body":
+ measured = {"thickness": float(self._sm_manager().GetBodyThickness(target))}
+ lines = [
+ "Sheet metal (managed measurement)"
+ if automatic
+ else "Sheet metal (measured snapshot)",
+ f"Thickness: {measured['thickness']:.3f} {units}",
+ ]
+ else:
+ measured = {"bends": []}
+ lines = [
+ "Bend data (managed measurement)" if automatic else "Bend data (measured snapshot)"
+ ]
+ for index, face in enumerate(selected, 1):
+ data = self._sm_manager().GetBendParameters(face)
+ values = {
+ "inner_radius": float(data.InnerRadius),
+ "angle_degrees": float(data.BendAngle),
+ "neutral_factor": float(data.NeutralFactor),
+ }
+ measured["bends"].append(values)
+ lines.append(
+ f"Bend {index}: R {values['inner_radius']:.3f} {units}; "
+ f"angle {values['angle_degrees']:.3f} deg; K {values['neutral_factor']:.3f}"
+ )
+ builder = self._sm_manager().CreateSheetMetalPmiBuilder(existing)
+ try:
+ builder.PMIType = getattr(A.SheetMetalPMIBuilder.Types, kind.capitalize())
+ builder.SelectedBody.Value = target
+ builder.SelectedFace.Clear()
+ if selected:
+ builder.SelectedFace.Add(selected)
+ builder.AssociatedObjects.Nxobjects.Clear()
+ builder.AssociatedObjects.Nxobjects.Add(selected or [target])
+ builder.Text.TextBlock.SetText(lines)
+ builder.Origin.SetInferRelativeToGeometry(True)
+ builder.Origin.Origin.SetValue(None, None, self.nxopen.Point3d(*point))
+ if not builder.Validate():
+ raise NXToolError(
+ "NX_ANNOTATION_INVALID", "Native sheet-metal annotation validation failed"
+ )
+ result = builder.Commit()
+ values = list(builder.GetCommittedObjects())
+ if not values and result is not None:
+ values = [result]
+ if not values:
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED", "NX returned no sheet-metal annotations"
+ )
+ self._update_model()
+ if automatic:
+ for value in values:
+ self._remember_managed_annotation(value, kind, target, selected, measured)
+ elif existing:
+ for value in values:
+ value.SetAttribute("NX_MCP_MEASURED_PMI_V1", "disabled")
+ refs = [
+ self._reference(value, "annotation", self._work_part(), "Sheet-metal PMI")
+ for value in values
+ ]
+ return {
+ "annotations": refs,
+ "annotation_count": len(refs),
+ "created": [] if existing else refs,
+ "modified": refs if existing else [],
+ "text": [
+ list(value.GetText()) if hasattr(value, "GetText") else None for value in values
+ ],
+ "body": self._reference(target, "body", self._work_part(), "Body"),
+ "kind": kind,
+ "measured_parameters": measured,
+ "text_semantics": "Automatically refreshed after MCP model mutations; explicitly refresh after manual NX changes"
+ if automatic
+ else "Measured snapshot; call this tool with the annotation ID to refresh after model edits",
+ "automatic": automatic,
+ "requested_position": point,
+ "units": self._units(),
+ "coordinate_frame": "work_part",
+ }
+ finally:
+ builder.Destroy()
diff --git a/src/nx_mcp/sheet_metal_catalog.json b/src/nx_mcp/sheet_metal_catalog.json
new file mode 100644
index 0000000..e1dcbd5
--- /dev/null
+++ b/src/nx_mcp/sheet_metal_catalog.json
@@ -0,0 +1,5513 @@
+{
+ "tab": {
+ "builder": "TabBuilder",
+ "factory": "CreateTabFeatureBuilder",
+ "fields": {
+ "is_secondary": {
+ "path": "IsSecondary",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "material_side": {
+ "path": "MaterialSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "TabBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "multi_thickness_property": {
+ "path": "MultiThicknessProperty",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "multi_thickness_toggle": {
+ "path": "MultiThicknessToggle",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "zone_name": {
+ "path": "ZoneName",
+ "getter": false,
+ "kind": "string"
+ }
+ }
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "target_body": {
+ "path": "TargetBody",
+ "getter": false,
+ "kind": "body",
+ "assign": true
+ },
+ "thickness": {
+ "path": "Thickness",
+ "getter": false,
+ "kind": "expression"
+ },
+ "thickness_side": {
+ "path": "ThicknessSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "TabBuilderThicknessSideOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "multi_bends": {
+ "kind": "object_list",
+ "container": "MultiBendPropertiesList",
+ "sequence": "FeatureBendPropertiesList",
+ "creator": "CreateFeatureProperties",
+ "fields": {
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "value": {
+ "path": "Value",
+ "getter": false,
+ "kind": "expression"
+ }
+ },
+ "required": []
+ }
+ },
+ "required": [
+ "section",
+ "thickness"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "100 x 80 mm rectangular tab, thickness 2 mm; successful save and close",
+ "edit_status": "experimental"
+ },
+ "flange": {
+ "builder": "MultiFlangeBuilder",
+ "factory": "CreateMultiFlangeBuilder",
+ "fields": {
+ "match_face": {
+ "path": "MatchFace",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "MultiFlangeBuilderMatchFaceOptions",
+ "values": [
+ "NotSet",
+ "UntilSelected"
+ ]
+ },
+ "multi_thickness_property": {
+ "path": "MultiThicknessProperty",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "multi_thickness_toggle": {
+ "path": "MultiThicknessToggle",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "zone_name": {
+ "path": "ZoneName",
+ "getter": false,
+ "kind": "string"
+ }
+ }
+ },
+ "plane": {
+ "path": "Plane",
+ "getter": false,
+ "kind": "plane",
+ "assign": true
+ },
+ "flanges": {
+ "kind": "flange_list",
+ "required": [
+ "edges",
+ "length",
+ "angle"
+ ],
+ "fields": {
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "distance1": {
+ "path": "Distance1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "distance2": {
+ "path": "Distance2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "edges": {
+ "path": "Edges",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "inset": {
+ "path": "Inset",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlangeBendPropertiesBuilderInsets",
+ "values": [
+ "MaterialInside",
+ "MaterialOutside",
+ "BendOutside",
+ "MaterialInsideOML"
+ ]
+ },
+ "keypoint": {
+ "path": "Keypoint",
+ "getter": false,
+ "kind": "point",
+ "assign": true,
+ "description": "Work-part coordinate point for length_option=Keypoint; that mode is not verified by the numeric-length fixture."
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length_option": {
+ "path": "LengthOption",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlangeBendPropertiesBuilderLengthOptions",
+ "values": [
+ "Value",
+ "Keypoint"
+ ],
+ "description": "Value uses length; Keypoint uses the supplied keypoint. This contract still requires length and angle in every flange entry. The recorded example verifies numeric length only."
+ },
+ "length_reference": {
+ "path": "LengthReference",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlangeBendPropertiesBuilderLengthReferences",
+ "values": [
+ "Inside",
+ "Outside",
+ "Web",
+ "Tangent"
+ ]
+ },
+ "miter": {
+ "path": "Miter",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "offset": {
+ "path": "Offset",
+ "getter": false,
+ "kind": "expression"
+ },
+ "point": {
+ "path": "Point",
+ "getter": false,
+ "kind": "point",
+ "assign": true
+ },
+ "reverse_direction_length": {
+ "path": "ReverseDirectionLength",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "reverse_direction_offset": {
+ "path": "ReverseDirectionOffset",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_recipe": {
+ "path": "UseRecipe",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ },
+ "width_option": {
+ "path": "WidthOption",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlangeBendPropertiesBuilderWidthOptions",
+ "values": [
+ "Full",
+ "AtCenter",
+ "AtEnd",
+ "FromEnd",
+ "FromBothEnds"
+ ],
+ "description": "Full uses the selected edge width. The native AtCenter fixture supplies width=60 on a 100 mm edge. AtEnd/FromEnd/FromBothEnds positioning combinations are not verified by that fixture."
+ },
+ "bend_options": {
+ "kind": "object",
+ "path": "BendOptions",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ },
+ "description": "For explicit relief/radius/neutral-factor overrides, set the corresponding use_global_* flag false; otherwise the native global setting may remain active."
+ }
+ }
+ }
+ },
+ "required": [
+ "flanges"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "90-degree flange, radius and neutral factor read-back; length edited from 20 to 25 mm",
+ "edit_status": "tested_scoped",
+ "example_parameters": {
+ "flanges": [
+ {
+ "edges": [
+ "$input_1"
+ ],
+ "length": 20,
+ "length_reference": "Inside",
+ "angle": 90
+ }
+ ]
+ },
+ "example_evidence": {
+ "source": "examples/validate_sheet_metal.py",
+ "validation": "native_fixture",
+ "scope": "100 x 80 x 2 mm XY tab; edge nearest [50,0,0]; 20 mm Inside-length flange at 90 degrees. Numeric values are fixture millimeters."
+ },
+ "prerequisites": [
+ "Select a boundary edge of an existing sheet-metal tab, not an arbitrary solid edge. The recorded fixture uses the 100 mm edge at y=0, z=0 on a 100 x 80 x 2 mm tab.",
+ "Each flanges entry requires edges, length and angle, including when replacing the list during an edit. Supplying flanges replaces the complete native flange list.",
+ "For width_option=AtCenter, supply width; the native channel fixture used width=60 on a 100 mm edge. Other width modes require their native positioning inputs and are not covered by this example.",
+ "For length_option=Keypoint, supply keypoint in work-part coordinates as well as the contract-required length and angle. Keypoint mode is exposed but this example only verifies numeric length."
+ ]
+ },
+ "contour_flange": {
+ "builder": "ContourFlangeBuilder",
+ "factory": "CreateContourFlangeFeatureBuilder",
+ "fields": {
+ "thickness": {
+ "path": "GetThickness",
+ "getter": true,
+ "kind": "expression"
+ },
+ "sweep_distance": {
+ "path": "GetSweepDistance",
+ "getter": true,
+ "kind": "expression"
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "edge_chain": {
+ "path": "EdgeChain",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "is_secondary": {
+ "path": "IsSecondary",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "miter_options": {
+ "path": "MiterOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "start_value": {
+ "path": "GetStartValue",
+ "getter": true,
+ "kind": "expression"
+ },
+ "end_value": {
+ "path": "GetEndValue",
+ "getter": true,
+ "kind": "expression"
+ },
+ "closed_corner_diameter": {
+ "path": "GetClosedCornerDiameter",
+ "getter": true,
+ "kind": "expression"
+ },
+ "blend_miter": {
+ "path": "BlendMiter",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "closed_corner_gap": {
+ "path": "ClosedCornerGap",
+ "getter": false,
+ "kind": "expression"
+ },
+ "closed_corner_type": {
+ "path": "ClosedCornerType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "MiterOptionsClosedCornerTypeOptions",
+ "values": [
+ "NotSet",
+ "Open",
+ "Closed",
+ "CircularCutout",
+ "UCutout",
+ "VCutout"
+ ]
+ },
+ "closed_cornerv_angle1": {
+ "path": "ClosedCornerVAngle1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "closed_cornerv_angle2": {
+ "path": "ClosedCornerVAngle2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_treatment_offset": {
+ "path": "CornerTreatmentOffset",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_treatment_origin_type": {
+ "path": "CornerTreatmentOriginType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "MiterOptionsCornerTreatmentOriginTypeOptions",
+ "values": [
+ "BendCenter",
+ "CornerPoint"
+ ]
+ },
+ "end_type": {
+ "path": "EndType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "MiterOptionsTypeOptions",
+ "values": [
+ "NormalToSourceFace",
+ "NormalToThicknessFace"
+ ]
+ },
+ "miter_corner": {
+ "path": "MiterCorner",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "miter_interior_corners_if_necessary": {
+ "path": "MiterInteriorCornersIfNecessary",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "miter_root_radius": {
+ "path": "MiterRootRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "position": {
+ "path": "Position",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "MiterOptionsPositionOptions",
+ "values": [
+ "NotSet",
+ "Start",
+ "End",
+ "Both"
+ ]
+ },
+ "start_type": {
+ "path": "StartType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "MiterOptionsTypeOptions",
+ "values": [
+ "NormalToSourceFace",
+ "NormalToThicknessFace"
+ ]
+ },
+ "three_bend_corner_flange_clearance": {
+ "path": "ThreeBendCornerFlangeClearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "use_normal_cutout_method": {
+ "path": "UseNormalCutoutMethod",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "sweep_side": {
+ "path": "SweepSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ContourFlangeBuilderSweepSideOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "sweep_type": {
+ "path": "SweepType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ContourFlangeBuilderSweepTypeOptions",
+ "values": [
+ "Finite",
+ "Symmetric",
+ "ToEnd",
+ "Chain"
+ ]
+ },
+ "thickness_side": {
+ "path": "ThicknessSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ContourFlangeBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ }
+ },
+ "required": [
+ "section",
+ "thickness",
+ "sweep_distance"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "thickness": 2,
+ "sweep_distance": 40
+ },
+ "edit_status": "experimental"
+ },
+ "lofted_flange": {
+ "builder": "LoftedFlangeBuilder",
+ "factory": "CreateLoftedFlangeFeatureBuilder",
+ "fields": {
+ "thickness": {
+ "path": "GetThickness",
+ "getter": true,
+ "kind": "expression"
+ },
+ "auto_relief_type": {
+ "path": "AutoReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LoftedFlangeBuilderAutoReliefTypes",
+ "values": [
+ "Linear",
+ "Spherical"
+ ]
+ },
+ "bend_divide_parameter": {
+ "path": "BendDivideParameter",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LoftedFlangeBuilderBendDivideParameters",
+ "values": [
+ "NumberOfBendSegments",
+ "MaximumChordHeight",
+ "MaximumSegmentLength",
+ "MaximumSegmentAngle"
+ ]
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "bending_method": {
+ "path": "BendingMethod",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LoftedFlangeBuilderBendingMethods",
+ "values": [
+ "Formed",
+ "Advanced",
+ "Bends"
+ ]
+ },
+ "end_section": {
+ "path": "EndSection",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "end_section_point": {
+ "path": "EndSectionPoint",
+ "getter": false,
+ "kind": "point3d",
+ "assign": true
+ },
+ "index_mark_length": {
+ "path": "IndexMarkLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "is_secondary": {
+ "path": "IsSecondary",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "maximum_chord_height": {
+ "path": "MaximumChordHeight",
+ "getter": false,
+ "kind": "expression"
+ },
+ "maximum_segment_angle": {
+ "path": "MaximumSegmentAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "maximum_segment_length": {
+ "path": "MaximumSegmentLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "number_of_bend_segments": {
+ "path": "NumberOfBendSegments",
+ "getter": false,
+ "kind": "integer"
+ },
+ "start_section": {
+ "path": "StartSection",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "start_section_point": {
+ "path": "StartSectionPoint",
+ "getter": false,
+ "kind": "point3d",
+ "assign": true
+ },
+ "thickness_side": {
+ "path": "ThicknessSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LoftedFlangeBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "trim_end_plates": {
+ "path": "TrimEndPlates",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_segmented_bends": {
+ "path": "UseSegmentedBends",
+ "getter": false,
+ "kind": "boolean"
+ }
+ },
+ "required": [
+ "start_section",
+ "end_section",
+ "thickness"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "start_section": "$input_1",
+ "end_section": "$input_2",
+ "thickness": 2,
+ "start_section_point": [
+ 0,
+ 0,
+ 0
+ ],
+ "end_section_point": [
+ 0,
+ 0,
+ 40
+ ],
+ "number_of_bend_segments": 8,
+ "bending_method": "Formed",
+ "use_segmented_bends": false
+ },
+ "edit_status": "experimental"
+ },
+ "bend": {
+ "builder": "BendBuilder",
+ "factory": "CreateBendFeatureBuilder",
+ "fields": {
+ "bend_angle": {
+ "path": "GetBendAngle",
+ "getter": true,
+ "kind": "expression"
+ },
+ "bend_location": {
+ "path": "BendLocation",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendBuilderBendLocationOptions",
+ "values": [
+ "OuterMoldLine",
+ "CenterLine",
+ "InnerMoldLine",
+ "MaterialInside",
+ "MaterialOutside"
+ ]
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "direction": {
+ "path": "Direction",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendBuilderBendDirectionOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "extend_profile": {
+ "path": "ExtendProfile",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "fixed_side": {
+ "path": "FixedSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendBuilderFixedSideOptions",
+ "values": [
+ "SectionSideLeft",
+ "SectionSideRight"
+ ]
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "target_face": {
+ "path": "TargetFace",
+ "getter": false,
+ "kind": "face",
+ "assign": true
+ }
+ },
+ "required": [
+ "section",
+ "target_face",
+ "bend_angle"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "target_face": "$input_2",
+ "bend_angle": 90
+ },
+ "edit_status": "experimental"
+ },
+ "jog": {
+ "builder": "JogBuilder",
+ "factory": "CreateJogFeatureBuilder",
+ "fields": {
+ "height": {
+ "path": "GetHeight",
+ "getter": true,
+ "kind": "expression"
+ },
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_location": {
+ "path": "BendLocation",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "JogBuilderBendLocationOptions",
+ "values": [
+ "MaterialInside",
+ "MaterialOutside",
+ "BendOutside"
+ ]
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "dimension_type": {
+ "path": "DimensionType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "JogBuilderDimensionTypeOptions",
+ "values": [
+ "Offset",
+ "Full"
+ ]
+ },
+ "direction_type": {
+ "path": "DirectionType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "JogBuilderDirectionTypeOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "extend_profile": {
+ "path": "ExtendProfile",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "fixed_side": {
+ "path": "FixedSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "JogBuilderFixedSideOptions",
+ "values": [
+ "SectionSideLeft",
+ "SectionSideRight"
+ ]
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "target_face": {
+ "path": "TargetFace",
+ "getter": false,
+ "kind": "face",
+ "assign": true
+ }
+ },
+ "required": [
+ "section",
+ "target_face",
+ "height"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "target_face": "$input_2",
+ "height": 10,
+ "angle": 90
+ },
+ "edit_status": "experimental"
+ },
+ "hem": {
+ "builder": "HemFlangeBuilder",
+ "factory": "CreateHemFlangeFeatureBuilder",
+ "fields": {
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "HemFlangeBuilderBendReliefOptions",
+ "values": [
+ "Square",
+ "Round",
+ "NotSet"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "edge_chain": {
+ "path": "EdgeChain",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "equal_radii": {
+ "path": "EqualRadii",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "first_bend_radius": {
+ "path": "FirstBendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "first_flange_length": {
+ "path": "FirstFlangeLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "inset_type": {
+ "path": "InsetType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "HemFlangeBuilderInsetTypeOptions",
+ "values": [
+ "MaterialInside",
+ "MaterialOutside",
+ "BendOutside"
+ ]
+ },
+ "miter_angle": {
+ "path": "MiterAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "second_bend_radius": {
+ "path": "SecondBendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "second_flange_length": {
+ "path": "SecondFlangeLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "sweep_angle": {
+ "path": "SweepAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "HemFlangeBuilderTypeOptions",
+ "values": [
+ "ClosedHemType",
+ "OpenHemType",
+ "SFlangeHemType",
+ "CurlHemType",
+ "OpenLoopHemType",
+ "ClosedLoopHemType",
+ "CenteredLoopHemType"
+ ]
+ },
+ "use_miter": {
+ "path": "UseMiter",
+ "getter": false,
+ "kind": "boolean"
+ }
+ },
+ "required": [
+ "edge_chain"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "edge_chain": [
+ "$input_1"
+ ],
+ "type": "OpenHemType",
+ "first_flange_length": 10,
+ "first_bend_radius": 2
+ },
+ "edit_status": "experimental"
+ },
+ "normal_cutout": {
+ "builder": "NormalCutoutBuilder",
+ "factory": "CreateNormalCutoutFeatureBuilder",
+ "fields": {
+ "cut_type": {
+ "path": "CutType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "NormalCutoutBuilderCutTypeOptions",
+ "values": [
+ "ThicknessCut",
+ "MidPlaneCut",
+ "NearestFaceCut"
+ ]
+ },
+ "depth": {
+ "path": "Depth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "depth_side": {
+ "path": "DepthSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "NormalCutoutBuilderDepthSideOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide",
+ "Symmetric"
+ ]
+ },
+ "depth_type": {
+ "path": "DepthType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "NormalCutoutBuilderDepthTypeOptions",
+ "values": [
+ "Finite",
+ "FromTo",
+ "ThroughNext",
+ "ThroughAll"
+ ]
+ },
+ "from": {
+ "path": "From",
+ "getter": false,
+ "kind": "face",
+ "assign": true
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "section_side": {
+ "path": "SectionSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "NormalCutoutBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "target_body": {
+ "path": "TargetBody",
+ "getter": false,
+ "kind": "body",
+ "assign": true
+ },
+ "to": {
+ "path": "To",
+ "getter": false,
+ "kind": "face",
+ "assign": true
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "NormalCutoutBuilderTypeOptions",
+ "values": [
+ "SketchType",
+ "NonPlanarCurveType"
+ ]
+ }
+ },
+ "required": [
+ "section",
+ "target_body"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "target_body": "$input_2",
+ "depth_type": "ThroughAll",
+ "depth_side": "Symmetric",
+ "depth": 5
+ },
+ "edit_status": "experimental"
+ },
+ "bead": {
+ "builder": "BeadBuilder",
+ "factory": "CreateBeadFeatureBuilder",
+ "fields": {
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "cross_section_type": {
+ "path": "CrossSectionType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BeadBuilderCrossSectionTypeOptions",
+ "values": [
+ "Circular",
+ "Ushaped",
+ "Vshaped"
+ ]
+ },
+ "die_radius": {
+ "path": "DieRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "end_type": {
+ "path": "EndType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BeadBuilderEndTypeOptions",
+ "values": [
+ "Punched",
+ "Lanced",
+ "Formed",
+ "Tapered"
+ ]
+ },
+ "height": {
+ "path": "Height",
+ "getter": false,
+ "kind": "expression"
+ },
+ "height_side": {
+ "path": "HeightSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BeadBuilderHeightSideOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "include_rounding": {
+ "path": "IncludeRounding",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "minimum_tool_clearance": {
+ "path": "MinimumToolClearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "punch_radius": {
+ "path": "PunchRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "punched_width": {
+ "path": "PunchedWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "radius": {
+ "path": "Radius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "taper_distance": {
+ "path": "TaperDistance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ }
+ },
+ "required": [
+ "section"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "height": 3,
+ "width": 8,
+ "cross_section_type": "Ushaped",
+ "angle": 45,
+ "end_type": "Formed",
+ "punched_width": 8,
+ "radius": 3,
+ "punch_radius": 2,
+ "die_radius": 2,
+ "taper_distance": 5
+ },
+ "edit_status": "experimental"
+ },
+ "dimple": {
+ "builder": "DimpleBuilder",
+ "factory": "CreateDimpleFeatureBuilder",
+ "fields": {
+ "depth": {
+ "path": "GetDepth",
+ "getter": true,
+ "kind": "expression"
+ },
+ "taper_angle": {
+ "path": "GetTaperAngle",
+ "getter": true,
+ "kind": "expression"
+ },
+ "punch_radius": {
+ "path": "GetPunchRadius",
+ "getter": true,
+ "kind": "expression"
+ },
+ "die_radius": {
+ "path": "GetDieRadius",
+ "getter": true,
+ "kind": "expression"
+ },
+ "fillet_radius": {
+ "path": "GetFilletRadius",
+ "getter": true,
+ "kind": "expression"
+ },
+ "depth_type": {
+ "path": "DepthType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DimpleBuilderDepthTypeOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "dimension_type": {
+ "path": "DimensionType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DimpleBuilderDimensionTypeOptions",
+ "values": [
+ "Offset",
+ "Full"
+ ]
+ },
+ "fillet_section_corners": {
+ "path": "FilletSectionCorners",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_rounding": {
+ "path": "IncludeRounding",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "minimum_tool_clearance": {
+ "path": "MinimumToolClearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "section_side": {
+ "path": "SectionSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DimpleBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "sidewall_type": {
+ "path": "SidewallType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DimpleBuilderSidewallTypeOptions",
+ "values": [
+ "Outside",
+ "Inside"
+ ]
+ }
+ },
+ "required": [
+ "section"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "depth": 3,
+ "taper_angle": 15,
+ "include_rounding": false
+ },
+ "edit_status": "experimental"
+ },
+ "louver": {
+ "builder": "LouverBuilder",
+ "factory": "CreateLouverFeatureBuilder",
+ "fields": {
+ "depth": {
+ "path": "Depth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "depth_side": {
+ "path": "DepthSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LouverBuilderDepthSideOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "die_radius": {
+ "path": "DieRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "end_type": {
+ "path": "EndType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LouverBuilderEndTypeOptions",
+ "values": [
+ "Formed",
+ "Lanced"
+ ]
+ },
+ "include_rounding": {
+ "path": "IncludeRounding",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "minimum_tool_clearance": {
+ "path": "MinimumToolClearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "section_side": {
+ "path": "SectionSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LouverBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ }
+ },
+ "required": [
+ "section"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "depth": 3,
+ "width": 12,
+ "end_type": "Lanced",
+ "include_rounding": false,
+ "depth_side": "SectionNormalSide",
+ "section_side": "Left",
+ "minimum_tool_clearance": 0.1
+ },
+ "edit_status": "experimental"
+ },
+ "drawn_cutout": {
+ "builder": "DrawnCutoutBuilder",
+ "factory": "CreateDrawnCutoutFeatureBuilder",
+ "fields": {
+ "corner_radius": {
+ "path": "CornerRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "cutout_depth": {
+ "path": "CutoutDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "depth_type": {
+ "path": "DepthType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DrawnCutoutBuilderDepthTypeOptions",
+ "values": [
+ "SectionNormalSide",
+ "SectionReverseNormalSide"
+ ]
+ },
+ "fillet_section_corners": {
+ "path": "FilletSectionCorners",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_rounding": {
+ "path": "IncludeRounding",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "minimum_tool_clearance": {
+ "path": "MinimumToolClearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "radius_of_die": {
+ "path": "RadiusOfDie",
+ "getter": false,
+ "kind": "expression"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "section_side": {
+ "path": "SectionSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DrawnCutoutBuilderSectionSideOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "side_angle": {
+ "path": "SideAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "sidewall_type": {
+ "path": "SidewallType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "DrawnCutoutBuilderSidewallTypeOptions",
+ "values": [
+ "Outside",
+ "Inside"
+ ]
+ }
+ },
+ "required": [
+ "section"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "cutout_depth": 5,
+ "side_angle": 60,
+ "include_rounding": false
+ },
+ "edit_status": "experimental"
+ },
+ "gusset": {
+ "builder": "GussetBuilder",
+ "factory": "CreateGussetBuilder",
+ "fields": {
+ "bend_face": {
+ "path": "BendFace",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ },
+ "corner_radius": {
+ "path": "CornerRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "datum_plane": {
+ "path": "DatumPlane",
+ "getter": false,
+ "kind": "plane",
+ "assign": true
+ },
+ "depth": {
+ "path": "Depth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "die_radius": {
+ "path": "DieRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "placement_count": {
+ "path": "PlacementCount",
+ "getter": false,
+ "kind": "integer"
+ },
+ "placement_distance": {
+ "path": "PlacementDistance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "placement_spacing": {
+ "path": "PlacementSpacing",
+ "getter": false,
+ "kind": "expression"
+ },
+ "placement_type": {
+ "path": "PlacementType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "GussetBuilderPlacementTypes",
+ "values": [
+ "Single",
+ "Fit",
+ "Fill",
+ "Fixed"
+ ]
+ },
+ "punch_radius": {
+ "path": "PunchRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": false
+ },
+ "shape": {
+ "path": "Shape",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "GussetBuilderShapes",
+ "values": [
+ "Square",
+ "Round"
+ ]
+ },
+ "side_angle": {
+ "path": "SideAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "start_edge": {
+ "path": "StartEdge",
+ "getter": false,
+ "kind": "select_edge",
+ "assign": false
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "GussetBuilderTypes",
+ "values": [
+ "AutomaticProfile",
+ "UserDefinedProfile"
+ ]
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ },
+ "width_side": {
+ "path": "WidthSide",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "GussetBuilderWidthSides",
+ "values": [
+ "Side1",
+ "Side2",
+ "Symmetric"
+ ]
+ }
+ },
+ "required": [
+ "bend_face"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "bend_face": "$input_1",
+ "type": "AutomaticProfile",
+ "datum_plane": {
+ "origin": [
+ 50,
+ 0,
+ 0
+ ],
+ "normal": [
+ 1,
+ 0,
+ 0
+ ]
+ },
+ "width": 10,
+ "depth": 6,
+ "side_angle": 45,
+ "punch_radius": 1,
+ "die_radius": 1
+ },
+ "edit_status": "experimental"
+ },
+ "edge_rip": {
+ "builder": "EdgeRipBuilder",
+ "factory": "CreateEdgeRipFeatureBuilder",
+ "fields": {
+ "blend_radius": {
+ "path": "BlendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "blend_sharp_corners": {
+ "path": "BlendSharpCorners",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "end_cap_shape": {
+ "path": "EndCapShape",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "EdgeRipBuilderEndCapShapeOptions",
+ "values": [
+ "Square",
+ "Round"
+ ]
+ },
+ "reverse_width_direction": {
+ "path": "ReverseWidthDirection",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "symmetric": {
+ "path": "Symmetric",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_system_width": {
+ "path": "UseSystemWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ },
+ "rip_edges": {
+ "kind": "reference_list",
+ "objects": "edge",
+ "method": "SetRipEdges"
+ }
+ },
+ "required": [],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "section": "$input_1",
+ "width": 0.5,
+ "symmetric": true,
+ "use_system_width": false,
+ "end_cap_shape": "Round"
+ },
+ "edit_status": "experimental"
+ },
+ "break_corner": {
+ "builder": "BreakCornerBuilder",
+ "factory": "CreateBreakCornerFeatureBuilder",
+ "fields": {
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BreakCornerBuilderTypeOptions",
+ "values": [
+ "Fillet",
+ "ChamferEqualSetback"
+ ]
+ },
+ "value": {
+ "path": "Value",
+ "getter": false,
+ "kind": "expression"
+ },
+ "edges": {
+ "kind": "reference_list",
+ "objects": "edge",
+ "method": "SetEdges"
+ },
+ "faces": {
+ "kind": "reference_list",
+ "objects": "face",
+ "method": "SetFaces"
+ }
+ },
+ "required": [],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "edges": [
+ "$input_1"
+ ],
+ "type": "Fillet",
+ "value": 3
+ },
+ "edit_status": "experimental"
+ },
+ "closed_corner": {
+ "builder": "ClosedCornerBuilder",
+ "factory": "CreateClosedCornerFeatureBuilder",
+ "fields": {
+ "blend_miter": {
+ "path": "BlendMiter",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "blend_miter_radius": {
+ "path": "BlendMiterRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "cut_method": {
+ "path": "CutMethod",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ClosedCornerBuilderCutMethodTypes",
+ "values": [
+ "ByTool",
+ "ByPath"
+ ]
+ },
+ "diameter": {
+ "path": "Diameter",
+ "getter": false,
+ "kind": "expression"
+ },
+ "gap": {
+ "path": "Gap",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length1": {
+ "path": "Length1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length2": {
+ "path": "Length2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "limit_edge1": {
+ "path": "LimitEdge1",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "limit_edge2": {
+ "path": "LimitEdge2",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "miter_corner": {
+ "path": "MiterCorner",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "offset": {
+ "path": "Offset",
+ "getter": false,
+ "kind": "expression"
+ },
+ "origin": {
+ "path": "Origin",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ClosedCornerBuilderOriginTypes",
+ "values": [
+ "BendCenter",
+ "CornerPoint"
+ ]
+ },
+ "overlap": {
+ "path": "Overlap",
+ "getter": false,
+ "kind": "expression"
+ },
+ "overlap_type": {
+ "path": "OverlapType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ClosedCornerBuilderOverlapTypeOptions",
+ "values": [
+ "NotSet",
+ "Side1",
+ "Side2"
+ ]
+ },
+ "punch_radius": {
+ "path": "PunchRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "rectangular_length": {
+ "path": "RectangularLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "rectangular_width": {
+ "path": "RectangularWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "treatment_type": {
+ "path": "TreatmentType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ClosedCornerBuilderTreatmentTypeOptions",
+ "values": [
+ "Open",
+ "Closed",
+ "CircularCutout",
+ "UCutout",
+ "VCutout",
+ "RectangularCutout"
+ ]
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ClosedCornerBuilderTypes",
+ "values": [
+ "CloseAndRelief",
+ "Relief"
+ ]
+ },
+ "v_angle1": {
+ "path": "VAngle1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "v_angle2": {
+ "path": "VAngle2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "face_pairs": {
+ "kind": "face_pairs"
+ }
+ },
+ "required": [
+ "face_pairs"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "face_pairs": [
+ [
+ "$input_1",
+ "$input_2"
+ ]
+ ],
+ "gap": 0.5,
+ "overlap_type": "NotSet",
+ "treatment_type": "CircularCutout",
+ "diameter": 6
+ },
+ "edit_status": "experimental"
+ },
+ "three_bend_corner": {
+ "builder": "ThreeBendCornerBuilder",
+ "factory": "CreateThreeBendCornerFeatureBuilder",
+ "fields": {
+ "blend_miter": {
+ "path": "BlendMiter",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "blend_miter_radius": {
+ "path": "BlendMiterRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_gap": {
+ "path": "CornerGap",
+ "getter": false,
+ "kind": "expression"
+ },
+ "cut_method": {
+ "path": "CutMethod",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ThreeBendCornerBuilderCutMethodTypes",
+ "values": [
+ "ByTool",
+ "ByPath"
+ ]
+ },
+ "diameter": {
+ "path": "Diameter",
+ "getter": false,
+ "kind": "expression"
+ },
+ "flange_clearance": {
+ "path": "FlangeClearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length1": {
+ "path": "Length1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length2": {
+ "path": "Length2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "miter_corner": {
+ "path": "MiterCorner",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "miter_root_radius": {
+ "path": "MiterRootRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "offset": {
+ "path": "Offset",
+ "getter": false,
+ "kind": "expression"
+ },
+ "origin_type": {
+ "path": "OriginType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ThreeBendCornerBuilderOriginTypes",
+ "values": [
+ "BendCenter",
+ "CornerPoint"
+ ]
+ },
+ "punch_radius": {
+ "path": "PunchRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "treatment_type": {
+ "path": "TreatmentType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ThreeBendCornerBuilderTreatmentTypeOptions",
+ "values": [
+ "Open",
+ "Closed",
+ "CircularCutout",
+ "UCutout",
+ "VCutout"
+ ]
+ },
+ "v_cutout_angle1": {
+ "path": "VCutoutAngle1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "v_cutout_angle2": {
+ "path": "VCutoutAngle2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "face_pairs": {
+ "kind": "face_pairs"
+ }
+ },
+ "required": [
+ "face_pairs"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "face_pairs": [
+ [
+ "$input_1",
+ "$input_2"
+ ]
+ ],
+ "corner_gap": 1,
+ "flange_clearance": 1,
+ "treatment_type": "Open",
+ "diameter": 4
+ },
+ "edit_status": "experimental"
+ },
+ "bridge_bend": {
+ "builder": "BridgeTransitionBuilder",
+ "factory": "CreateBridgeTransitionBuilder",
+ "fields": {
+ "alternate_solution": {
+ "path": "AlternateSolution",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "bend_options_of_flex_transition": {
+ "path": "BendOptionsOfFlexTransition",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "bend_options_of_sketched_transition": {
+ "path": "BendOptionsOfSketchedTransition",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "end_edge": {
+ "path": "EndEdge",
+ "getter": false,
+ "kind": "select_edge",
+ "assign": false
+ },
+ "end_edge_lateral_offset": {
+ "path": "EndEdgeLateralOffset",
+ "getter": false,
+ "kind": "expression"
+ },
+ "flex_bend_divide_parameter_type": {
+ "path": "FlexBendDivideParameterType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderFlexBendDivideParameterOptions",
+ "values": [
+ "BendsSegments",
+ "MaximumChordHeight",
+ "MaximumSegmentLength",
+ "MaximumSegmentAngle"
+ ]
+ },
+ "flex_bending_method_type": {
+ "path": "FlexBendingMethodType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderFlexBendingMethodOptions",
+ "values": [
+ "Formed",
+ "Bends"
+ ]
+ },
+ "flex_endpoint": {
+ "path": "FlexEndpoint",
+ "getter": false,
+ "kind": "point",
+ "assign": true
+ },
+ "flex_length": {
+ "path": "FlexLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "flex_length_type": {
+ "path": "FlexLengthType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderFlexLengthOptions",
+ "values": [
+ "ScaleFactor",
+ "Value"
+ ]
+ },
+ "flex_maximum_chord_height": {
+ "path": "FlexMaximumChordHeight",
+ "getter": false,
+ "kind": "expression"
+ },
+ "flex_maximum_segment_angle": {
+ "path": "FlexMaximumSegmentAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "flex_maximum_segment_length": {
+ "path": "FlexMaximumSegmentLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "flex_number_of_bend_segments": {
+ "path": "FlexNumberOfBendSegments",
+ "getter": false,
+ "kind": "integer"
+ },
+ "fold_bend_options": {
+ "path": "FoldBendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "fold_transition_type": {
+ "path": "FoldTransitionType",
+ "getter": false,
+ "kind": "integer"
+ },
+ "inset_type": {
+ "path": "InsetType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderInsetOptions",
+ "values": [
+ "MaterialInside",
+ "MaterialOutside"
+ ]
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length_tolerance": {
+ "path": "LengthTolerance",
+ "getter": false,
+ "kind": "number"
+ },
+ "multi_thickness_property": {
+ "path": "MultiThicknessProperty",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "multi_thickness_toggle": {
+ "path": "MultiThicknessToggle",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "zone_name": {
+ "path": "ZoneName",
+ "getter": false,
+ "kind": "string"
+ }
+ }
+ },
+ "plane": {
+ "path": "Plane",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ },
+ "point": {
+ "path": "Point",
+ "getter": false,
+ "kind": "point",
+ "assign": true
+ },
+ "reference_geometry_plane": {
+ "path": "ReferenceGeometryPlane",
+ "getter": false,
+ "kind": "plane",
+ "assign": true
+ },
+ "scale_factor": {
+ "path": "ScaleFactor",
+ "getter": false,
+ "kind": "number"
+ },
+ "section": {
+ "path": "Section",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "smart_divide": {
+ "path": "SmartDivide",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "start_and_end_parameters_equal": {
+ "path": "StartAndEndParametersEqual",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "start_edge": {
+ "path": "StartEdge",
+ "getter": false,
+ "kind": "select_edge",
+ "assign": false
+ },
+ "start_edge_lateral_offset": {
+ "path": "StartEdgeLateralOffset",
+ "getter": false,
+ "kind": "expression"
+ },
+ "trim_or_extend_to_bend": {
+ "path": "TrimOrExtendToBend",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderTypeOptions",
+ "values": [
+ "Zu",
+ "Fold",
+ "Flex",
+ "Sketched"
+ ]
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ },
+ "width_direction": {
+ "path": "WidthDirection",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderWidthDirectionOptions",
+ "values": [
+ "Left",
+ "Right"
+ ]
+ },
+ "width_type": {
+ "path": "WidthType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BridgeTransitionBuilderWidthOptions",
+ "values": [
+ "Finite",
+ "Symmetric",
+ "FullStartEdge",
+ "FullEndEdge",
+ "FullBothEdges"
+ ]
+ },
+ "zu_end_edge_bend_options": {
+ "path": "ZuEndEdgeBendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "zu_start_edge_bend_options": {
+ "path": "ZuStartEdgeBendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ }
+ },
+ "required": [
+ "start_edge",
+ "end_edge"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "start_edge": "$input_1",
+ "end_edge": "$input_2",
+ "type": "Zu",
+ "width_type": "FullBothEdges",
+ "length": 20,
+ "width": 80
+ },
+ "edit_status": "experimental"
+ },
+ "bend_taper": {
+ "builder": "BendTaperBuilder",
+ "factory": "CreateBendTaperBuilder",
+ "fields": {
+ "bend_taper_angle1": {
+ "path": "BendTaperAngle1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_taper_angle2": {
+ "path": "BendTaperAngle2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_taper_input_method1": {
+ "path": "BendTaperInputMethod1",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderBendTaperInputMethod",
+ "values": [
+ "Angle",
+ "Distance",
+ "ToEnd"
+ ]
+ },
+ "bend_taper_input_method2": {
+ "path": "BendTaperInputMethod2",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderBendTaperInputMethod",
+ "values": [
+ "Angle",
+ "Distance",
+ "ToEnd"
+ ]
+ },
+ "bend_taper_select_bend_face": {
+ "path": "BendTaperSelectBendFace",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "bend_taper_type1": {
+ "path": "BendTaperType1",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderBendTaperType",
+ "values": [
+ "Linear",
+ "Tangent",
+ "Square"
+ ]
+ },
+ "bend_taper_type2": {
+ "path": "BendTaperType2",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderBendTaperType",
+ "values": [
+ "Linear",
+ "Tangent",
+ "Square"
+ ]
+ },
+ "end_radius1": {
+ "path": "EndRadius1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "end_radius2": {
+ "path": "EndRadius2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "infer_radius1": {
+ "path": "InferRadius1",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "infer_radius2": {
+ "path": "InferRadius2",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "start_radius1": {
+ "path": "StartRadius1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "start_radius2": {
+ "path": "StartRadius2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "start_type1": {
+ "path": "StartType1",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderStartType",
+ "values": [
+ "TaperFromBend",
+ "TaperFromWeb"
+ ]
+ },
+ "start_type2": {
+ "path": "StartType2",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderStartType",
+ "values": [
+ "TaperFromBend",
+ "TaperFromWeb"
+ ]
+ },
+ "stationary_entity": {
+ "path": "StationaryEntity",
+ "getter": false,
+ "kind": "face_or_edge",
+ "assign": true
+ },
+ "taper_distance1": {
+ "path": "TaperDistance1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "taper_distance2": {
+ "path": "TaperDistance2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "taper_sides": {
+ "path": "TaperSides",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderBendTaperSides",
+ "values": [
+ "Both",
+ "Side1",
+ "Side2",
+ "Symmetric"
+ ]
+ },
+ "web_taper_angle1": {
+ "path": "WebTaperAngle1",
+ "getter": false,
+ "kind": "expression"
+ },
+ "web_taper_angle2": {
+ "path": "WebTaperAngle2",
+ "getter": false,
+ "kind": "expression"
+ },
+ "web_taper_type1": {
+ "path": "WebTaperType1",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderWebTaperType",
+ "values": [
+ "NotSet",
+ "Face",
+ "FaceChain"
+ ]
+ },
+ "web_taper_type2": {
+ "path": "WebTaperType2",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendTaperBuilderWebTaperType",
+ "values": [
+ "NotSet",
+ "Face",
+ "FaceChain"
+ ]
+ }
+ },
+ "required": [
+ "bend_taper_select_bend_face",
+ "stationary_entity"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "bend_taper_select_bend_face": [
+ "$input_1"
+ ],
+ "stationary_entity": "$input_2",
+ "bend_taper_input_method1": "Distance",
+ "bend_taper_input_method2": "Distance",
+ "taper_distance1": 5,
+ "taper_distance2": 5,
+ "taper_sides": "Both"
+ },
+ "edit_status": "experimental"
+ },
+ "resize_bend_angle": {
+ "builder": "ResizeBendAngleBuilder",
+ "factory": "CreateResizeBendAngleBuilder",
+ "fields": {
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_face": {
+ "path": "BendFace",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ },
+ "keep_radius_fixed": {
+ "path": "KeepRadiusFixed",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "reference_edge": {
+ "path": "ReferenceEdge",
+ "getter": false,
+ "kind": "select_edge",
+ "assign": false
+ },
+ "reference_face": {
+ "path": "ReferenceFace",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ }
+ },
+ "required": [
+ "bend_face",
+ "angle",
+ "reference_face"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "bend_face": "$input_1",
+ "angle": 110,
+ "reference_face": "$input_2"
+ },
+ "edit_status": "experimental"
+ },
+ "resize_bend_radius": {
+ "builder": "ResizeBendRadiusBuilder",
+ "factory": "CreateResizeBendRadiusFeatureBuilder",
+ "fields": {
+ "bend_faces": {
+ "path": "BendFaces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "reference_entity": {
+ "path": "ReferenceEntity",
+ "getter": false,
+ "kind": "select_face_or_edge",
+ "assign": false
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ResizeBendRadiusBuilderTypes",
+ "values": [
+ "FixedFoldedLength",
+ "FixedUnfoldedLength"
+ ]
+ }
+ },
+ "required": [
+ "bend_faces",
+ "bend_radius",
+ "reference_entity"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "bend_faces": [
+ "$input_1"
+ ],
+ "bend_radius": 3,
+ "reference_entity": "$input_2"
+ },
+ "edit_status": "experimental"
+ },
+ "resize_neutral_factor": {
+ "builder": "ResizeNeutralFactorBuilder",
+ "factory": "CreateResizeNeutralFactorBuilder",
+ "fields": {
+ "bend_faces": {
+ "path": "BendFaces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "use_global": {
+ "path": "UseGlobal",
+ "getter": false,
+ "kind": "boolean"
+ }
+ },
+ "required": [
+ "bend_faces",
+ "neutral_factor"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "bend_faces": [
+ "$input_1"
+ ],
+ "neutral_factor": 0.4
+ },
+ "edit_status": "experimental"
+ },
+ "convert": {
+ "builder": "ConvertToSheetmetalBuilder",
+ "factory": "CreateConvertToSheetmetalFeatureBuilder",
+ "fields": {
+ "additional_faces_to_convert": {
+ "path": "AdditionalFacesToConvert",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "base_face": {
+ "path": "BaseFace",
+ "getter": false,
+ "kind": "face",
+ "assign": true
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "ConvertToSheetmetalBuilderBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "is_uniform_thickness": {
+ "path": "IsUniformThickness",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "local_base_face": {
+ "path": "LocalBaseFace",
+ "getter": false,
+ "kind": "face",
+ "assign": true
+ },
+ "local_region_faces": {
+ "path": "LocalRegionFaces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "maintain_zero_bend_radius": {
+ "path": "MaintainZeroBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "rip_section": {
+ "path": "RipSection",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "rip_edges": {
+ "kind": "reference_list",
+ "objects": "edge",
+ "method": "SetRipEdges"
+ },
+ "corners": {
+ "kind": "object_list",
+ "container": null,
+ "sequence": "CornerList",
+ "creator": "CreateConvertInputListItem",
+ "fields": {
+ "corner_faces": {
+ "path": "CornerFaces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ }
+ },
+ "required": [
+ "corner_faces"
+ ]
+ }
+ },
+ "required": [
+ "base_face"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "base_face": "$input_1",
+ "is_uniform_thickness": true
+ },
+ "edit_status": "experimental"
+ },
+ "from_solid": {
+ "builder": "SheetMetalFromSolidBuilder",
+ "factory": "CreateSheetMetalFromSolidBuilder",
+ "fields": {
+ "bend_edges": {
+ "path": "BendEdges",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "hide_original": {
+ "path": "HideOriginal",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "reverse_direction": {
+ "path": "ReverseDirection",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "thickness": {
+ "path": "Thickness",
+ "getter": false,
+ "kind": "expression"
+ },
+ "thickness_tolerance": {
+ "path": "ThicknessTolerance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "use_global_thickness": {
+ "path": "UseGlobalThickness",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "web_faces": {
+ "path": "WebFaces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "bend_properties": {
+ "kind": "object_list",
+ "container": null,
+ "sequence": "BendPropertiesList",
+ "creator": "CreateSheetMetalFromSolidBendProperties",
+ "fields": {
+ "bend_edges": {
+ "path": "BendEdges",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ }
+ },
+ "required": [
+ "bend_edges"
+ ]
+ }
+ },
+ "required": [
+ "web_faces",
+ "thickness"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "web_faces": [
+ "$input_1",
+ "$input_2"
+ ],
+ "bend_properties": [
+ {
+ "bend_edges": [
+ "$input_3"
+ ],
+ "bend_options": {
+ "bend_radius": 2,
+ "use_global_bend_radius": false
+ }
+ }
+ ],
+ "thickness": 2,
+ "use_global_thickness": false,
+ "hide_original": true
+ },
+ "edit_status": "experimental"
+ },
+ "flat_solid": {
+ "builder": "FlatSolidBuilder",
+ "factory": "CreateFlatSolidFeatureBuilder",
+ "fields": {
+ "added_geometry": {
+ "path": "AddedGeometry",
+ "getter": false,
+ "kind": "section",
+ "assign": false
+ },
+ "associative": {
+ "path": "Associative",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "auto_assign_component_layer": {
+ "path": "AutoAssignComponentLayer",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "auto_associate_objects": {
+ "path": "AutoAssociateObjects",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "fix_at_timestamp": {
+ "path": "FixAtTimestamp",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "flat_body_color": {
+ "path": "FlatBodyColor",
+ "getter": false,
+ "kind": "integer"
+ },
+ "flat_body_layer": {
+ "path": "FlatBodyLayer",
+ "getter": false,
+ "kind": "integer"
+ },
+ "inner_corner_treatment": {
+ "path": "InnerCornerTreatment",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "treatment_type": {
+ "path": "TreatmentType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "CornerTreatmentBuilderCornerTreatmentType",
+ "values": [
+ "NotSet",
+ "Chamfer",
+ "Radius"
+ ]
+ },
+ "use_global": {
+ "path": "UseGlobal",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "value": {
+ "path": "Value",
+ "getter": false,
+ "kind": "expression"
+ }
+ }
+ },
+ "layer_setting": {
+ "path": "LayerSetting",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlatSolidBuilderLayerSettingOption",
+ "values": [
+ "Default",
+ "Preference",
+ "Specify"
+ ]
+ },
+ "orientation": {
+ "path": "Orientation",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlatSolidBuilderOrientationType",
+ "values": [
+ "Default",
+ "Edge",
+ "Csys"
+ ]
+ },
+ "orientation_csys": {
+ "path": "OrientationCsys",
+ "getter": false,
+ "kind": "csys",
+ "assign": true
+ },
+ "orientation_from_csys": {
+ "path": "OrientationFromCsys",
+ "getter": false,
+ "kind": "csys",
+ "assign": true
+ },
+ "outer_corner_treatment": {
+ "path": "OuterCornerTreatment",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "treatment_type": {
+ "path": "TreatmentType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "CornerTreatmentBuilderCornerTreatmentType",
+ "values": [
+ "NotSet",
+ "Chamfer",
+ "Radius"
+ ]
+ },
+ "use_global": {
+ "path": "UseGlobal",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "value": {
+ "path": "Value",
+ "getter": false,
+ "kind": "expression"
+ }
+ }
+ },
+ "reference_vertex": {
+ "path": "ReferenceVertex",
+ "getter": false,
+ "kind": "point3d",
+ "assign": true
+ },
+ "stationary_face": {
+ "path": "StationaryFace",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ },
+ "transform_components": {
+ "path": "TransformComponents",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlatSolidBuilderTransformComponentsOption",
+ "values": [
+ "NotSet",
+ "Body",
+ "Csys",
+ "BodyAndComponent"
+ ]
+ },
+ "transform_pin_andcc_p": {
+ "path": "TransformPinAndCCP",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "transform_restriction_areas": {
+ "path": "TransformRestrictionAreas",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "transform_to_absolute_csys": {
+ "path": "TransformToAbsoluteCsys",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "x_axis_edge": {
+ "path": "XAxisEdge",
+ "getter": false,
+ "kind": "select_edge",
+ "assign": false
+ }
+ },
+ "required": [
+ "stationary_face",
+ "x_axis_edge"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "stationary_face": "$input_1",
+ "x_axis_edge": "$input_2",
+ "associative": true
+ },
+ "edit_status": "experimental"
+ },
+ "flat_pattern": {
+ "builder": "FlatPatternBuilder",
+ "factory": "CreateFlatPatternBuilder",
+ "fields": {
+ "added_geometry": {
+ "path": "AddedGeometry",
+ "getter": false,
+ "kind": "section",
+ "assign": false
+ },
+ "associative": {
+ "path": "Associative",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "bend_direction_down_text": {
+ "path": "BendDirectionDownText",
+ "getter": false,
+ "kind": "string"
+ },
+ "bend_direction_up_text": {
+ "path": "BendDirectionUpText",
+ "getter": false,
+ "kind": "string"
+ },
+ "fix_at_timestamp": {
+ "path": "FixAtTimestamp",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "hole_treatment": {
+ "path": "HoleTreatment",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "diameter": {
+ "path": "Diameter",
+ "getter": false,
+ "kind": "expression"
+ },
+ "treatment": {
+ "path": "Treatment",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "HoleTreatmentBuilderTreatmentType",
+ "values": [
+ "NotSet",
+ "Centermark",
+ "HoleAndCentermark"
+ ]
+ },
+ "use_global": {
+ "path": "UseGlobal",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "inner_corner_treatment": {
+ "path": "InnerCornerTreatment",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "treatment_type": {
+ "path": "TreatmentType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "CornerTreatmentBuilderCornerTreatmentType",
+ "values": [
+ "NotSet",
+ "Chamfer",
+ "Radius"
+ ]
+ },
+ "use_global": {
+ "path": "UseGlobal",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "value": {
+ "path": "Value",
+ "getter": false,
+ "kind": "expression"
+ }
+ }
+ },
+ "keep_flat_solid_external": {
+ "path": "KeepFlatSolidExternal",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "orientation": {
+ "path": "Orientation",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "FlatSolidBuilderOrientationType",
+ "values": [
+ "Default",
+ "Edge",
+ "Csys"
+ ]
+ },
+ "orientation_csys": {
+ "path": "OrientationCsys",
+ "getter": false,
+ "kind": "csys",
+ "assign": true
+ },
+ "orientation_from_csys": {
+ "path": "OrientationFromCsys",
+ "getter": false,
+ "kind": "csys",
+ "assign": true
+ },
+ "outer_corner_treatment": {
+ "path": "OuterCornerTreatment",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "treatment_type": {
+ "path": "TreatmentType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "CornerTreatmentBuilderCornerTreatmentType",
+ "values": [
+ "NotSet",
+ "Chamfer",
+ "Radius"
+ ]
+ },
+ "use_global": {
+ "path": "UseGlobal",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "value": {
+ "path": "Value",
+ "getter": false,
+ "kind": "expression"
+ }
+ }
+ },
+ "reference_vertex": {
+ "path": "ReferenceVertex",
+ "getter": false,
+ "kind": "point3d",
+ "assign": true
+ },
+ "show_interior_feature_curves": {
+ "path": "ShowInteriorFeatureCurves",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "transform_to_absolute_csys": {
+ "path": "TransformToAbsoluteCsys",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "upward_face": {
+ "path": "UpwardFace",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ },
+ "x_axis_edge": {
+ "path": "XAxisEdge",
+ "getter": false,
+ "kind": "select_edge",
+ "assign": false,
+ "description": "Select a valid orientation edge on the selected upward web face. After forming, reacquire its current boundary/tangent edge rather than reusing the original tab outer-edge location."
+ }
+ },
+ "required": [
+ "upward_face",
+ "x_axis_edge"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native developed bracket, DXF/GEO export; flat pattern drawing and visually inspected PDF",
+ "edit_status": "experimental"
+ },
+ "advanced_flange": {
+ "builder": "AdvancedFlangeBuilder",
+ "factory": "CreateAdvancedFlangeBuilder",
+ "fields": {
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "edges": {
+ "path": "Edges",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "end_adjustment": {
+ "path": "EndAdjustment",
+ "getter": false,
+ "kind": "expression"
+ },
+ "faces": {
+ "path": "Faces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face",
+ "description": "Reference-face collector for ToReference. NX requires faces belonging to the same body as the selected edges. Tested web and formed-wall face combinations were rejected; do not infer an arbitrary face is geometrically valid."
+ },
+ "flat_pattern_compensation_at_end": {
+ "path": "FlatPatternCompensationAtEnd",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "flat_pattern_compensation_at_start": {
+ "path": "FlatPatternCompensationAtStart",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "infer_length": {
+ "path": "InferLength",
+ "getter": false,
+ "kind": "boolean",
+ "description": "Native inference toggle. ByValue=true committed on the numeric-length fixture but produced identical geometry; this is not proof of inferred-length behavior. Reference-driven inference remains unverified."
+ },
+ "inset": {
+ "path": "Inset",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "AdvancedFlangeBuilderInsets",
+ "values": [
+ "MaterialInside",
+ "MaterialOutside",
+ "BendOutside",
+ "MaterialInsideOML"
+ ]
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length_reference": {
+ "path": "LengthReference",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "AdvancedFlangeBuilderLengthReferences",
+ "values": [
+ "Inside",
+ "Outside",
+ "Web",
+ "Din6935"
+ ]
+ },
+ "plane1": {
+ "path": "Plane1",
+ "getter": false,
+ "kind": "plane",
+ "assign": true,
+ "description": "First end-limit plane in work-part coordinates. Native 100x80x2 tab fixture: origin [10,0,0], normal [1,0,0] trims 10 mm from the flange end."
+ },
+ "plane2": {
+ "path": "Plane2",
+ "getter": false,
+ "kind": "plane",
+ "assign": true,
+ "description": "Second end-limit plane. Native paired fixture uses plane1 at x=10 and plane2 at x=90, both normal +X, to trim both ends. Plane2-only mode is not established."
+ },
+ "reverse_direction": {
+ "path": "ReverseDirection",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "reverse_trim_side": {
+ "path": "ReverseTrimSide",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "start_adjustment": {
+ "path": "StartAdjustment",
+ "getter": false,
+ "kind": "expression"
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "AdvancedFlangeBuilderTypes",
+ "values": [
+ "ByValue",
+ "ToReference"
+ ],
+ "description": "ByValue and ToReference are native builder modes. Only the example with native default mode and numeric length is verified; ToReference geometry combinations remain unverified."
+ }
+ },
+ "required": [
+ "edges"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "edges": [
+ "$input_1"
+ ],
+ "length": 20,
+ "angle": 90
+ },
+ "edit_status": "experimental",
+ "example_evidence": {
+ "source": "tests/fixtures/sheet-metal-examples.json#/operations/advanced_flange",
+ "validation": "native_fixture",
+ "scope": "suite2 AdvancedFlange creation with length=20, angle=90 on a boundary edge of a 100 x 80 x 2 mm XY tab; optional reference-driven modes were not exercised."
+ },
+ "prerequisites": [
+ "Numeric-length ByValue mode and one/two end-limit planes were exercised natively on NX v2606.",
+ "ToReference without faces fails preflight. Same-body web and formed-wall face selections were rejected by NX with rollback; a successful reference-driven recipe is not established.",
+ "infer_length=true in ByValue mode did not change the numeric-length fixture."
+ ]
+ },
+ "variational_flange": {
+ "builder": "VariationalFlangeBuilder",
+ "factory": "CreateVariationalFlangeBuilder",
+ "fields": {
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "angle_law_type": {
+ "path": "AngleLawType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "VariationalFlangeBuilderLawTypes",
+ "values": [
+ "Constant",
+ "Linear"
+ ]
+ },
+ "edges": {
+ "path": "Edges",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "end_angle": {
+ "path": "EndAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "end_length": {
+ "path": "EndLength",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "length_law_type": {
+ "path": "LengthLawType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "VariationalFlangeBuilderLawTypes",
+ "values": [
+ "Constant",
+ "Linear"
+ ]
+ },
+ "length_reference": {
+ "path": "LengthReference",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "VariationalFlangeBuilderLengthReferences",
+ "values": [
+ "Inside",
+ "Outside",
+ "Web",
+ "Tangent"
+ ]
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "radius": {
+ "path": "Radius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "reverse_direction": {
+ "path": "ReverseDirection",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "start_angle": {
+ "path": "StartAngle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "start_length": {
+ "path": "StartLength",
+ "getter": false,
+ "kind": "expression"
+ }
+ },
+ "required": [
+ "edges"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "edges": [
+ "$input_1"
+ ],
+ "length_law_type": "Linear",
+ "start_length": 10,
+ "end_length": 20,
+ "angle": 90,
+ "radius": 2,
+ "neutral_factor": 0.33
+ },
+ "edit_status": "experimental"
+ },
+ "joggle": {
+ "builder": "JoggleBuilder",
+ "factory": "CreateJoggleBuilder",
+ "fields": {
+ "adjustment": {
+ "path": "Adjustment",
+ "getter": false,
+ "kind": "expression"
+ },
+ "end_plane": {
+ "path": "EndPlane",
+ "getter": false,
+ "kind": "plane",
+ "assign": true
+ },
+ "flat_pattern_compensation": {
+ "path": "FlatPatternCompensation",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "limit_type": {
+ "path": "LimitType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "JoggleBuilderLimitTypes",
+ "values": [
+ "Single",
+ "Twin"
+ ]
+ },
+ "side1_options": {
+ "path": "Side1Options",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "clearance": {
+ "path": "Clearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "offset_radius": {
+ "path": "OffsetRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "runout": {
+ "path": "Runout",
+ "getter": false,
+ "kind": "expression"
+ },
+ "stationary_radius": {
+ "path": "StationaryRadius",
+ "getter": false,
+ "kind": "expression"
+ }
+ }
+ },
+ "side2_options": {
+ "path": "Side2Options",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "clearance": {
+ "path": "Clearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "offset_radius": {
+ "path": "OffsetRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "runout": {
+ "path": "Runout",
+ "getter": false,
+ "kind": "expression"
+ },
+ "stationary_radius": {
+ "path": "StationaryRadius",
+ "getter": false,
+ "kind": "expression"
+ }
+ }
+ },
+ "start_plane": {
+ "path": "StartPlane",
+ "getter": false,
+ "kind": "plane",
+ "assign": true
+ },
+ "symmetric_sides": {
+ "path": "SymmetricSides",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_material_table": {
+ "path": "UseMaterialTable",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "inputs": {
+ "kind": "joggle_list",
+ "required": [
+ "faces",
+ "depth"
+ ],
+ "fields": {
+ "depth": {
+ "path": "Depth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "faces": {
+ "path": "Faces",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "face"
+ },
+ "reverse_direction": {
+ "path": "ReverseDirection",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ }
+ },
+ "required": [
+ "inputs"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "inputs": [
+ {
+ "faces": [
+ "$input_1"
+ ],
+ "depth": 5
+ }
+ ],
+ "start_plane": {
+ "origin": [
+ 50,
+ 0,
+ 0
+ ],
+ "normal": [
+ 1,
+ 0,
+ 0
+ ]
+ },
+ "limit_type": "Single",
+ "side1_options": {
+ "runout": 10,
+ "stationary_radius": 2,
+ "offset_radius": 2,
+ "clearance": 0.2
+ }
+ },
+ "edit_status": "experimental"
+ },
+ "lightening_cutout": {
+ "builder": "LighteningCutoutBuilder",
+ "factory": "CreateLighteningCutoutBuilder",
+ "fields": {
+ "angle": {
+ "path": "Angle",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_options": {
+ "path": "BendOptions",
+ "getter": false,
+ "kind": "object",
+ "fields": {
+ "bend_radius": {
+ "path": "BendRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_depth": {
+ "path": "BendReliefDepth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "bend_relief_type": {
+ "path": "BendReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsBendReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "Square",
+ "Round"
+ ]
+ },
+ "bend_relief_width": {
+ "path": "BendReliefWidth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "corner_relief_type": {
+ "path": "CornerReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BendOptionsCornerReliefTypeOptions",
+ "values": [
+ "NotSet",
+ "BendOnly",
+ "BendAndFace",
+ "BendAndFaceChain"
+ ]
+ },
+ "die_tool_id": {
+ "path": "DieToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "die_tool_id_name": {
+ "path": "DieToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "extend_bend_relief": {
+ "path": "ExtendBendRelief",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_relief_in_width": {
+ "path": "IncludeReliefInWidth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "neutral_factor": {
+ "path": "NeutralFactor",
+ "getter": false,
+ "kind": "expression"
+ },
+ "override_tool_set": {
+ "path": "OverrideToolSet",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "punch_tool_id": {
+ "path": "PunchToolId",
+ "getter": false,
+ "kind": "integer"
+ },
+ "punch_tool_id_name": {
+ "path": "PunchToolIdName",
+ "getter": false,
+ "kind": "string"
+ },
+ "use_global_bend_radius": {
+ "path": "UseGlobalBendRadius",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_neutral_factor": {
+ "path": "UseGlobalNeutralFactor",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_depth": {
+ "path": "UseGlobalReliefDepth",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "use_global_relief_width": {
+ "path": "UseGlobalReliefWidth",
+ "getter": false,
+ "kind": "boolean"
+ }
+ }
+ },
+ "check_clearance": {
+ "path": "CheckClearance",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "clearance": {
+ "path": "Clearance",
+ "getter": false,
+ "kind": "expression"
+ },
+ "diameter": {
+ "path": "Diameter",
+ "getter": false,
+ "kind": "expression"
+ },
+ "die_radius": {
+ "path": "DieRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "hole_center": {
+ "path": "HoleCenter",
+ "getter": false,
+ "kind": "point_section",
+ "assign": false
+ },
+ "length": {
+ "path": "Length",
+ "getter": false,
+ "kind": "expression"
+ },
+ "reverse_bend_direction": {
+ "path": "ReverseBendDirection",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "section_corner_radius": {
+ "path": "SectionCornerRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "standard_name": {
+ "path": "StandardName",
+ "getter": false,
+ "kind": "string"
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "LighteningCutoutBuilderCutoutType",
+ "values": [
+ "Hole",
+ "UserDefined"
+ ]
+ },
+ "user_defined_section": {
+ "path": "UserDefinedSection",
+ "getter": false,
+ "kind": "section",
+ "assign": false
+ }
+ },
+ "required": [],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "type": "Hole",
+ "hole_center": [
+ [
+ 50,
+ 40,
+ 0
+ ]
+ ],
+ "diameter": 12,
+ "length": 4,
+ "angle": 45,
+ "die_radius": 2
+ },
+ "edit_status": "experimental"
+ },
+ "solid_punch": {
+ "builder": "SolidPunchBuilder",
+ "factory": "CreateSolidPunchBuilder",
+ "fields": {
+ "auto_centroid": {
+ "path": "AutoCentroid",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "constant_thickness": {
+ "path": "ConstantThickness",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "die_radius": {
+ "path": "DieRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "from_csys": {
+ "path": "FromCsys",
+ "getter": false,
+ "kind": "csys",
+ "assign": true
+ },
+ "hide_tool": {
+ "path": "HideTool",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "include_rounding": {
+ "path": "IncludeRounding",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "infer_thickness": {
+ "path": "InferThickness",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "pierce_faces": {
+ "path": "PierceFaces",
+ "getter": false,
+ "kind": "select_faces",
+ "assign": false
+ },
+ "punch_radius": {
+ "path": "PunchRadius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "target_face": {
+ "path": "TargetFace",
+ "getter": false,
+ "kind": "select_face",
+ "assign": false
+ },
+ "thickness": {
+ "path": "Thickness",
+ "getter": false,
+ "kind": "expression"
+ },
+ "to_csys": {
+ "path": "ToCsys",
+ "getter": false,
+ "kind": "csys",
+ "assign": true
+ },
+ "tool_body": {
+ "path": "ToolBody",
+ "getter": false,
+ "kind": "select_body",
+ "assign": false
+ },
+ "type": {
+ "path": "Type",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "SolidPunchBuilderTypes",
+ "values": [
+ "PunchType",
+ "DieType"
+ ]
+ }
+ },
+ "required": [
+ "target_face",
+ "tool_body"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "target_face": "$input_1",
+ "tool_body": "$input_2",
+ "type": "PunchType",
+ "from_csys": {
+ "origin": [
+ 0,
+ 0,
+ 0
+ ],
+ "x_axis": [
+ 1,
+ 0,
+ 0
+ ],
+ "y_axis": [
+ 0,
+ 1,
+ 0
+ ]
+ },
+ "to_csys": {
+ "origin": [
+ 0,
+ 0,
+ 0
+ ],
+ "x_axis": [
+ 1,
+ 0,
+ 0
+ ],
+ "y_axis": [
+ 0,
+ 1,
+ 0
+ ]
+ },
+ "constant_thickness": true,
+ "infer_thickness": true,
+ "include_rounding": false,
+ "auto_centroid": false
+ },
+ "edit_status": "experimental"
+ },
+ "bulge_relief": {
+ "builder": "BulgeReliefBuilder",
+ "factory": "CreateBulgeReliefBuilder",
+ "fields": {
+ "bend_edges": {
+ "path": "BendEdges",
+ "getter": false,
+ "kind": "collector",
+ "assign": false,
+ "objects": "edge"
+ },
+ "depth": {
+ "path": "Depth",
+ "getter": false,
+ "kind": "expression"
+ },
+ "radius": {
+ "path": "Radius",
+ "getter": false,
+ "kind": "expression"
+ },
+ "relief_type": {
+ "path": "ReliefType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BulgeReliefBuilderReliefTypes",
+ "values": [
+ "Circular",
+ "UShaped",
+ "VShaped"
+ ]
+ },
+ "width": {
+ "path": "Width",
+ "getter": false,
+ "kind": "expression"
+ },
+ "width_type": {
+ "path": "WidthType",
+ "getter": false,
+ "kind": "enum",
+ "enum_type": "BulgeReliefBuilderWidthTypes",
+ "values": [
+ "Value",
+ "FullWidth"
+ ]
+ }
+ },
+ "required": [
+ "bend_edges"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native creation fixture with clean feature diagnostics and UF body consistency; does not certify every option or edit combination",
+ "example_parameters": {
+ "bend_edges": [
+ "$input_1"
+ ],
+ "depth": 3,
+ "width": 6,
+ "radius": 2,
+ "relief_type": "Circular"
+ },
+ "edit_status": "experimental"
+ },
+ "unbend": {
+ "builder": "UnbendBuilder",
+ "factory": "CreateUnbendFeatureBuilder",
+ "fields": {
+ "added_geometry": {
+ "path": "AddedGeometry",
+ "getter": false,
+ "kind": "section",
+ "assign": true
+ },
+ "extract_gusset_curves": {
+ "path": "ExtractGussetCurves",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "face_collector": {
+ "path": "FaceCollector",
+ "getter": false,
+ "kind": "collector",
+ "assign": true,
+ "objects": "face",
+ "description": "Bend face IDs from nx_sheet_metal_info.items[].bends[].face.id on the owning body; reacquire after unbend or another topology change."
+ },
+ "hide_original_curves": {
+ "path": "HideOriginalCurves",
+ "getter": false,
+ "kind": "boolean"
+ },
+ "reference_entity": {
+ "path": "ReferenceEntity",
+ "getter": false,
+ "kind": "face_or_edge",
+ "assign": true,
+ "description": "Stationary face or edge on the same work-part sheet-metal body. Native fixture uses the original largest planar web face, not the flattened bend strip; edge-based stationary references are unverified."
+ }
+ },
+ "required": [
+ "face_collector",
+ "reference_entity"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Native face collector initialized; actual bend flattened; body consistency and subsequent rebend verified",
+ "edit_status": "experimental",
+ "example_parameters": {
+ "face_collector": [
+ "$input_1"
+ ],
+ "reference_entity": "$input_2"
+ },
+ "example_evidence": {
+ "source": "tests/fixtures/sheet-metal-examples.json#/operations/unbend",
+ "validation": "native_fixture",
+ "scope": "suite4/unbend_rebend: one bend on a 100 x 80 x 2 mm tab with a 20 mm, 90-degree flange; original planar web is stationary. Edge-based stationary references were not exercised."
+ },
+ "prerequisites": [
+ "Start from a native sheet-metal body with a bend; the fixture uses a 100 x 80 x 2 mm tab and a 20 mm, 90-degree flange.",
+ "$input_1 is the bend face from nx_sheet_metal_info.items[].bends[].face.id; $input_2 is the original planar web face selected from that same body.",
+ "Reacquire bend and stationary references after topology changes. For rebend, select the current bend face after unbend and keep the original web stationary; do not use the newly flattened strip as the stationary face."
+ ]
+ },
+ "rebend": {
+ "builder": "RebendBuilder",
+ "factory": "CreateRebendFeatureBuilder",
+ "fields": {
+ "face_collector": {
+ "path": "FaceCollector",
+ "getter": false,
+ "kind": "collector",
+ "assign": true,
+ "objects": "face",
+ "description": "Bend face IDs from nx_sheet_metal_info.items[].bends[].face.id on the owning body; reacquire after unbend or another topology change."
+ },
+ "reference_entity": {
+ "path": "ReferenceEntity",
+ "getter": false,
+ "kind": "face_or_edge",
+ "assign": true,
+ "description": "Stationary face or edge on the same work-part sheet-metal body. Native fixture uses the original largest planar web face, not the flattened bend strip; edge-based stationary references are unverified."
+ }
+ },
+ "required": [
+ "face_collector",
+ "reference_entity"
+ ],
+ "native_status": "tested",
+ "tested_on": "v2606",
+ "validation_scope": "Flattened bend reformed about the largest stationary web face; native geometry healthy",
+ "edit_status": "experimental",
+ "example_parameters": {
+ "face_collector": [
+ "$input_1"
+ ],
+ "reference_entity": "$input_2"
+ },
+ "example_evidence": {
+ "source": "tests/fixtures/sheet-metal-examples.json#/operations/rebend",
+ "validation": "native_fixture",
+ "scope": "suite4/unbend_rebend: one bend on a 100 x 80 x 2 mm tab with a 20 mm, 90-degree flange; original planar web is stationary. Edge-based stationary references were not exercised."
+ },
+ "prerequisites": [
+ "Start from a native sheet-metal body with a bend; the fixture uses a 100 x 80 x 2 mm tab and a 20 mm, 90-degree flange.",
+ "$input_1 is the bend face from nx_sheet_metal_info.items[].bends[].face.id; $input_2 is the original planar web face selected from that same body.",
+ "Reacquire bend and stationary references after topology changes. For rebend, select the current bend face after unbend and keep the original web stationary; do not use the newly flattened strip as the stationary face."
+ ]
+ }
+}
diff --git a/src/nx_mcp/sheet_metal_server.py b/src/nx_mcp/sheet_metal_server.py
new file mode 100644
index 0000000..dd3ac94
--- /dev/null
+++ b/src/nx_mcp/sheet_metal_server.py
@@ -0,0 +1,142 @@
+"""Public contracts for native sheet-metal operations."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+SheetMetalOperation = Literal[
+ "tab",
+ "flange",
+ "contour_flange",
+ "lofted_flange",
+ "bend",
+ "jog",
+ "hem",
+ "normal_cutout",
+ "bead",
+ "dimple",
+ "louver",
+ "drawn_cutout",
+ "gusset",
+ "edge_rip",
+ "break_corner",
+ "closed_corner",
+ "three_bend_corner",
+ "bridge_bend",
+ "bend_taper",
+ "resize_bend_angle",
+ "resize_bend_radius",
+ "resize_neutral_factor",
+ "convert",
+ "from_solid",
+ "flat_solid",
+ "flat_pattern",
+ "advanced_flange",
+ "variational_flange",
+ "joggle",
+ "lightening_cutout",
+ "solid_punch",
+ "bulge_relief",
+ "unbend",
+ "rebend",
+]
+READ_ONLY = {"nx_sheet_metal_schema", "nx_sheet_metal_info", "nx_sheet_metal_defaults"}
+NON_MODEL = {"nx_export_flat_pattern", "nx_sheet_metal_context"}
+
+
+def nx_create_path_sketch(
+ edges: list[str],
+ help_point: list[float],
+ percent: float = 0,
+ orienting_face: str | None = None,
+ reverse_normal: bool = False,
+ reverse_axis: bool = False,
+ name: str | None = None,
+):
+ """Create and activate a native sketch along a work-part edge path, normal to the path. percent is arc-length percentage 0..100 from the NX section start, not the curve's mathematical parameter. help_point=[x,y,z] anchors section selection in work-part units. Optional orienting_face controls local axes relative to a face. Return the actual origin, basis and normal; add curves in that local frame and finish the sketch. Needed for secondary contour flanges. Finish another active sketch first; work/display parts must match."""
+
+
+def nx_sheet_metal_schema(operation: SheetMetalOperation | None = None):
+ """Discover native sheet-metal operations or the strict creation/edit JSON parameter schema for one operation. Every operation reports its actual native validation status; experimental does not mean geometrically verified. Lengths use work-part units, expression angles degrees, neutral factor is unitless. Use typed geometry IDs from nx_list_topology/nx_find_geometry and finished sketch IDs for sections."""
+
+
+def nx_sheet_metal_context():
+ """Enter modern NX Sheet Metal (UG_APP_SBSM) for the current displayed work part. Finish any active sketch first. Changes the interactive application, not model geometry; inspect checkpoint state afterward. Batch sessions use native builder application context. Sheet-metal feature tools require this context and never silently switch into Modeling."""
+
+
+def nx_sheet_metal_feature(
+ operation: SheetMetalOperation, parameters: dict[str, Any], feature: str | None = None
+):
+ """Create a native sheet-metal feature or edit an owned feature by typed ID. First call nx_sheet_metal_schema(operation) for exact parameter names, enums and required geometry. Enter nx_sheet_metal_context before authoring interactively. All references must belong to the work part; unknown parameters are rejected. All changes run serially under an NX rollback mark. Returns all resulting bodies and native feature type. Availability and geometric verification vary by operation and installation; see schema status."""
+
+
+def nx_sheet_metal_info(body: str | None = None, offset: int = 0, limit: int = 50):
+ """Inspect sheet-metal bodies, actual native thickness and bend parameters. Omit body to inspect owned work-part bodies. Does not infer manufacturability or constant thickness of arbitrary solids. Returned references use the current part generation."""
+
+
+def nx_sheet_metal_defaults():
+ """Read this part's sheet-metal stock thickness, radius, neutral factor, relief dimensions and native material/tool/bend-definition settings. These are feature creation defaults; existing feature overrides retain their own expressions."""
+
+
+def nx_set_sheet_metal_defaults(
+ parameter_entry: Literal["Value", "MaterialTable", "ToolIdTable"] | None = None,
+ thickness: float | None = None,
+ bend_radius: float | None = None,
+ neutral_factor: float | None = None,
+ bend_relief_width: float | None = None,
+ bend_relief_depth: float | None = None,
+ material: str | None = None,
+ tool: str | None = None,
+ bend_definition: Literal[
+ "NeutralFactorValue",
+ "BendTable",
+ "BendAllowanceFormula",
+ "MaterialTable",
+ "ToolTable",
+ "BendAllowanceTable",
+ "BendDeductionTable",
+ "BendDeductionFormula",
+ "Din6935Formula",
+ ]
+ | None = None,
+ bend_table: str | None = None,
+ bend_allowance_formula: str | None = None,
+ bend_deduction_formula: str | None = None,
+):
+ """Update native part sheet-metal defaults and return read-back. Lengths use work-part units; thickness must be positive and neutral factor 0..1. Material/tool names must exist in installed tables. Bend-table paths must be existing files inside the allowed workspace. Formula strings use NX syntax. Failed native changes roll back."""
+
+
+def nx_export_flat_pattern(
+ flat_pattern: str,
+ path: str,
+ format: Literal["dxf", "geo"] = "dxf",
+ revision: Literal[
+ "R12", "R13", "R14", "R2000", "R2004", "R2005", "R2007", "R20102012", "R20132016", "R2018"
+ ] = "R2018",
+ bend_up: bool = True,
+ bend_down: bool = True,
+ bend_tangent: bool = False,
+ interior_cutout: bool = True,
+ interior_feature: bool = False,
+ inner_mold: bool = False,
+ outer_mold: bool = False,
+ added_top: bool = False,
+ added_bottom: bool = False,
+ tolerance: float = 0.01,
+):
+ """Export an existing native Flat Pattern feature to a new workspace DXF or Trumpf GEO file. File suffix must match format; existing files are never overwritten. Positive tolerance uses work-part units. Returns actual path, options, units, size and SHA-256 for nx_download_file. Inner/outer mold and DXF revision are DXF-only. This export does not create a flat pattern from a folded body."""
+
+
+def nx_add_flat_pattern_view(drawing: str, flat_pattern: str, position: list[float] | None = None):
+ """Place the native named view belonging to an existing Flat Pattern feature on a drawing sheet. Position is [x,y] in sheet units; default [100,100]. Uses actual developed geometry and bend lines from NX, with the existing flat pattern's settings. Returns a typed drawing-view reference for projection, dimensions and PDF export. Does not copy or move the folded solid."""
+
+
+def nx_sheet_metal_annotation(
+ kind: Literal["body", "bend"],
+ body: str,
+ position: list[float],
+ faces: list[str] | None = None,
+ annotation: str | None = None,
+ automatic: bool = False,
+):
+ """Create or refresh native Sheet Metal PMI attached to an owned body or bend faces. Text contains a measured snapshot of actual thickness or bend radius/angle/neutral factor; it does not automatically refresh after geometry edits. Call again with the annotation ID to refresh. kind=bend requires faces from that body; kind=body forbids faces. Position is [x,y,z] in work-part coordinates and units. Returns annotation references, measured data and native text."""
diff --git a/src/nx_mcp/surface_math.py b/src/nx_mcp/surface_math.py
new file mode 100644
index 0000000..7cfe243
--- /dev/null
+++ b/src/nx_mcp/surface_math.py
@@ -0,0 +1,57 @@
+"""Coordinate-invariant surface differential geometry for sampled continuity checks."""
+
+from __future__ import annotations
+
+import math
+
+from nx_mcp.runtime import NXToolError
+
+
+def dot(a, b):
+ return sum(x * y for x, y in zip(a, b, strict=True))
+
+
+def shape_operator(du, dv, duu, duv, dvv):
+ normal = [
+ du[1] * dv[2] - du[2] * dv[1],
+ du[2] * dv[0] - du[0] * dv[2],
+ du[0] * dv[1] - du[1] * dv[0],
+ ]
+ area = math.sqrt(dot(normal, normal))
+ if area <= 1e-14:
+ raise NXToolError("NX_SINGULAR_SURFACE", "Surface parameterization is singular at a sample")
+ normal = [v / area for v in normal]
+ e, f, g = dot(du, du), dot(du, dv), dot(dv, dv)
+ determinant = e * g - f * f
+ if determinant <= 1e-24 * max(1.0, e * g):
+ raise NXToolError("NX_SINGULAR_SURFACE", "Surface metric is singular at a sample")
+ inverse = [[g / determinant, -f / determinant], [-f / determinant, e / determinant]]
+ second = [[dot(normal, duu), dot(normal, duv)], [dot(normal, duv), dot(normal, dvv)]]
+ tangent = [[du[i], dv[i]] for i in range(3)]
+ dual = [
+ [sum(tangent[i][k] * inverse[k][j] for k in range(2)) for j in range(2)] for i in range(3)
+ ]
+ shape = [
+ [
+ sum(
+ dual[i][k] * second[k][column] * dual[j][column]
+ for k in range(2)
+ for column in range(2)
+ )
+ for j in range(3)
+ ]
+ for i in range(3)
+ ]
+ return normal, shape
+
+
+def continuity_difference(first, second):
+ normal_a, shape_a = first
+ normal_b, shape_b = second
+ cosine = dot(normal_a, normal_b)
+ orientation = 1 if cosine >= 0 else -1
+ angle = math.degrees(math.acos(min(1.0, abs(cosine))))
+ curvature = math.sqrt(
+ sum((shape_a[i][j] - orientation * shape_b[i][j]) ** 2 for i in range(3) for j in range(3))
+ )
+ return angle, curvature
diff --git a/src/nx_mcp/thread_standards.py b/src/nx_mcp/thread_standards.py
new file mode 100644
index 0000000..2ee1515
--- /dev/null
+++ b/src/nx_mcp/thread_standards.py
@@ -0,0 +1,148 @@
+"""Read installed thread tables in place and use native table-driven builders."""
+
+import os
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from nx_mcp.authoring import finite, page
+from nx_mcp.runtime import NXToolError
+
+
+class ThreadStandardsMixin:
+ def _thread_rows(self):
+ base = os.environ.get("UGII_BASE_DIR")
+ if not base:
+ raise NXToolError("NX_CATALOG_UNAVAILABLE", "NX installation root is not configured")
+ source = Path(base) / "UGII" / "modeling_standards" / "NX_Thread_Standard.xml"
+ if not source.is_file():
+ raise NXToolError("NX_CATALOG_UNAVAILABLE", "Installed NX thread table was not found")
+ if source.stat().st_size > 20_000_000:
+ raise NXToolError("NX_CATALOG_INVALID", "Thread catalog exceeds the supported size")
+ return [dict(x.attrib) for x in ET.parse(source).getroot().iter("ThreadedHole")]
+
+ def _thread_catalog(self, standard=None, size=None, offset=0, limit=50):
+ rows = self._thread_rows()
+ if standard is None:
+ if size is not None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Select a standard before a size")
+ return {
+ **page(sorted({r["Standard"] for r in rows}), offset, limit),
+ "level": "standards",
+ "source": "installed NX thread catalog; read in place",
+ }
+ rows = [
+ r
+ for r in rows
+ if r.get("Standard") == standard and (size is None or r.get("Size") == size)
+ ]
+ if not rows:
+ raise NXToolError(
+ "NX_NOT_FOUND", "Requested standard/size is absent from the installed catalog"
+ )
+ keys = ["Standard", "Unit", "Size", "Method", "RadialEngage", "Callout"]
+ if size is not None:
+ keys += [
+ "MajorDiameter",
+ "MinorDiameter",
+ "TapDrillDia",
+ "ShaftDiameter",
+ "Pitch",
+ "Angle",
+ "NumStarts",
+ "Tapered",
+ ]
+ result = page([{k: r.get(k) for k in keys} for r in rows], offset, limit)
+ result.update(
+ level="sizes" if size is None else "selected_size",
+ source="installed NX thread catalog; read in place",
+ )
+ return result
+
+ def _standard_thread(
+ self,
+ face,
+ start_face,
+ standard,
+ size,
+ length,
+ method=None,
+ radial_engage=None,
+ detailed=False,
+ left_hand=False,
+ reverse=False,
+ ):
+ import NXOpen.Features as F
+ import NXOpen.UF as U
+
+ rows = [
+ r
+ for r in self._thread_rows()
+ if r.get("Standard") == standard
+ and r.get("Size") == size
+ and (method is None or r.get("Method") == method)
+ and (radial_engage is None or r.get("RadialEngage") == radial_engage)
+ ]
+ if len(rows) != 1:
+ raise NXToolError(
+ "NX_AMBIGUOUS_THREAD" if rows else "NX_NOT_FOUND",
+ "Select one installed catalog row using standard, size, method and radial_engage",
+ )
+ row = rows[0]
+ target = self._engineering_owned(face, "face")
+ start = self._engineering_owned(start_face, "face")
+ data = U.UFSession.GetUFSession().Modeling.AskFaceData(target.Tag)
+ if data[0] != 16 or start.GetBody() != target.GetBody():
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "Select a cylinder and start face on the same body"
+ )
+ length = finite(length, "length", True)
+ b = self._freeform_builder("CreateThreadBuilder")
+ try:
+ b.ThreadInput = F.ThreadBuilder.Input.ThreadTable
+ b.SmartThread = False
+ b.CylindricalFace.Value = target
+ b.StartObject.Value = start
+ b.ThreadType = (
+ F.ThreadBuilder.Type.Detailed if detailed else F.ThreadBuilder.Type.Symbolic
+ )
+ b.ThreadStandard = row["Standard"]
+ b.ThreadSize = row["Size"]
+ b.ThreadMethod = row["Method"]
+ b.RadialEngage = row["RadialEngage"]
+ b.MatchThreadSizeToCylinder = False
+ b.ShaftDiameterExp.RightHandSide = str(2 * data[4])
+ b.TapDrillDiameterExp.RightHandSide = str(2 * data[4])
+ b.ThreadLimit = F.ThreadBuilder.LimitOption.Value
+ b.ThreadLength.RightHandSide = str(length)
+ b.ThreadHandedness = (
+ F.ThreadBuilder.Handedness.LeftHand
+ if left_hand
+ else F.ThreadBuilder.Handedness.RightHand
+ )
+ b.ReverseThreadDirection = reverse
+ result = self._freeform_commit(b)
+ if b.ThreadStandard != row["Standard"] or b.ThreadSize != row["Size"]:
+ raise NXToolError(
+ "NX_VERIFICATION_FAILED",
+ "Native builder did not retain the selected standard/size",
+ )
+ result.update(
+ standard=b.ThreadStandard,
+ size=b.ThreadSize,
+ method=b.ThreadMethod,
+ radial_engage=b.RadialEngage,
+ pitch=b.Pitch,
+ major_diameter=b.MajorDiameter,
+ minor_diameter=b.MinorDiameter,
+ internal=b.IsInternalThread,
+ handedness="left"
+ if b.ThreadHandedness == F.ThreadBuilder.Handedness.LeftHand
+ else "right",
+ parameter_units=self._units(),
+ representation="detailed" if detailed else "symbolic",
+ catalog_callout=row.get("Callout"),
+ catalog_units=row.get("Unit"),
+ )
+ return result
+ finally:
+ b.Destroy()
diff --git a/src/nx_mcp/tools/file_ops.py b/src/nx_mcp/tools/file_ops.py
index eb2e3e0..cb8e83f 100644
--- a/src/nx_mcp/tools/file_ops.py
+++ b/src/nx_mcp/tools/file_ops.py
@@ -97,7 +97,7 @@ async def nx_open_part(path: str) -> ToolResult | ToolError:
# ---------------------------------------------------------------------------
@mcp_tool(
name="nx_save_part",
- description="Save the currently active (work) part.",
+ description="Save only the active work part; component files are not saved. Activate and save each component explicitly. The integration profile reports saved paths, disk fingerprints, native save errors and preservation checks for unrelated loaded parts.",
params={},
)
async def nx_save_part() -> ToolResult | ToolError:
@@ -108,7 +108,7 @@ async def nx_save_part() -> ToolResult | ToolError:
part = NXSession.get_instance().require_work_part()
part.Save(
- NXOpen.BasePart.SaveComponents.TrueValue, NXOpen.BasePart.CloseAfterSave.FalseValue
+ NXOpen.BasePart.SaveComponents.FalseValue, NXOpen.BasePart.CloseAfterSave.FalseValue
)
return ToolResult.success(
diff --git a/src/nx_mcp/utils/geometry.py b/src/nx_mcp/utils/geometry.py
index fe5b4ec..e50783a 100644
--- a/src/nx_mcp/utils/geometry.py
+++ b/src/nx_mcp/utils/geometry.py
@@ -37,14 +37,28 @@ def resolve_object_by_name(
) -> Any | None:
"""Find an NX object by name across multiple collections.
- Each collection in *collections should support .ToArray() returning
+ Each collection in *collections must be iterable and return
objects with a .Name attribute. Comparison is case-insensitive.
- Returns the first match, or None.
+ Returns a unique match, or None. Ambiguous names are rejected.
"""
- target = name.lower()
+ from nx_mcp.runtime import NXToolError
+
+ target = name.casefold()
+ matches = []
for collection in collections:
- for obj in collection.ToArray():
- if obj.Name.lower() == target:
- return obj
- return None
+ for obj in collection:
+ if (
+ target
+ in {
+ str(getattr(obj, "Name", "")).casefold(),
+ str(getattr(obj, "JournalIdentifier", "")).casefold(),
+ }
+ and obj not in matches
+ ):
+ matches.append(obj)
+ if len(matches) > 1:
+ raise NXToolError(
+ "NX_AMBIGUOUS_REFERENCE", "Name matches multiple objects; use an opaque reference"
+ )
+ return matches[0] if matches else None
diff --git a/src/nx_mcp/utils/selection.py b/src/nx_mcp/utils/selection.py
index 7f120ca..1320962 100644
--- a/src/nx_mcp/utils/selection.py
+++ b/src/nx_mcp/utils/selection.py
@@ -50,7 +50,7 @@ def create_collector_from_names(
collection = getattr(work_part, collection_name, None)
if collection is not None:
with contextlib.suppress(Exception):
- all_objects.extend(collection.ToArray())
+ all_objects.extend(list(collection))
resolved: list[Any] = []
for name in names:
diff --git a/src/nx_mcp/visual_tools.py b/src/nx_mcp/visual_tools.py
new file mode 100644
index 0000000..4f4b866
--- /dev/null
+++ b/src/nx_mcp/visual_tools.py
@@ -0,0 +1,538 @@
+"""NX-native visualization and solver diagnostics. All calls run on the NX thread."""
+
+from __future__ import annotations
+
+import math
+import uuid
+
+from nx_mcp.runtime import NXToolError
+
+
+def enum_name(value, enum):
+ for name in dir(enum):
+ if not name.startswith("_") and getattr(enum, name) == value:
+ return name
+ return "unknown_" + str(value)
+
+
+def unit_normal(value, argument="normal"):
+ if not isinstance(value, (list, tuple)) or len(value) != 3:
+ raise NXToolError("NX_INVALID_ARGUMENT", f"{argument} requires three finite numbers")
+ v = [float(x) for x in value]
+ length = math.sqrt(sum(x * x for x in v))
+ if not math.isfinite(length) or length < 1e-12:
+ raise NXToolError("NX_INVALID_ARGUMENT", f"{argument} must be finite and nonzero")
+ return [x / length for x in v]
+
+
+class VisualToolsMixin:
+ def _visual_part(self):
+ part = self._work_part()
+ if part != self.session.Parts.Display:
+ raise NXToolError(
+ "NX_DISPLAY_PART_MISMATCH", "Activate the target as both work and display part"
+ )
+ return part
+
+ def _display_targets(self, objects, expand=False):
+ if not isinstance(objects, list) or not 1 <= len(objects) <= 1000:
+ raise NXToolError("NX_INVALID_ARGUMENT", "objects requires 1–1000 references")
+ values = []
+ for ref in objects:
+ obj = self._resolve(
+ ref, {"body", "face", "edge", "curve", "sketch", "component", "feature", "datum"}
+ )
+ if isinstance(obj, self.nxopen.Features.Feature) or (
+ expand and hasattr(obj, "FindOccurrence")
+ ):
+ values.extend(self._geometry(ref))
+ elif isinstance(obj, self.nxopen.Sketch):
+ values.extend(obj.GetAllGeometry())
+ else:
+ values.append(obj)
+ values = list({int(x.Tag): x for x in values}.values())
+ if not values:
+ raise NXToolError("NX_NO_TARGET_BODY", "Selection contains no displayable objects")
+ return values
+
+ def _display_ref(self, obj):
+ kind = (
+ "datum"
+ if isinstance(
+ obj,
+ tuple(
+ getattr(self.nxopen, n)
+ for n in ("DatumPlane", "DatumAxis", "CoordinateSystem")
+ if hasattr(self.nxopen, n)
+ ),
+ )
+ else "component"
+ if hasattr(obj, "FindOccurrence")
+ else "body"
+ if isinstance(obj, self.nxopen.Body)
+ else "face"
+ if isinstance(obj, self.nxopen.Face)
+ else "edge"
+ if isinstance(obj, self.nxopen.Edge)
+ else "curve"
+ )
+ return self._reference(obj, kind, self._work_part(), "Display object")
+
+ def _display_record(self, obj, appearance=False):
+ record = {"object": self._display_ref(obj), "blanked": bool(obj.IsBlanked)}
+ if appearance:
+ record["color_index"] = obj.Color
+ if isinstance(obj, self.nxopen.Face):
+ import NXOpen.UF
+
+ record["transparency"] = NXOpen.UF.UFSession.GetUFSession().Obj.AskTranslucency(
+ obj.Tag
+ )
+ return record
+
+ def _display_records(self, values, appearance=False):
+ if appearance:
+ values = list(
+ {
+ int(x.Tag): x
+ for obj in values
+ for x in (
+ [obj] + list(obj.GetFaces()) if isinstance(obj, self.nxopen.Body) else [obj]
+ )
+ }.values()
+ )
+ if len(values) > 10000:
+ raise NXToolError("NX_OBJECT_LIMIT", "Display change exceeds 10000 objects/faces")
+ return [self._display_record(x, appearance) for x in values]
+
+ def _save_display_snapshot(self, records):
+ if not hasattr(self, "_display_snapshots"):
+ self._display_snapshots = {}
+ token = "display_" + uuid.uuid4().hex
+ self._display_snapshots[token] = {
+ "part_id": self._part_id(self._work_part()),
+ "records": records,
+ }
+ return token
+
+ def _display_info(self, objects, count_only=False):
+ self._visual_part()
+ if count_only:
+ values = self._display_targets(objects, expand=True)
+ count = len(
+ {
+ int(x.Tag)
+ for obj in values
+ for x in (
+ [obj] + list(obj.GetFaces()) if isinstance(obj, self.nxopen.Body) else [obj]
+ )
+ }
+ )
+ return {
+ "count": count,
+ "object_limit": 10000,
+ "within_limit": count <= 10000,
+ "expansion": "unique bodies and faces",
+ "objects": [],
+ }
+ records = self._display_records(self._display_targets(objects, expand=True), True)
+ return {
+ "objects": records,
+ "count": len(records),
+ "transparency_scale": "0 opaque, 100 transparent",
+ "color_system": "NX part color-table index",
+ "coordinate_frame": "display_part",
+ }
+
+ def _apply_appearance(self, values, color_index=None, transparency=None):
+ modification = self.session.DisplayManager.NewDisplayModification()
+ try:
+ modification.ApplyToOwningParts = False
+ modification.ApplyToAllFaces = True
+ if color_index is not None:
+ modification.NewColor = color_index
+ if transparency is not None:
+ modification.NewTranslucency = transparency
+ modification.Apply(values)
+ finally:
+ modification.Dispose()
+
+ def _set_display(self, objects, color_index=None, transparency=None, color=None):
+ self._visual_part()
+ if color is not None:
+ import NXOpen.UF
+
+ names = {
+ n: n.upper() + "_NAME"
+ for n in [
+ "red",
+ "green",
+ "blue",
+ "yellow",
+ "cyan",
+ "magenta",
+ "orange",
+ "white",
+ "black",
+ ]
+ }
+ names["gray"] = "MEDIUM_GRAY_NAME"
+ if color not in names or color_index is not None:
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT",
+ "Specify a supported named color or color_index, not both",
+ )
+ uf = NXOpen.UF.UFSession.GetUFSession()
+ color_index = uf.Disp.AskClosestColorInDisplayedPart(
+ getattr(uf.Disp.ColorName, names[color])
+ )
+ if color_index is None and transparency is None:
+ raise NXToolError("NX_INVALID_ARGUMENT", "Specify color_index or transparency")
+ if color_index is not None and (
+ type(color_index) is not int or not 1 <= color_index <= 216
+ ):
+ raise NXToolError("NX_INVALID_ARGUMENT", "color_index must be an integer from 1 to 216")
+ if transparency is not None and (
+ type(transparency) is not int or not 0 <= transparency <= 100
+ ):
+ raise NXToolError(
+ "NX_INVALID_ARGUMENT", "transparency must be an integer from 0 to 100"
+ )
+ values = self._display_targets(objects, expand=True)
+ if transparency is not None and any(
+ not isinstance(x, (self.nxopen.Body, self.nxopen.Face)) for x in values
+ ):
+ raise NXToolError("NX_OBJECT_TYPE_MISMATCH", "Transparency requires bodies or faces")
+ before = self._display_records(values, True)
+ self._apply_appearance(values, color_index, transparency)
+ token = self._save_display_snapshot(before)
+ return {
+ "restore_id": token,
+ "objects": self._display_records(values, True),
+ "modified": [r["object"] for r in before],
+ "prototype_parts_modified": False,
+ "warnings": [
+ "Appearance changes can be saved in the part; restore_id restores explicit attributes, not inherited override state."
+ ],
+ }
+
+ def _set_visibility(self, objects, mode="show"):
+ part = self._visual_part()
+ if mode not in {"show", "hide", "isolate"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "mode must be show, hide or isolate")
+ selected = self._display_targets(objects)
+ if any(isinstance(x, (self.nxopen.Face, self.nxopen.Edge)) for x in selected):
+ raise NXToolError(
+ "NX_OBJECT_TYPE_MISMATCH",
+ "Visibility targets bodies, components or curves; faces/edges are unsupported",
+ )
+ if mode == "isolate":
+ if any(
+ not isinstance(x, self.nxopen.Body) and not hasattr(x, "FindOccurrence")
+ for x in selected
+ ):
+ raise NXToolError(
+ "NX_OBJECT_TYPE_MISMATCH", "Isolation targets bodies or components"
+ )
+ bodies = self._geometry(scope="assembly")
+ components = [c for c, _ in self._walk_components(part)]
+ values = list({int(x.Tag): x for x in bodies + components}.values())
+ keep = set()
+ for ref in objects:
+ for obj in self._geometry(ref):
+ keep.add(int(obj.Tag))
+ component = obj.OwningComponent if obj.IsOccurrence else None
+ while component:
+ keep.add(int(component.Tag))
+ component = component.Parent
+ before = self._display_records(values)
+ for obj in values:
+ (obj.Unblank if int(obj.Tag) in keep else obj.Blank)()
+ else:
+ values = selected
+ before = self._display_records(values)
+ for obj in values:
+ (obj.Unblank if mode == "show" else obj.Blank)()
+ token = self._save_display_snapshot(before)
+ return {
+ "restore_id": token,
+ "mode": mode,
+ "objects": self._display_records(values),
+ "modified": [r["object"] for r in before],
+ "warnings": [
+ "Isolation controls loaded body/component geometry; datum and reference-curve visibility is unchanged."
+ ]
+ if mode == "isolate"
+ else [],
+ }
+
+ def _validate_display_restore(self, restore_id):
+ self._visual_part()
+ snapshots = getattr(self, "_display_snapshots", {})
+ if restore_id not in snapshots:
+ raise NXToolError(
+ "NX_DISPLAY_SNAPSHOT_STALE", "Unknown or already restored display snapshot"
+ )
+ snapshot = snapshots[restore_id]
+ pid = self._part_id(self._work_part())
+ if snapshot["part_id"] != pid:
+ raise NXToolError("NX_DISPLAY_PART_MISMATCH", "Activate the snapshot owner part")
+ latest = next(k for k in reversed(snapshots) if snapshots[k]["part_id"] == pid)
+ if latest != restore_id:
+ raise NXToolError("NX_RESTORE_ORDER", "Restore display changes in reverse order")
+ resolved = [(self._resolve(r["object"]["id"]), r) for r in snapshot["records"]]
+ return resolved
+
+ def _restore_display(self, restore_id):
+ resolved = self._validate_display_restore(restore_id)
+ snapshots = self._display_snapshots
+ for obj, record in resolved:
+ if "color_index" in record:
+ # Bodies precede their faces, preserving per-face colors afterward.
+ self._apply_appearance([obj], record["color_index"], record.get("transparency"))
+ (obj.Blank if record["blanked"] else obj.Unblank)()
+ del snapshots[restore_id]
+ return {
+ "restored": restore_id,
+ "count": len(resolved),
+ "modified": [r["object"] for _, r in resolved],
+ }
+
+ def _clear_highlights(self):
+ cleared = 0
+ for obj in getattr(self, "_highlighted_objects", []):
+ try:
+ obj.Unhighlight()
+ cleared += 1
+ except Exception:
+ pass # NX may have deleted an entity since the highlight.
+ self._highlighted_objects = []
+ return {"cleared_count": cleared, "scope": "MCP-owned highlights only"}
+
+ def _highlight_collisions(self, obj1, obj2, include_contact=False):
+ self._visual_part()
+ if self.session.IsBatch:
+ raise NXToolError("NX_VIEWPORT_UNAVAILABLE", "Highlighting requires interactive NX")
+ result = self._check_interference(obj1, obj2)
+ pairs = [
+ p
+ for p in result["pairs"]
+ if p["classification"] == "penetration"
+ or (include_contact and p["classification"] == "contact")
+ ]
+ refs = {r["id"]: r for p in pairs for r in p["objects"]}
+ values = [self._resolve(r) for r in refs]
+ self._clear_highlights()
+ try:
+ for obj in values:
+ obj.Highlight()
+ self._highlighted_objects.append(obj)
+ except Exception:
+ self._clear_highlights()
+ raise
+ return {
+ **result,
+ "highlighted": list(refs.values()),
+ "highlighted_count": len(values),
+ "highlight_style": "native NX selection highlight; no persistent color changes",
+ }
+
+ def _list_sections(self):
+ from nx_mcp.hardened import xyz
+
+ part = self._visual_part()
+ view = part.ModelingViews.WorkView
+ active = view.ActiveDynamicSection
+ enabled = bool(view.DisplaySectioningToggle)
+ sections = []
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Invisible, "NX MCP inspect sections"
+ )
+ try:
+ for section in part.DynamicSections:
+ builder = part.DynamicSections.CreateSectionBuilder(section, view)
+ try:
+ sections.append(
+ {
+ "object": self._reference(section, "section", part, "Section"),
+ "origin": xyz(builder.GetOrigin()),
+ "normal": xyz(builder.GetNormal()),
+ "active": active == section,
+ "clip_enabled": bool(builder.ShowClip),
+ "cap": bool(builder.ShowCap),
+ }
+ )
+ finally:
+ builder.Destroy()
+ finally:
+ self.session.UndoToMark(mark, None)
+ self.session.DeleteUndoMark(mark, None)
+ return {
+ "sections": sections,
+ "count": len(sections),
+ "view_sectioning_enabled": enabled,
+ "coordinate_frame": "display_part",
+ }
+
+ def _section_view(self, origin, normal, section=None, name="MCP section", cap=True):
+ import NXOpen.Display
+
+ from nx_mcp.hardened import vector, xyz
+
+ part = self._visual_part()
+ view = part.ModelingViews.WorkView
+ if self.session.IsBatch:
+ raise NXToolError("NX_VIEWPORT_UNAVAILABLE", "Section views require interactive NX")
+ point = vector(origin, "origin")
+ direction = unit_normal(normal)
+ if not isinstance(name, str) or not name.strip():
+ raise NXToolError("NX_INVALID_ARGUMENT", "name cannot be empty")
+ target = self._resolve(section, {"section"}) if section else None
+ if target is None and view.ActiveDynamicSection:
+ raise NXToolError(
+ "NX_SECTION_EXISTS",
+ "Specify the existing section ID to edit it, or delete it first",
+ )
+ builder = (
+ part.DynamicSections.CreateSectionBuilder(target, view)
+ if target
+ else part.DynamicSections.CreateSectionBuilder(view)
+ )
+ try:
+ builder.Type = NXOpen.Display.DynamicSectionTypes.Type.OnePlane
+ builder.CsysType = NXOpen.Display.DynamicSectionTypes.CoordinateSystem.Absolute
+ builder.SetName(name)
+ builder.SetNormal(self.nxopen.Vector3d(*direction))
+ builder.SetOrigin(self.nxopen.Point3d(*point))
+ builder.ClipType = NXOpen.Display.DynamicSectionTypes.Clip.Section
+ builder.ShowClip = True
+ builder.ShowCap = cap
+ builder.ShowViewer = False
+ builder.ShowGrid = False
+ result = builder.Commit()
+ view.ActiveDynamicSection = result
+ view.DisplaySectioningToggle = True
+ return {
+ "object": self._reference(result, "section", part, "Section"),
+ "origin": xyz(builder.GetOrigin()),
+ "normal": xyz(builder.GetNormal()),
+ "cap": cap,
+ "coordinate_frame": "display_part",
+ "retained_side": "negative_normal",
+ "retained_half_space": "dot(point - origin, normal) <= 0",
+ "geometry_changed": False,
+ "warnings": [
+ "Native view clipping; model solids and measurements remain unchanged."
+ ],
+ }
+ finally:
+ builder.Destroy()
+
+ def _section_control(self, section, action):
+ part = self._visual_part()
+ view = part.ModelingViews.WorkView
+ if action not in {"enable", "disable", "delete"}:
+ raise NXToolError("NX_INVALID_ARGUMENT", "action must be enable, disable or delete")
+ target = self._resolve(section, {"section"})
+ if action == "delete":
+ part.DynamicSections.DeleteSections(False, [target])
+ elif action == "enable":
+ view.ActiveDynamicSection = target
+ view.DisplaySectioningToggle = True
+ elif view.ActiveDynamicSection == target:
+ view.DisplaySectioningToggle = False
+ return {"section_id": section, "action": action, "geometry_changed": False}
+
+ def _sketch_diagnostics(self, sketch_id):
+ sketch = self._resolve(sketch_id, {"sketch"})
+ part = self._work_part()
+ active = self.session.ActiveSketch
+ if active and active != sketch:
+ raise NXToolError(
+ "NX_SKETCH_ACTIVE", "Finish the other active sketch before running diagnostics"
+ )
+ mark = self.session.SetUndoMark(
+ self.nxopen.Session.MarkVisibility.Invisible, "NX MCP sketch diagnostics"
+ )
+ activated = False
+ try:
+ if not active:
+ sketch.Activate(self.nxopen.Sketch.ViewReorient.FalseValue)
+ activated = True
+ region = part.Sketches.CreateWorkRegionBuilder()
+ try:
+ # Evaluate all geometry regardless of the current work-region UI
+ # preference; the invisible undo mark restores this temporary change.
+ region.Scope = self.nxopen.SketchWorkRegionBuilder.ScopeType.EntireSketch
+ region.Commit()
+ finally:
+ region.Destroy()
+ sketch.CalculateStatus()
+ status, dof = sketch.GetStatus()
+ status_name = enum_name(status, self.nxopen.Sketch.Status)
+ constraints = list(
+ sketch.GetAllConstraintsOfType(
+ self.nxopen.Sketch.ConstraintClass.Any, self.nxopen.Sketch.ConstraintType.NoCon
+ )
+ )
+ constraint_records = []
+ for constraint in constraints:
+ ref = self._reference(constraint, "constraint", part, "Sketch constraint")
+ row = {
+ "object": ref,
+ "type": enum_name(constraint.ConstraintType, self.nxopen.Sketch.ConstraintType),
+ }
+ if hasattr(constraint, "AssociatedExpression") and constraint.AssociatedExpression:
+ exp = constraint.AssociatedExpression
+ row["expression"] = self._expression_record(exp)
+ constraint_records.append(row)
+ geometry = []
+ for curve in sketch.GetAllGeometry():
+ attached = sketch.GetConstraintsForGeometry(
+ curve, self.nxopen.Sketch.ConstraintClass.Any
+ )
+ geometry.append(
+ {
+ "object": self._reference(curve, "curve", part, "Sketch geometry"),
+ "constraints": [
+ self._reference(c, "constraint", part, "Constraint")["id"]
+ for c in attached
+ ],
+ }
+ )
+ return {
+ "sketch": self._reference(sketch, "sketch", part, "Sketch"),
+ "solver_status": status_name,
+ "remaining_degrees_of_freedom": dof
+ if status_name in {"UnderConstrained", "WellConstrained"}
+ else None,
+ "native_dof_value": dof,
+ "constraints": constraint_records,
+ "constraint_count": len(constraints),
+ "geometry": geometry,
+ "geometry_count": len(geometry),
+ "evaluation": "native CalculateStatus, entire sketch",
+ "conflicting_constraints": None,
+ "work_region_handling": "Temporarily evaluate entire sketch; restore native state with undo",
+ "warnings": [
+ "Persistent constraints are enumerated; inferred solver relations are not individual persistent constraints.",
+ "NX overall status does not identify a minimal conflicting constraint set.",
+ ],
+ }
+ finally:
+ try:
+ if activated and self.session.ActiveSketch == sketch:
+ sketch.Deactivate(
+ self.nxopen.Sketch.ViewReorient.FalseValue,
+ self.nxopen.Sketch.UpdateLevel.Model,
+ )
+ finally:
+ try:
+ self.session.UndoToMark(mark, None)
+ self.session.DeleteUndoMark(mark, None)
+ except Exception as error:
+ raise NXToolError(
+ "NX_ROLLBACK_FAILED",
+ "Sketch diagnostic cleanup failed: " + str(error),
+ details={"mutation_outcome": "partial"},
+ ) from error
diff --git a/src/nx_mcp/workspace.py b/src/nx_mcp/workspace.py
index 3dc77ba..bf4ddc5 100644
--- a/src/nx_mcp/workspace.py
+++ b/src/nx_mcp/workspace.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from pathlib import Path
+from pathlib import Path, PureWindowsPath
class WorkspaceViolation(ValueError):
@@ -13,15 +13,22 @@ class Workspace:
def __init__(self, root: str | Path) -> None:
self.root = Path(root).resolve()
- def resolve(self, relative_path: str) -> Path:
- requested = Path(relative_path)
- if requested.is_absolute():
- raise WorkspaceViolation("Path must stay inside the configured workspace")
-
- return self.ensure_inside(self.root / requested)
+ def resolve(self, path: str) -> Path:
+ """Resolve an NX-host absolute or workspace-relative path, never a client path."""
+ requested = Path(path)
+ windows = PureWindowsPath(path)
+ # On POSIX a Windows drive would otherwise become an ordinary directory.
+ # On Windows reject drive-relative and root-relative paths (C:foo, \foo).
+ if windows.drive and not requested.is_absolute():
+ raise WorkspaceViolation("Use an absolute NX-host path inside the workspace")
+ if windows.root and not requested.is_absolute():
+ raise WorkspaceViolation("Use a complete NX-host path inside the workspace")
+ return self.ensure_inside(requested if requested.is_absolute() else self.root / requested)
def ensure_inside(self, path: str | Path) -> Path:
resolved = Path(path).resolve()
if not resolved.is_relative_to(self.root):
raise WorkspaceViolation("Path must stay inside the configured workspace")
+ if any(part.casefold() == ".nx-mcp" for part in resolved.relative_to(self.root).parts):
+ raise WorkspaceViolation("Internal NX MCP state is not a user artifact")
return resolved
diff --git a/tests/conftest.py b/tests/conftest.py
index 914c794..8ddfa37 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,6 +6,8 @@
import pytest
+from tests.fakes import rig # noqa: F401
+
def create_mock_nxopen_modules() -> dict[str, types.ModuleType]:
"""Create a full mock NXOpen module tree for testing."""
diff --git a/tests/fakes/__init__.py b/tests/fakes/__init__.py
new file mode 100644
index 0000000..bd8a991
--- /dev/null
+++ b/tests/fakes/__init__.py
@@ -0,0 +1,407 @@
+"""Stateful NX seams for failure/recovery tests; not a geometric kernel oracle."""
+
+import itertools
+from types import ModuleType
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.hardened import HardenedExecutor
+from nx_mcp.workspace import Workspace
+
+
+def point(x=0, y=0, z=0):
+ return NS(X=x, Y=y, Z=z)
+
+
+def matrix():
+ return NS(Xx=1, Xy=0, Xz=0, Yx=0, Yy=1, Yz=0, Zx=0, Zy=0, Zz=1)
+
+
+class Object:
+ ids = itertools.count(1)
+
+ def __init__(self, name=""):
+ self.Tag = next(self.ids)
+ self.Name = name
+ self.JournalIdentifier = f"OBJECT({self.Tag})"
+ self.IsBlanked = False
+ self.Color = 7
+ self.transparency = 0
+ self.highlighted = False
+
+ def SetName(self, name):
+ self.Name = name
+
+ def Blank(self):
+ self.IsBlanked = True
+
+ def Unblank(self):
+ self.IsBlanked = False
+
+ def Highlight(self):
+ self.highlighted = True
+
+ def Unhighlight(self):
+ self.highlighted = False
+
+
+class Face(Object):
+ pass
+
+
+class Edge(Object):
+ pass
+
+
+class Curve(Object):
+ pass
+
+
+class Body(Object):
+ def __init__(self, name="Body", box=None):
+ super().__init__(name)
+ self.box = box or [0, 0, 0, 10, 10, 10]
+ self.IsSolidBody = True
+ self.IsOccurrence = False
+ self.faces = [Face("face")]
+ self.edges = [Edge("edge")]
+ self.volume = 1000
+
+ def GetFaces(self):
+ return self.faces
+
+ def GetEdges(self):
+ return self.edges
+
+
+class Feature(Object):
+ def __init__(self, name="Extrude", bodies=None):
+ super().__init__(name)
+ self.FeatureType = "EXTRUDE"
+ self.bodies = bodies or []
+ self.expressions = [NS(Name="p1", RightHandSide="10", Value=10)]
+
+ def GetBodies(self):
+ return self.bodies
+
+ def GetExpressions(self):
+ return self.expressions
+
+ def GetParents(self):
+ return []
+
+ def GetChildren(self):
+ return []
+
+
+class Sketch(Object):
+ Null = None
+ ViewReorient = NS(FalseValue=False, TrueValue=True)
+ UpdateLevel = NS(Model="model")
+ InferConstraintsOption = NS(InferNoConstraints=False)
+ Status = NS(UnderConstrained=1, WellConstrained=2, OverConstrained=3)
+ ConstraintClass = NS(Any=0)
+ ConstraintType = NS(NoCon=0, Distance=1)
+
+ def __init__(self, session, name="Sketch"):
+ super().__init__(name)
+ self.session = session
+ self.Origin = point()
+ self.Orientation = NS(Element=matrix())
+ self.geometry = []
+ self.constraints = []
+ self.status = (1, 4)
+
+ def Activate(self, *_):
+ self.session.ActiveSketch = self
+
+ def Deactivate(self, *_):
+ self.session.ActiveSketch = None
+
+ def AddGeometry(self, curve, *_):
+ self.geometry.append(curve)
+
+ def GetAllGeometry(self):
+ return self.geometry
+
+ def GetAllConstraintsOfType(self, *_):
+ return self.constraints
+
+ def GetConstraintsForGeometry(self, *_):
+ return self.constraints
+
+ def CalculateStatus(self):
+ pass
+
+ def GetStatus(self):
+ return self.status
+
+
+class Component(Object):
+ def __init__(self, name, bodies=(), parent=None):
+ super().__init__(name)
+ self.Parent = parent
+ self.IsSuppressed = False
+ self.ReferenceSet = "Entire Part"
+ self.children = []
+ self.Prototype = NS(Bodies=list(bodies), FullPath=name + ".prt")
+ self.position = point()
+ self.rotation = matrix()
+ self.occurrences = {}
+ for b in bodies:
+ o = Body(name + " occurrence", list(b.box))
+ o.IsOccurrence = True
+ o.OwningComponent = self
+ self.occurrences[b.Tag] = o
+ if parent:
+ parent.children.append(self)
+
+ def GetChildren(self):
+ return self.children
+
+ def FindOccurrence(self, body):
+ return self.occurrences.get(body.Tag)
+
+ def GetPosition(self):
+ return self.position, self.rotation
+
+
+class Collection(list):
+ pass
+
+
+class Session:
+ def __init__(self):
+ self.Parts = Collection()
+ self.Parts.Work = self.Parts.Display = None
+ self.ActiveSketch = None
+ self.IsBatch = False
+ self.marks = {}
+ self.mark_ids = itertools.count(1)
+ self.UpdateManager = NS(DoUpdate=Mock(return_value=0))
+
+ def SetUndoMark(self, *_):
+ mark = next(self.mark_ids)
+ states = []
+ for p in self.Parts:
+ groups = [
+ p.Bodies,
+ p.Features,
+ p.Curves,
+ p.Sketches,
+ p.DynamicSections,
+ p.Notes,
+ p.Labels,
+ ]
+ objs = list(itertools.chain.from_iterable(groups))
+ attrs = [(o, o.IsBlanked, o.Color, o.transparency) for o in objs]
+ states.append((p, [list(g) for g in groups], attrs, p.IsModified))
+ self.marks[mark] = (states, self.ActiveSketch)
+ return mark
+
+ def UndoToMark(self, mark, *_):
+ states, active = self.marks[mark]
+ for p, groups, attrs, modified in states:
+ for dest, values in zip(
+ [p.Bodies, p.Features, p.Curves, p.Sketches, p.DynamicSections, p.Notes, p.Labels],
+ groups,
+ strict=True,
+ ):
+ dest[:] = values
+ for obj, blank, color, transparency in attrs:
+ obj.IsBlanked, obj.Color, obj.transparency = blank, color, transparency
+ p.IsModified = modified
+ self.ActiveSketch = active
+
+ def DeleteUndoMark(self, mark, *_):
+ self.marks.pop(mark, None)
+
+ def DoesUndoMarkExist(self, mark, *_):
+ return mark in self.marks
+
+
+class Part(Object):
+ def __init__(self, session, path):
+ super().__init__(path.stem)
+ self.FullPath = str(path)
+ self.PartUnits = "mm"
+ self.IsModified = False
+ self.Bodies = Collection()
+ self.Features = Collection()
+ self.Curves = Collection()
+ self.Datums = Collection()
+ self.CoordinateSystems = Collection()
+ self.Sketches = Collection()
+ self.DynamicSections = Collection()
+ self.Notes = Collection()
+ self.Labels = Collection()
+ self.ComponentAssembly = NS(RootComponent=None)
+ self.WCS = NS(CoordinateSystem=NS(Orientation=NS(Element=matrix())))
+ self.ModelingViews = NS(
+ WorkView=NS(
+ Name="Work",
+ Matrix=matrix(),
+ Origin=point(),
+ AbsoluteOrigin=point(),
+ Scale=1,
+ RenderingStyle="shaded",
+ ActiveDynamicSection=None,
+ DisplaySectioningToggle=False,
+ Fit=Mock(),
+ UpdateDisplay=Mock(),
+ )
+ )
+ self.UnitCollection = NS(FindObject=lambda name: name)
+ self.MeasureManager = NS(
+ NewMassProperties=lambda _u, _a, b: NS(Volume=sum(x.volume for x in b), Dispose=Mock())
+ )
+ self.session = session
+ session.Parts.append(self)
+ session.Parts.Work = session.Parts.Display = self
+
+ def Save(self, *_):
+ from pathlib import Path
+
+ Path(self.FullPath).parent.mkdir(parents=True, exist_ok=True)
+ Path(self.FullPath).write_bytes(b"native part fixture")
+ self.IsModified = False
+ self.session.marks.clear()
+ return NS(Dispose=Mock(), NumberUnsavedParts=0, NumberUnsavedObjects=0)
+
+ def SaveAs(self, path):
+ self.FullPath = path
+ return NS(Dispose=Mock(), NumberUnsavedParts=0, NumberUnsavedObjects=0)
+
+ def Close(self, *_):
+ self.session.Parts.remove(self)
+ if self.session.Parts.Work is self:
+ self.session.Parts.Work = None
+ if self.session.Parts.Display is self:
+ self.session.Parts.Display = None
+
+
+@pytest.fixture
+def rig(tmp_path, monkeypatch):
+ session = Session()
+ part = Part(session, tmp_path / "test.prt")
+ nx = ModuleType("NXOpen")
+ nx.Body = Body
+ nx.Face = Face
+ nx.Edge = Edge
+ nx.Sketch = Sketch
+ nx.Point3d = point
+ nx.Vector3d = point
+ nx.Matrix3x3 = matrix
+ nx.Features = NS(Feature=Feature)
+ nx.Session = NS(MarkVisibility=NS(Visible=1, Invisible=0))
+ nx.MeasureBodies = NS(AnalysisUnit=NS(KilogramMillimeter=6))
+ nx.BasePart = NS(
+ Units=NS(Millimeters="mm"),
+ SaveComponents=NS(TrueValue=True, FalseValue=False),
+ CloseAfterSave=NS(FalseValue=False),
+ CloseWholeTree=NS(FalseValue=False),
+ CloseModified=NS(CloseModified=0),
+ )
+ nx.SketchWorkRegionBuilder = NS(ScopeType=NS(EntireSketch="all"))
+ nx.View = NS(
+ RenderingStyleType=NS(Shaded="shaded", ShadedWithEdges="edges", StaticWireframe="wire")
+ )
+ session.Parts.SetWork = lambda p: setattr(session.Parts, "Work", p)
+
+ def display(p, *_):
+ session.Parts.Display = p
+ return None, NS(Dispose=Mock())
+
+ session.Parts.SetDisplay = display
+ session.Parts.OpenBase = lambda path: (
+ Part(session, __import__("pathlib").Path(path)),
+ NS(Dispose=Mock()),
+ )
+ e = HardenedExecutor(
+ session, nx, "v2606 fake seam", Workspace(tmp_path), enable_experimental=True
+ )
+
+ def ref(obj, kind="body"):
+ return e._reference(obj, kind, part, kind)["id"]
+
+ def object_for(tag):
+ return next(entry.value for entry in e.objects._objects.values() if entry.value.Tag == tag)
+
+ uf = NS(
+ Obj=NS(AskTranslucency=lambda tag: object_for(tag).transparency),
+ Disp=NS(
+ ColorName=NS(RED_NAME=3, MEDIUM_GRAY_NAME=5), AskClosestColorInDisplayedPart=lambda x: x
+ ),
+ ModlGeneral=NS(
+ AskBoundingBox=lambda tag: object_for(tag).box,
+ AskBoundingBoxExact=lambda tag, _: (
+ object_for(tag).box[:3],
+ [1, 0, 0, 0, 1, 0, 0, 0, 1],
+ [object_for(tag).box[i + 3] - object_for(tag).box[i] for i in range(3)],
+ ),
+ ),
+ )
+ modules = {
+ "Preferences": NS(
+ Font=NS(Solid=1, Dashed=2, ValueOf=lambda value: value),
+ Width=NS(
+ Original=0,
+ Thin=1,
+ Normal=2,
+ Thick=3,
+ One=5,
+ Two=6,
+ Three=7,
+ Four=8,
+ Five=9,
+ Six=10,
+ Seven=11,
+ Eight=12,
+ Nine=13,
+ ),
+ ShadingRenderingStyleOption=NS(FullyShaded=0, PartiallyShaded=1, Wireframe=2),
+ ),
+ "UF": NS(UFSession=NS(GetUFSession=lambda: uf)),
+ "Display": NS(
+ DynamicSectionTypes=NS(
+ Type=NS(OnePlane=1), CoordinateSystem=NS(Absolute=1), Clip=NS(Section=1)
+ )
+ ),
+ "Gateway": NS(
+ ImageExportBuilder=NS(
+ FileFormats=NS(Png=1),
+ BackgroundOptions=NS(CustomColor=1, Original=2, Transparent=3),
+ )
+ ),
+ "GeometricAnalysis": NS(
+ SimpleInterference=NS(
+ InterferenceMethod=NS(InterferenceSolid=1),
+ Result=NS(InterferenceExists=1, OnlyEdgesOrFacesInterfere=2, NoInterference=3),
+ )
+ ),
+ }
+ monkeypatch.setitem(__import__("sys").modules, "NXOpen", nx)
+ for name, mod in modules.items():
+ setattr(nx, name, mod)
+ monkeypatch.setitem(__import__("sys").modules, "NXOpen." + name, mod)
+ modifications = []
+
+ def modification():
+ m = NS(Dispose=Mock())
+
+ def apply(values):
+ for obj in values:
+ for target in [obj] + (obj.GetFaces() if isinstance(obj, Body) else []):
+ if hasattr(m, "NewColor"):
+ target.Color = m.NewColor
+ if hasattr(m, "NewTranslucency"):
+ target.transparency = m.NewTranslucency
+
+ m.Apply = Mock(side_effect=apply)
+ modifications.append(m)
+ return m
+
+ session.DisplayManager = NS(NewDisplayModification=modification)
+ return NS(e=e, session=session, part=part, nx=nx, uf=uf, ref=ref, modifications=modifications)
diff --git a/tests/fixtures/sheet-metal-examples.json b/tests/fixtures/sheet-metal-examples.json
new file mode 100644
index 0000000..87da5e3
--- /dev/null
+++ b/tests/fixtures/sheet-metal-examples.json
@@ -0,0 +1,30 @@
+{
+ "nx_version": "v2606",
+ "stage": "pre-release native executor validation; public MCP acceptance is separate",
+ "historical_source": "https://github.com/xuio/NX_MCP/blob/a8ef3a270fbf4083b54ea48b06b64257dd910357/docs/sheet-metal-native-validation.json",
+ "operations": {
+ "advanced_flange": {
+ "native_type": "AdvancedFlange",
+ "source_fixture": "suite2",
+ "model_health": true,
+ "saved_and_closed": true,
+ "parameters": {
+ "edges": [
+ "$input_1"
+ ],
+ "length": 20,
+ "angle": 90
+ }
+ },
+ "unbend": {
+ "native_type": "Unbend",
+ "source_fixture": "suite4/unbend_rebend",
+ "scope": "Native face collector initialized; actual bend flattened; body consistency and subsequent rebend verified"
+ },
+ "rebend": {
+ "native_type": "Rebend",
+ "source_fixture": "suite4/unbend_rebend",
+ "scope": "Flattened bend reformed about the largest stationary web face; native geometry healthy"
+ }
+ }
+}
diff --git a/tests/test_advanced_authoring.py b/tests/test_advanced_authoring.py
new file mode 100644
index 0000000..90153db
--- /dev/null
+++ b/tests/test_advanced_authoring.py
@@ -0,0 +1,491 @@
+"""Failure boundaries and selection semantics; native fixtures test NX geometry."""
+
+import copy
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Component, Curve, Face, Object, Sketch, point
+from tests.test_authoring_review import Expression
+from tests.test_authoring_review import author as author_fixture
+
+pytestmark = pytest.mark.fake_nx
+
+
+@pytest.fixture
+def author(rig, monkeypatch):
+ import sys
+ from types import ModuleType
+
+ r = author_fixture.__wrapped__(rig)
+ r.e._active_mark = None
+ module = ModuleType("NXOpen.GeometricUtilities")
+ module.PatternDefinition = NS(PatternEnum=NS(Linear="linear"))
+ monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", module)
+ r.nx.GeometricUtilities = module
+ return r
+
+
+def test_exact_distance_ranks_surface_not_center(author):
+ a = author.body.faces[0]
+ b = Face()
+ b.box = [0, 0, 9, 1, 1, 9]
+ b.native_type, b.normal, b.radius = 22, [0, 0, 1], 0
+ a.box = [-100, -100, 10, 100, 100, 10]
+ author.body.faces.append(b)
+ author.uf.Modeling.AskMinimumDist3.side_effect = lambda level, tag, *args: (
+ 1 if tag == a.Tag else 5,
+ [0, 0, 10],
+ [0, 0, 11],
+ 0,
+ )
+ result = author.e._find_geometry(near=[0, 0, 11])
+ assert result["items"][0]["object"]["id"] == author.ref(a, "face")
+ assert result["items"][0]["distance"] == 1
+ assert result["ranking"] == "native BREP minimum distance"
+ author.uf.Modeling.AskMinimumDist3.assert_called_with(
+ 2, b.Tag, 0, 0, [0.0, 0.0, 0.0], 1, [0.0, 0.0, 11.0]
+ )
+
+
+def test_selection_rule_requeries_and_rejects_ties(author):
+ result = author.e._find_geometry(near=[0, 0, 11])
+ selector = result["selector"]
+ assert author.e._resolve_geometry(selector)["match"]["distance"] == 2
+ author.uf.Modeling.AskMinimumDist3.return_value = (8, [0, 0, 3], [0, 0, 11], 0)
+ assert author.e._resolve_geometry(selector)["match"]["distance"] == 8
+ author.body.faces.append(copy.copy(author.body.faces[0]))
+ author.body.faces[-1].Tag += 100000
+ with pytest.raises(NXToolError, match="tied"):
+ author.e._resolve_geometry(selector)
+
+
+@pytest.mark.parametrize("change", ["version", "extra", "query", "owner_part", "owner"])
+def test_malformed_and_wrong_owner_rules_rejected(author, change):
+ selector = author.e._find_geometry(near=[0, 0, 11])["selector"]
+ if change == "extra":
+ selector["extra"] = 1
+ elif change == "version":
+ selector["version"] = 2
+ elif change == "query":
+ selector["query"]["extra"] = 1
+ elif change == "owner_part":
+ selector["owner_part"] = "other.prt"
+ else:
+ selector["owner"] = {"kind": "face"}
+ with pytest.raises(NXToolError):
+ author.e._resolve_geometry(selector)
+
+
+def test_saved_owner_resolution_and_no_match(author):
+ owner = author.ref(author.body, "body")
+ selector = author.e._find_geometry(owner=owner, near=[0, 0, 11])["selector"]
+ assert selector["owner"]["kind"] == "body"
+ assert author.e._resolve_geometry(selector)["match"]
+ selector["query"]["geometry_type"] = "cylinder"
+ with pytest.raises(NXToolError, match="No geometry"):
+ author.e._resolve_geometry(selector)
+
+
+def test_bore_groups_and_partial_faces(author):
+ faces = [
+ {
+ "object": {"id": "a"},
+ "cylindrical_role": "bore",
+ "axis": [0, 0, 1],
+ "axis_point": [0, 0, 0],
+ },
+ {
+ "object": {"id": "b"},
+ "cylindrical_role": "bore",
+ "axis": [0, 0, -1],
+ "axis_point": [0, 0, 10],
+ },
+ {
+ "object": {"id": "c"},
+ "cylindrical_role": "boss",
+ "axis": [0, 0, 1],
+ "axis_point": [10, 0, 0],
+ },
+ ]
+ author.e._find_geometry = Mock(return_value={"items": faces, "next_offset": None})
+ author.e._resolve = Mock(return_value=Object())
+ author.uf.Modeling.AskFaceUvMinmax = Mock(
+ side_effect=[[0, 6.283185307179586, 0, 10], [0, 3.14, 0, 10]]
+ )
+ r = author.e._recognize_holes()
+ assert r["total"] == 2 and len(r["coaxial_groups"]) == 1
+ assert r["items"][0]["full_circumference"] and not r["items"][1]["full_circumference"]
+
+
+@pytest.mark.parametrize(
+ "values",
+ [
+ {},
+ {"missing": "1"},
+ {"height": ""},
+ {"height": " " * 5},
+ {"height": 123},
+ {"height": "x" * 4097},
+ ],
+)
+def test_feature_parameter_preflight(author, values):
+ with pytest.raises(NXToolError):
+ author.e._set_feature_parameters(author.ref(author.feature, "feature"), values)
+ author.part.Expressions.EditExpression.assert_not_called()
+
+
+def test_feature_parameters_ownership_and_atomic_update(author):
+ r = author.e._feature_parameters(author.ref(author.feature, "feature"))
+ assert r["parameters"][0]["name"] == "height"
+ author.e._set_feature_parameters(author.ref(author.feature, "feature"), {"height": "18"})
+ assert author.expression.RightHandSide == "18"
+ other = Expression("unrelated")
+ author.part.Expressions.append(other)
+ with pytest.raises(NXToolError, match="not owned"):
+ author.e._set_feature_parameters(
+ author.ref(author.feature, "feature"), {"height": "20", "unrelated": "30"}
+ )
+ assert author.expression.RightHandSide == "18"
+ with pytest.raises(NXToolError, match="Duplicate"):
+ author.e._set_feature_parameters(
+ author.ref(author.feature, "feature"),
+ {"height": "20", author.ref(author.expression, "expression"): "30"},
+ )
+ author.expression.IsNoEdit = True
+ with pytest.raises(NXToolError, match="editable"):
+ author.e._set_feature_parameters(author.ref(author.feature, "feature"), {"height": "20"})
+
+
+@pytest.mark.parametrize(
+ "count,spacing", [(1, 1), (101, 1), (True, 1), (2.5, 1), (2, 0), (2, float("nan"))]
+)
+def test_native_pattern_invalid_inputs_precede_builder(author, count, spacing):
+ with pytest.raises(NXToolError):
+ author.e._native_component_pattern("missing", [1, 0, 0], spacing, count)
+
+
+@pytest.fixture
+def patterned(author):
+ r = author
+ root = Component("root")
+ r.part.ComponentAssembly.RootComponent = root
+ seed = Component("seed", parent=root)
+ r.nx.GeometricUtilities.PatternDefinition = NS(PatternEnum=NS(Linear="linear"))
+ r.nx.Vector3d = point
+ r.nx.SmartObject = NS(UpdateOption=NS(WithinModeling="model"))
+ r.part.Directions = NS(CreateDirection=Mock(return_value="direction"))
+ count = Expression("count")
+ count.Value = 4
+ pitch = Expression("pitch")
+ pitch.Value = 20
+ pattern = Object("pattern")
+ pattern.GetComponentsToPattern = lambda: [seed]
+ others = [Component("copy" + str(i), parent=root) for i in range(3)]
+ for i, c in enumerate(others):
+ c.position = point((i + 1) * 20, 0, 0)
+ pattern.GetAllPatternMembers = lambda: [NS(GetAllComponents=lambda: [seed, *others])]
+ b = NS(
+ Associative=True,
+ ComponentPatternSet=NS(Add=Mock()),
+ PatternService=NS(
+ PatternType="linear",
+ PatternEnum=NS(Linear="linear", Circular="circular"),
+ RectangularDefinition=NS(
+ UseYDirectionToggle=False,
+ XSpacing=NS(NCopies=count, PitchDistance=pitch),
+ YSpacing=NS(NCopies=Expression("y")),
+ ),
+ ),
+ Commit=Mock(return_value=pattern),
+ Destroy=Mock(),
+ )
+ r.part.ComponentAssembly.CreateComponentPatternBuilder = Mock(return_value=b)
+ r.part.ComponentAssembly.ComponentPatterns = NS(GetAllComponentPatterns=lambda: [pattern])
+ r.seed, r.pattern, r.pattern_builder = seed, pattern, b
+ return r
+
+
+def test_native_pattern_creation_readback_edit_and_cleanup(patterned):
+ r = patterned
+ result = r.e._native_component_pattern(r.ref(r.seed, "component"), [1, 0, 0], 20, 4)
+ assert result["associative"] and result["total_instances"] == 4
+ assert result["native_type"] == "NXOpen.Assemblies.ComponentPattern"
+ assert r.e._list_component_patterns()["count"] == 1
+ r.e._edit_component_pattern(result["object"]["id"], spacing=22, count=4)
+ assert (
+ r.pattern_builder.PatternService.RectangularDefinition.XSpacing.PitchDistance.RightHandSide
+ == "22.0"
+ )
+ assert r.pattern_builder.Destroy.call_count == 5
+
+
+def test_pattern_failure_count_and_unsupported_edits(patterned):
+ r = patterned
+ with pytest.raises(NXToolError, match="count differs"):
+ r.e._native_component_pattern(r.ref(r.seed, "component"), [1, 0, 0], 20, 5)
+ with pytest.raises(NXToolError, match="Supply"):
+ r.e._edit_component_pattern(r.ref(r.pattern, "component_pattern"))
+ r.pattern_builder.Associative = False
+ with pytest.raises(NXToolError, match="associative"):
+ r.e._edit_component_pattern(r.ref(r.pattern, "component_pattern"), spacing=20)
+ r.seed.IsSuppressed = True
+ with pytest.raises(NXToolError, match="unsuppressed"):
+ r.e._native_component_pattern(r.ref(r.seed, "component"), [1, 0, 0], 20, 4)
+
+
+@pytest.fixture
+def constrained(author):
+ r = author
+
+ class Line(Curve):
+ pass
+
+ class Arc(Curve):
+ pass
+
+ r.nx.Line, r.nx.Arc = Line, Arc
+ sk = Sketch(r.session)
+ r.part.Sketches.append(sk)
+ sk.Update = Mock()
+ a, b, arc = Line(), Line(), Arc()
+ for c in (a, b):
+ c.StartPoint, c.EndPoint = point(), point(10, 0, 0)
+ sk.geometry = [a, b, arc]
+ r.nx.Sketch.DimensionGeometry = lambda: NS(Geometry=None, AssocType=None)
+ r.nx.Sketch.ConstraintGeometry = lambda: NS(Geometry=None, PointType=None)
+ r.nx.Sketch.DimensionOption = NS(CreateAsReference="reference", CreateAsDriving="driving")
+ r.nx.Sketch.AssocType = NS(StartPoint="start", EndPoint="end")
+ r.nx.Sketch.ConstraintPointType = NS(StartVertex="start", EndVertex="end", ArcCenter="center")
+ for name in ["ParallelDim", "HorizontalDim", "VerticalDim"]:
+ setattr(r.nx.Sketch.ConstraintType, name, name)
+ constraint = Object()
+ constraint.AssociatedExpression = Expression("dim")
+ for name in [
+ "CreateDimension",
+ "CreateRadialDimension",
+ "CreateDiameterDimension",
+ "CreateParallelConstraint",
+ "CreatePerpendicularConstraint",
+ "CreateEqualLengthConstraint",
+ "CreateEqualRadiusConstraint",
+ "CreateConcentricConstraint",
+ "CreateCoincidentConstraint",
+ ]:
+ setattr(sk, name, Mock(return_value=constraint))
+ r.e._relation_residual = Mock(return_value=0.0)
+ r.e._sketch_diagnostics = Mock(
+ return_value={"solver_status": "UnderConstrained", "constraints": [], "constraint_count": 0}
+ )
+ for name in [
+ "CreateParallelConstraint",
+ "CreatePerpendicularConstraint",
+ "CreateEqualLengthConstraint",
+ "CreateEqualRadiusConstraint",
+ "CreateConcentricConstraint",
+ "CreateCoincidentConstraint",
+ ]:
+
+ def create(*args):
+ sk.constraints.append(constraint)
+ return constraint
+
+ getattr(sk, name).side_effect = create
+ constraint.ConstraintType = 99
+ r.sk, r.lines, r.arc = sk, [a, b], arc
+ return r
+
+
+@pytest.mark.parametrize("kind", ["length", "horizontal", "vertical", "radius", "diameter"])
+def test_dimensions_apply_formula_restore_activation(constrained, kind):
+ r = constrained
+ result = r.e._sketch_dimension(
+ r.ref(r.sk, "sketch"),
+ r.ref(r.arc if kind in {"radius", "diameter"} else r.lines[0], "curve"),
+ kind,
+ 12,
+ [0, 2],
+ )
+ assert result["expression"]["formula"] == "12.0"
+ assert r.session.ActiveSketch is None
+
+
+@pytest.mark.parametrize(
+ "kind,value,origin,reference",
+ [
+ ("bad", 1, [0, 0], False),
+ ("radius", 1, [0, 0], False),
+ ("length", 0, [0, 0], False),
+ ("length", 1, [0], False),
+ ("length", 1, [0, 0], 1),
+ ("length", 15, [0, 0], True),
+ ],
+)
+def test_dimension_rejections(constrained, kind, value, origin, reference):
+ r = constrained
+ with pytest.raises(NXToolError):
+ r.e._sketch_dimension(
+ r.ref(r.sk, "sketch"), r.ref(r.lines[0], "curve"), kind, value, origin, reference
+ )
+ assert r.session.ActiveSketch is None
+
+
+@pytest.mark.parametrize("relation", ["parallel", "perpendicular", "equal_length", "coincident"])
+def test_line_relations_restore_activation(constrained, relation):
+ r = constrained
+ kw = {"point1": "end", "point2": "start"} if relation == "coincident" else {}
+ result = r.e._sketch_relation(
+ r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], relation, **kw
+ )
+ assert result["constraint"] and r.session.ActiveSketch is None
+
+
+@pytest.mark.parametrize(
+ "relation,points",
+ [
+ ("bad", {}),
+ ("parallel", {"point1": "start"}),
+ ("equal_radius", {}),
+ ("coincident", {}),
+ ("coincident", {"point1": "center", "point2": "end"}),
+ ],
+)
+def test_relation_preflight(constrained, relation, points):
+ r = constrained
+ with pytest.raises(NXToolError):
+ r.e._sketch_relation(
+ r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], relation, **points
+ )
+ r.sk.CreateParallelConstraint.assert_not_called()
+
+
+def test_conflicting_solver_state_rejected(constrained):
+ r = constrained
+ r.e._sketch_diagnostics.return_value["solver_status"] = "OverConstrained"
+ with pytest.raises(NXToolError, match="conflicts"):
+ r.e._sketch_relation(
+ r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], "parallel"
+ )
+ assert r.session.ActiveSketch is None
+
+
+def test_other_active_sketch_and_foreign_curve_rejected(constrained):
+ r = constrained
+ r.session.ActiveSketch = Sketch(r.session)
+ with pytest.raises(NXToolError, match="other active"):
+ r.e._sketch_dimension(
+ r.ref(r.sk, "sketch"), r.ref(r.lines[0], "curve"), "length", 10, [0, 0]
+ )
+ with pytest.raises(NXToolError, match="not owned"):
+ r.e._owned_curve(r.sk, r.ref(Curve(), "curve"))
+
+
+def test_conflict_trials_are_bounded_and_restored(constrained):
+ r = constrained
+ constraints = [{"object": {"id": r.ref(Object(), "constraint")}} for _ in range(3)]
+ r.e._sketch_diagnostics.side_effect = [
+ {"solver_status": "OverConstrained", "constraints": constraints, "constraint_count": 3},
+ {"solver_status": "UnderConstrained"},
+ {"solver_status": "OverConstrained"},
+ ]
+ r.sk.DeleteObjects = Mock(return_value=None)
+ result = r.e._sketch_conflicts(r.ref(r.sk, "sketch"), 2)
+ assert result["checked"] == 2 and not result["complete"]
+ assert result["single_removal_relief"] == [constraints[0]["object"]]
+ assert not r.session.marks and r.session.ActiveSketch is None
+
+
+@pytest.mark.parametrize("value", [0, 51, True])
+def test_conflict_limits(constrained, value):
+ with pytest.raises(NXToolError):
+ constrained.e._sketch_conflicts("missing", value)
+
+
+def test_explicit_legacy_constraint_contradiction_even_if_native_status_undercounted(constrained):
+ r = constrained
+ r.nx.Sketch.ConstraintType.Horizontal = 10
+ r.nx.Sketch.ConstraintType.Vertical = 11
+ a, b = Object(), Object()
+ a.ConstraintType, b.ConstraintType = 10, 11
+ r.sk.constraints = [a, b]
+ pairs = r.e._explicit_constraint_conflicts(r.sk)
+ assert len(pairs) == 2
+ assert pairs[0]["reason"] == "A nonzero line cannot be both horizontal and vertical"
+ with pytest.raises(NXToolError, match="conflicts"):
+ r.e._check_sketch_result(r.ref(r.sk, "sketch"))
+
+
+def test_conflict_cleanup_failure_reports_partial(constrained):
+ r = constrained
+ r.session.UndoToMark = Mock(side_effect=RuntimeError("undo failed"))
+ with pytest.raises(NXToolError) as error:
+ r.e._sketch_conflicts(r.ref(r.sk, "sketch"))
+ assert error.value.details["mutation_outcome"] == "partial"
+
+
+def test_relation_noop_is_rejected(constrained):
+ r = constrained
+ r.e._relation_residual.return_value = 1.0
+ with pytest.raises(NXToolError, match="did not satisfy"):
+ r.e._sketch_relation(
+ r.ref(r.sk, "sketch"), *[r.ref(c, "curve") for c in r.lines], "parallel"
+ )
+ assert r.session.ActiveSketch is None
+
+
+def test_geometric_relation_residuals(constrained):
+ from nx_mcp.advanced_authoring import AdvancedAuthoringMixin
+
+ r = constrained
+
+ def residual(a, b, rel, p1=None, p2=None):
+ return AdvancedAuthoringMixin._relation_residual(r.e, a, b, rel, p1, p2)
+
+ a, b = r.lines
+ assert residual(a, b, "parallel") == 0
+ assert residual(a, b, "perpendicular") == 1
+ b.EndPoint = point(0, 20, 0)
+ assert residual(a, b, "perpendicular") == 0
+ assert residual(a, b, "equal_length") == 10
+ assert residual(a, b, "coincident", "start", "start") == 0
+ r.arc.Radius = 5
+ r.arc.CenterPoint = point()
+ other = type(r.arc)()
+ other.Radius = 7
+ other.CenterPoint = point(0, 0, 2)
+ assert residual(r.arc, other, "equal_radius") == 2
+ assert residual(r.arc, other, "concentric") == 2
+ with pytest.raises(NXToolError):
+ r.e._relation_point(r.arc, "start")
+
+
+@pytest.mark.parametrize(
+ "relation",
+ ["parallel", "perpendicular", "equal_length", "equal_radius", "concentric", "coincident"],
+)
+def test_modern_builder_configuration_and_cleanup(constrained, relation):
+ r = constrained
+ builder = NS(
+ StationaryObject=NS(SetValue=Mock()),
+ MotionObjects=NS(Add=Mock()),
+ MotionPoints=NS(Add=Mock()),
+ SetCreateConstraints=Mock(),
+ FindRelations=Mock(),
+ Commit=Mock(),
+ Destroy=Mock(),
+ )
+ for name in ["Parallel", "Perpendicular", "Equal", "Coincident"]:
+ setattr(r.part.Sketches, "CreateSketchMake" + name + "Builder", lambda: builder)
+ r.nx.InferSnapType = NS(SnapType=NS(Start="start", End="end", Center="center"))
+ r.nx.SketchMakeEqualBuilder = NS(EqualTypes=NS(Radius="radius", Length="length"))
+ a, b = r.lines
+ if relation in ["concentric", "equal_radius"]:
+ a = r.arc
+ b = type(a)()
+ a.CenterPoint = b.CenterPoint = point()
+ r.e._modern_relation(r.sk, a, b, relation, "start", "end")
+ builder.SetCreateConstraints.assert_called_once_with(True)
+ builder.Commit.assert_called_once()
+ builder.Destroy.assert_called_once()
diff --git a/tests/test_agent_improvements.py b/tests/test_agent_improvements.py
new file mode 100644
index 0000000..753d711
--- /dev/null
+++ b/tests/test_agent_improvements.py
@@ -0,0 +1,189 @@
+import os
+import time
+from unittest.mock import AsyncMock
+
+import pytest
+
+from nx_mcp.agent_guidance import guidance
+from nx_mcp.agent_surface import ResultStore, compact_payload
+from nx_mcp.result_retention import maintain
+from nx_mcp.server import create_server
+from nx_mcp.workspace import Workspace
+
+
+@pytest.mark.asyncio
+async def test_guidance_examples_match_real_input_schemas(tmp_path):
+ from nx_mcp.agent_guidance import GUIDANCE
+
+ server = create_server(AsyncMock(), Workspace(tmp_path), enable_experimental=True)
+ for name, (_, _, example) in GUIDANCE.items():
+ tool = server._tool_manager.get_tool(name)
+ assert tool is not None
+ if example is not None:
+ tool.fn_metadata.arg_model.model_validate(example)
+ result = guidance(tool)
+ assert result["native_evidence"]["status"] in {"tested", "experimental", "unavailable"}
+ assert (
+ guidance(server._tool_manager.get_tool("nx_export_step"))["effects"]["saves_part"] is True
+ )
+ assert (
+ guidance(server._tool_manager.get_tool("nx_measure_volume"))["effects"][
+ "geometry_or_assembly"
+ ]
+ is False
+ )
+
+
+def test_next_actions_use_returned_ids_without_fabrication():
+ ref = {"id": "body_live", "kind": "body", "part_id": "owner"}
+ result = compact_payload(
+ {"bodies": [ref], "warnings": ["review"], "mutation_outcome": "committed"},
+ "result_id",
+ "nx_extrude",
+ )
+ assert result["next_actions"][0]["arguments"] == {"body": "body_live"}
+ assert result["warnings"] == ["review"]
+ assert "next_actions" not in compact_payload({"status": "error"}, "result_id")
+
+
+def test_retention_dry_run_protection_and_untouched_recovery(tmp_path, monkeypatch):
+ monkeypatch.setenv("NX_MCP_RESULT_MAX_BYTES", "1000")
+ store = ResultStore(tmp_path)
+ old = store.put({"old": "x" * 100})
+ path = store.root / (old + ".json")
+ os.utime(path, (time.time() - 100, time.time() - 100))
+ unrelated = store.root / "not-a-snapshot.json"
+ unrelated.write_text("keep")
+ receipt = tmp_path / ".nx-mcp/operations/operation.json"
+ receipt.parent.mkdir()
+ receipt.write_text("keep")
+ preview = maintain(store.root, max_age_seconds=10)
+ assert preview["selected_count"] == 1 and path.exists()
+ result = maintain(store.root, apply=True, max_age_seconds=10)
+ assert result["applied"] and not path.exists()
+ assert unrelated.read_text() == receipt.read_text() == "keep"
+ current = store.put({"current": "x" * 100})
+ result = maintain(store.root, apply=True, max_bytes=1, protect=current)
+ assert result["selected_count"] == 0
+ with pytest.raises(ValueError):
+ maintain(store.root, max_bytes=0)
+ with pytest.raises(OSError):
+ store.put({"large": "x" * 2000})
+
+
+@pytest.mark.asyncio
+async def test_filtered_snapshot_pages_reuse_single_native_read(tmp_path):
+ refs = [
+ {"id": str(i), "kind": "feature", "name": f"Bolt {i}", "part_id": "p"} for i in range(31)
+ ]
+ bridge = AsyncMock()
+ bridge.call.return_value = {"objects": refs, "units": "mm"}
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+ first = await server.call_tool(
+ "nx_inspect", {"tool": "nx_list_features", "name_contains": "bolt", "limit": 10}
+ )
+ value = first.structuredContent
+ assert not first.isError and value["count"] == 10 and value["total_count"] == 31
+ second = await server.call_tool(
+ "nx_inspect",
+ {
+ "tool": "nx_list_features",
+ "result_id": value["result_id"],
+ "name_contains": "bolt",
+ "offset": 10,
+ "limit": 10,
+ },
+ )
+ assert second.structuredContent["items"][0]["id"] == "10"
+ assert bridge.call.await_count == 1
+ assert (await server.call_tool("nx_inspect", {"tool": "nx_extrude"})).isError
+ assert (
+ await server.call_tool(
+ "nx_inspect", {"tool": "nx_list_sketches", "result_id": value["result_id"]}
+ )
+ ).isError
+ bad = await server.call_tool("nx_inspect", {"tool": "nx_list_features", "collection": "faces"})
+ assert bad.isError
+ filtered = await server.call_tool(
+ "nx_inspect",
+ {"tool": "nx_list_features", "result_id": value["result_id"], "kind": "sketch"},
+ )
+ assert filtered.structuredContent["count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_annotation_capture_collects_all_pages_and_preserves_frame(tmp_path):
+ bridge = AsyncMock()
+ bridge.call.side_effect = [
+ {
+ "items": [{"object": {"id": "a", "name": "First", "kind": "annotation"}}],
+ "total": 2,
+ "next_offset": 1,
+ "position_frame": "sheet",
+ },
+ {
+ "items": [{"object": {"id": "b", "name": "Second", "kind": "annotation"}}],
+ "total": 2,
+ "next_offset": None,
+ },
+ ]
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+ response = await server.call_tool(
+ "nx_inspect", {"tool": "nx_list_annotations", "name_contains": "second"}
+ )
+ assert not response.isError
+ assert response.structuredContent["total_count"] == 1
+ assert response.structuredContent["unfiltered_count"] == 2
+ assert response.structuredContent["coordinate_frame"] == "sheet"
+
+
+@pytest.mark.asyncio
+async def test_cleanup_tool_defaults_to_preview(tmp_path):
+ store = ResultStore(tmp_path)
+ key = store.put({"large": "x" * 100})
+ server = create_server(
+ AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent"
+ )
+ response = await server.call_tool("nx_result_cleanup", {"max_bytes": 1})
+ assert response.structuredContent["selected_count"] == 1
+ assert store.get(key)
+ response = await server.call_tool("nx_result_cleanup", {"max_bytes": 1, "dry_run": False})
+ assert response.structuredContent["applied"]
+ with pytest.raises(FileNotFoundError):
+ store.get(key)
+
+
+def test_backend_page_cardinality_is_not_silently_truncated():
+ refs = [{"id": str(i), "kind": "part"} for i in range(38)]
+ payload = compact_payload(
+ {"parts": refs, "count": 38, "total_count": 38, "next_offset": None}, "result_id"
+ )
+ assert len(payload["parts"]) == payload["count"] == 38
+ assert payload["next_offset"] is None
+ assert "/parts" not in payload.get("omitted", {})
+ assert len(compact_payload({"objects": refs}, "result_id")["objects"]) == 20
+
+
+def test_next_action_does_not_repeat_the_current_inspection():
+ from nx_mcp.agent_guidance import next_actions
+
+ assert not next_actions("nx_list_topology", {"body": {"id": "body", "kind": "body"}})
+ assert not next_actions("nx_download_file", {"path": "a.png", "sha256": "x"})
+
+
+@pytest.mark.asyncio
+async def test_spaced_discovery_and_optional_output_schema(tmp_path):
+ server = create_server(
+ AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent"
+ )
+ result = await server.call_tool(
+ "nx_discover_tools",
+ {"query": "create part", "include_schema": True, "include_output_schema": False},
+ )
+ rows = result.structuredContent["tools"]
+ assert rows[0]["name"] == "nx_create_part"
+ assert "inputSchema" in rows[0] and "outputSchema" not in rows[0]
+ result = await server.call_tool(
+ "nx_discover_tools", {"query": "nx_create_part", "include_schema": True}
+ )
+ assert "outputSchema" in result.structuredContent["tools"][0]
diff --git a/tests/test_agent_surface.py b/tests/test_agent_surface.py
new file mode 100644
index 0000000..0702490
--- /dev/null
+++ b/tests/test_agent_surface.py
@@ -0,0 +1,279 @@
+"""Agent-profile contracts across a real in-memory MCP boundary (no NX kernel)."""
+
+from unittest.mock import AsyncMock
+
+import pytest
+from mcp.shared.memory import create_connected_server_and_client_session
+
+from nx_mcp.agent_surface import ResultStore, compact
+from nx_mcp.server import create_server
+from nx_mcp.workspace import Workspace
+
+
+def test_projection_preserves_identity_warnings_and_marks_truncation():
+ omitted = {}
+ ref = {
+ "id": "opaque",
+ "kind": "component",
+ "part_id": "owner",
+ "occurrence_path": "root/child",
+ "journal_id": "long",
+ }
+ value = compact(
+ {"objects": [ref] * 25, "warnings": ["w"] * 30, "data_base64": "secret"}, omitted=omitted
+ )
+ assert len(value["objects"]) == 20 and len(value["warnings"]) == 30
+ assert value["objects"][0]["occurrence_path"] == "root/child"
+ assert "journal_id" not in value["objects"][0]
+ assert "data_base64" not in value and omitted["/objects"]["total_count"] == 25
+
+
+def test_snapshot_persistence_and_path_rejection(tmp_path):
+ store = ResultStore(tmp_path)
+ identity = store.put({"answer": 42})
+ assert ResultStore(tmp_path).get(identity) == {"answer": 42}
+ with pytest.raises(ValueError):
+ store.get("../../secret")
+
+
+@pytest.mark.asyncio
+async def test_discovery_defaults_dispatch_and_expansion(tmp_path):
+ bridge = AsyncMock()
+ bridge.call.return_value = {
+ "status": "success",
+ "parts": [],
+ "count": 0,
+ "warnings": [],
+ "units": "mm",
+ }
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+ async with create_connected_server_and_client_session(server) as client:
+ tools = (await client.list_tools()).tools
+ assert len(tools) == 13
+ assert "nx_extrude" not in {t.name for t in tools}
+ result = await client.call_tool(
+ "nx_discover_tools", {"query": "nx_extrude", "include_schema": True}
+ )
+ assert result.structuredContent["total_count"] == 1
+ assert "distance" in result.structuredContent["tools"][0]["inputSchema"]["properties"]
+ result = await client.call_tool("nx_list_open_parts", {})
+ assert not result.isError
+ assert bridge.call.call_args.args[1]["limit"] == 20
+ assert bridge.call.call_args.args[1]["compact"] is True
+ snapshot = await client.call_tool(
+ "nx_result", {"result_id": result.structuredContent["result_id"], "field": "/parts"}
+ )
+ assert snapshot.structuredContent["items"] == []
+ bridge.call.reset_mock()
+ bad = await client.call_tool(
+ "nx_invoke", {"tool": "nx_extrude", "arguments": {"bogus": True}}
+ )
+ assert bad.isError and not bridge.call.called
+ good = await client.call_tool(
+ "nx_invoke", {"tool": "nx_list_open_parts", "arguments": {}, "detail": "full"}
+ )
+ assert "result_id" not in good.structuredContent
+ assert bridge.call.call_args.args[1]["limit"] is None
+
+
+@pytest.mark.asyncio
+async def test_artifact_metadata_resource_and_reserved_path(tmp_path):
+ (tmp_path / "test.txt").write_bytes(b"binary bytes")
+ server = create_server(
+ AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent"
+ )
+ async with create_connected_server_and_client_session(server) as client:
+ result = await client.call_tool("nx_download_file", {"path": "test.txt"})
+ assert not result.isError and "data_base64" not in result.structuredContent
+ link = next(c for c in result.content if c.type == "resource_link")
+ artifact = await client.read_resource(link.uri)
+ assert artifact.contents[0].blob
+ assert len((await client.list_resource_templates()).resourceTemplates) == 1
+ bad = await client.call_tool("nx_download_file", {"path": ".nx-mcp/secret"})
+ assert bad.isError
+
+
+@pytest.mark.asyncio
+async def test_failed_mutation_retains_outcome_and_operation_id(tmp_path):
+ bridge = AsyncMock()
+ bridge.call.return_value = {
+ "status": "error",
+ "code": "NX_TEST",
+ "message": "failed",
+ "warnings": ["important"],
+ "operation_id": "test_operation",
+ "mutation_outcome": "rolled_back",
+ }
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+ result = await server.call_tool(
+ "nx_invoke",
+ {
+ "tool": "nx_extrude",
+ "arguments": {"sketch_id": "x", "distance": 1, "operation_id": "test_operation"},
+ },
+ )
+ assert result.isError and result.structuredContent["mutation_outcome"] == "rolled_back"
+ assert result.structuredContent["operation_id"] == "test_operation"
+
+
+@pytest.mark.asyncio
+async def test_compact_success_replay_receipt_and_full_snapshot(tmp_path):
+ bridge = AsyncMock()
+ ref = {"id": "a", "kind": "body", "part_id": "p", "session_id": "s"}
+ bridge.call.return_value = {
+ "status": "success",
+ "units": "mm",
+ "warnings": ["check"],
+ "operation_id": "operation_123",
+ "mutation_outcome": "committed",
+ "bodies": [ref] * 25,
+ "changes": {"created": [ref] * 25, "modified": None, "deleted": []},
+ }
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+ result = await server.call_tool(
+ "nx_invoke",
+ {
+ "tool": "nx_extrude",
+ "arguments": {"sketch_id": "s", "distance": 2, "operation_id": "operation_123"},
+ },
+ )
+ payload = result.structuredContent
+ assert payload["change_counts"] == {"created": 25, "modified": None, "deleted": 0}
+ assert payload["mutation_outcome"] == "committed" and payload["warnings"] == ["check"]
+ bridge.call.reset_mock()
+ expanded = await server.call_tool(
+ "nx_result", {"result_id": payload["result_id"], "field": "/bodies", "offset": 20}
+ )
+ assert len(expanded.structuredContent["items"]) == 5
+ assert expanded.structuredContent["items"][0]["session_id"] == "s"
+ expanded = await server.call_tool(
+ "nx_result", {"result_id": payload["result_id"], "detail": "full"}
+ )
+ assert len(expanded.structuredContent["value"]["bodies"]) == 25
+ assert not bridge.call.called
+ for args in [{"field": "bodies"}, {"field": "/absent"}, {"offset": -1}, {"limit": 101}]:
+ bad = await server.call_tool("nx_result", {"result_id": payload["result_id"], **args})
+ assert bad.isError
+
+
+@pytest.mark.asyncio
+async def test_storage_failure_does_not_relabel_committed_mutation(tmp_path, monkeypatch):
+ bridge = AsyncMock()
+ bridge.call.return_value = {
+ "status": "success",
+ "mutation_outcome": "committed",
+ "operation_id": "operation_123",
+ }
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+
+ def fail(*args):
+ raise OSError("full disk")
+
+ monkeypatch.setattr(ResultStore, "put", fail)
+ result = await server.call_tool(
+ "nx_invoke",
+ {
+ "tool": "nx_extrude",
+ "arguments": {"sketch_id": "s", "distance": 2, "operation_id": "operation_123"},
+ },
+ )
+ assert not result.isError and result.structuredContent["mutation_outcome"] == "committed"
+
+
+@pytest.mark.asyncio
+async def test_discovery_paging_and_argument_errors(tmp_path):
+ server = create_server(
+ AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent"
+ )
+ for args in [{"limit": 21}, {"offset": -1}, {"domain": "wrong"}]:
+ assert (await server.call_tool("nx_discover_tools", args)).isError
+ result = await server.call_tool(
+ "nx_discover_tools", {"limit": 2, "offset": 2, "domain": "modeling"}
+ )
+ assert len(result.structuredContent["tools"]) == 2
+ assert result.structuredContent["next_offset"] == 4
+ assert (await server.call_tool("nx_invoke", {"tool": "missing", "arguments": {}})).isError
+ assert (await server.call_tool("missing", {})).isError
+
+
+def test_profile_validation(tmp_path):
+ with pytest.raises(ValueError):
+ create_server(surface="typo")
+ with pytest.raises(ValueError):
+ create_server(surface="agent", enable_experimental=False)
+
+
+def test_dual_http_profiles(tmp_path, monkeypatch):
+ from starlette.testclient import TestClient
+
+ from nx_mcp.http_surface import create_app
+
+ monkeypatch.setenv("NX_MCP_ENABLE_EXPERIMENTAL", "1")
+ app = create_app(AsyncMock(), Workspace(tmp_path))
+ with TestClient(app, base_url="http://127.0.0.1:8765") as client:
+ for path, count in [("/mcp", 189), ("/agent/mcp", 13)]:
+ response = client.post(
+ path,
+ headers={"Accept": "application/json, text/event-stream"},
+ json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
+ )
+ assert response.status_code == 200
+ assert len(response.json()["result"]["tools"]) == count
+
+
+@pytest.mark.asyncio
+async def test_resource_traversal_and_size_limit(tmp_path):
+ from mcp.server.fastmcp.exceptions import ResourceError
+
+ server = create_server(
+ AsyncMock(), Workspace(tmp_path), enable_experimental=True, surface="agent"
+ )
+ (tmp_path / ".nx-mcp").mkdir()
+ (tmp_path / ".nx-mcp" / "secret").write_text("secret")
+ large = tmp_path / "large.bin"
+ with large.open("wb") as stream:
+ stream.truncate(8 * 1024 * 1024 + 1)
+ for uri in [
+ "nx-artifact://workspace/.nx-mcp%2Fsecret",
+ "nx-artifact://workspace/large.bin",
+ "nx-artifact://workspace/..%2Fsecret",
+ ]:
+ with pytest.raises((ResourceError, ValueError)):
+ await server.read_resource(uri)
+
+
+@pytest.mark.asyncio
+async def test_gateway_rejects_ignored_top_level_arguments(tmp_path):
+ bridge = AsyncMock()
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True, surface="agent")
+ result = await server.call_tool(
+ "nx_invoke",
+ {
+ "tool": "nx_extrude",
+ "arguments": {"sketch_id": "s", "distance": 2},
+ "operation_id": "wrong_place",
+ },
+ )
+ assert result.isError and not bridge.call.called
+
+
+def test_benchmark_uses_shared_projection_and_exposes_usage_limitations():
+ import runpy
+ from pathlib import Path
+ from types import SimpleNamespace
+
+ script = runpy.run_path(
+ str(Path(__file__).resolve().parents[1] / "scripts/benchmark_agent_surface.py")
+ )
+ encoding = SimpleNamespace(name="test", encode=lambda value, **kwargs: list(value))
+ result = script["benchmark"](
+ [
+ {"state": "submitted"},
+ {"state": "response", "result": {"status": "success", "data_base64": "x" * 10000}},
+ ],
+ encoding,
+ )
+ assert result["recorded_calls"] == 1 and result["binary_transfer_calls"] == 1
+ assert result["compact_response_tokens"] < result["full_response_tokens"]
+ assert result["provider_input_tokens"] == "unavailable"
+ assert result["model_calls"] == 0
diff --git a/tests/test_artifact_recovery.py b/tests/test_artifact_recovery.py
new file mode 100644
index 0000000..fe0bdb4
--- /dev/null
+++ b/tests/test_artifact_recovery.py
@@ -0,0 +1,276 @@
+"""Artifact integrity, interrupted uploads and publication boundaries."""
+
+import base64
+import hashlib
+import json
+import zipfile
+from pathlib import Path
+from unittest.mock import AsyncMock
+
+import pytest
+
+from nx_mcp.integration_server import artifact_call, package_assembly
+from nx_mcp.recovery import OperationStore
+from nx_mcp.runtime import NXToolError
+from nx_mcp.server import create_server
+from nx_mcp.workspace import Workspace
+
+
+def upload_params(**overrides):
+ return {
+ "path": "fixture.step",
+ "offset": 0,
+ "total_size": 4,
+ "sha256": hashlib.sha256(b"abcd").hexdigest(),
+ "data_base64": base64.b64encode(b"ab").decode(),
+ **overrides,
+ }
+
+
+@pytest.mark.parametrize(
+ "override,code",
+ [
+ ({"path": "script.exe"}, "NX_UNSUPPORTED_FILE_TYPE"),
+ ({"offset": -1}, "NX_INVALID_ARGUMENT"),
+ ({"total_size": 300 * 1024 * 1024}, "NX_INVALID_ARGUMENT"),
+ ({"sha256": "x" * 64}, "NX_INVALID_ARGUMENT"),
+ ({"sha256": "a"}, "NX_INVALID_ARGUMENT"),
+ ({"data_base64": base64.b64encode(b"abcde").decode()}, "NX_INVALID_ARGUMENT"),
+ ({"offset": 2}, "NX_UPLOAD_GAP"),
+ ],
+)
+def test_upload_rejects_invalid_chunks_without_publishing(tmp_path, override, code):
+ with pytest.raises(NXToolError) as error:
+ artifact_call("nx_upload_file", upload_params(**override), Workspace(tmp_path))
+ assert error.value.code == code
+ assert not (tmp_path / "fixture.step").exists()
+
+
+def test_interrupted_upload_conflict_gap_and_checksum_do_not_publish(tmp_path):
+ w = Workspace(tmp_path)
+ p = upload_params()
+ assert not artifact_call("nx_upload_file", p, w)["committed"]
+ for override, code in [
+ ({"data_base64": base64.b64encode(b"xx").decode()}, "NX_UPLOAD_CONFLICT"),
+ ({"offset": 3, "data_base64": base64.b64encode(b"d").decode()}, "NX_UPLOAD_GAP"),
+ ({"offset": 2, "data_base64": base64.b64encode(b"xx").decode()}, "NX_CHECKSUM_MISMATCH"),
+ ]:
+ with pytest.raises(NXToolError) as error:
+ artifact_call("nx_upload_file", dict(p, **override), w)
+ assert error.value.code == code and not (tmp_path / "fixture.step").exists()
+ # Recovery uses a corrected digest; bytes from an invalid upload cannot be silently reused.
+ corrected = b"zzzz"
+ q = upload_params(
+ sha256=hashlib.sha256(corrected).hexdigest(),
+ data_base64=base64.b64encode(corrected).decode(),
+ )
+ assert artifact_call("nx_upload_file", q, w)["committed"]
+ assert (tmp_path / "fixture.step").read_bytes() == corrected
+
+
+def test_workspace_metadata_and_cancellation_are_scoped(tmp_path):
+ w = Workspace(tmp_path)
+ (tmp_path / "a.txt").write_text("hello")
+ (tmp_path / "sub").mkdir()
+ store = OperationStore(tmp_path)
+ store.put({"operation_id": "batch-operation", "state": "running", "method": "nx_batch"})
+ assert artifact_call("nx_cancel_operation", {"operation_id": "batch-operation"}, w)[
+ "cancellation_requested"
+ ]
+ assert store.path("batch-operation").with_suffix(".cancel").exists()
+ with pytest.raises(NXToolError):
+ artifact_call("nx_cancel_operation", {"operation_id": "not-seen"}, w)
+ assert (
+ artifact_call("nx_operation_status", {"operation_id": "batch-operation"}, w)["state"]
+ == "running"
+ )
+ listed = artifact_call("nx_workspace_list", {"path": "."}, w)
+ assert [v["kind"] for v in listed["entries"]] == ["file", "directory"]
+ assert listed["entries"][0]["sha256"] == hashlib.sha256(b"hello").hexdigest()
+ for offset, length in [(-1, 1), (0, 0), (0, 262145)]:
+ with pytest.raises(NXToolError):
+ artifact_call(
+ "nx_download_file", {"path": "a.txt", "offset": offset, "length": length}, w
+ )
+
+
+@pytest.mark.asyncio
+async def test_package_dependency_manifest_and_no_overwrite(tmp_path):
+ files = [tmp_path / "assembly.prt", tmp_path / "prototype.prt"]
+ for p in files:
+ p.write_text(p.stem)
+ part = {"path": str(files[0]), "work": True, "modified": False}
+ component = {"part_path": str(files[1]), "translation": [1, 2, 3]}
+
+ async def call(method, params):
+ return (
+ {"parts": [part]}
+ if method == "nx_list_open_parts"
+ else {"components": [component, component], "count": 2}
+ )
+
+ bridge = AsyncMock()
+ bridge.call.side_effect = call
+ result = await package_assembly(bridge, Workspace(tmp_path), "assembly.zip")
+ assert result["component_instances"] == 2 and result["prototype_files"] == 1
+ with zipfile.ZipFile(tmp_path / "assembly.zip") as z:
+ manifest = json.loads(z.read("nx-assembly-manifest.json"))
+ assert len(manifest["files"]) == 2 and manifest["components"]["count"] == 2
+ assert z.read("prototype.prt") == b"prototype"
+ before = (tmp_path / "assembly.zip").read_bytes()
+ with pytest.raises(FileExistsError):
+ await package_assembly(bridge, Workspace(tmp_path), "assembly.zip")
+ assert (tmp_path / "assembly.zip").read_bytes() == before
+ assert not list((tmp_path / ".nx-mcp").glob("package-*"))
+ part["modified"] = True
+ with pytest.raises(NXToolError, match="Save referenced"):
+ await package_assembly(bridge, Workspace(tmp_path), "dirty.zip")
+ part["modified"] = False
+ files[1].unlink()
+ with pytest.raises(NXToolError):
+ await package_assembly(bridge, Workspace(tmp_path), "missing.zip")
+ with pytest.raises(NXToolError):
+ await package_assembly(bridge, Workspace(tmp_path), "wrong.txt")
+
+
+@pytest.mark.asyncio
+async def test_mcp_paths_are_validated_before_bridge_dispatch(tmp_path):
+ bridge = AsyncMock()
+ bridge.call.return_value = {"status": "success"}
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True)
+ result = await server.call_tool("nx_import_geometry", {"path": "../secret.step"})
+ assert result.isError and result.structuredContent["code"] == "NX_PATH_OUTSIDE_WORKSPACE"
+ bridge.call.assert_not_called()
+ result = await server.call_tool(
+ "nx_import_geometry", {"path": "part.step", "target": "new_part", "output_path": "new.prt"}
+ )
+ assert not result.isError
+ params = bridge.call.call_args.args[1]
+ assert Path(params["output_path"]) == tmp_path / "new.prt"
+ await server.call_tool(
+ "nx_batch",
+ {"operations": [{"method": "nx_add_component", "params": {"part_path": "p.prt"}}]},
+ )
+ assert (
+ Path(bridge.call.call_args.args[1]["operations"][0]["params"]["part_path"])
+ == tmp_path / "p.prt"
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("kind", ["valid", "changed", "large"])
+@pytest.mark.parametrize("method", ["nx_screenshot", "nx_render_view"])
+async def test_inline_capture_delivery_checks_committed_artifact(tmp_path, kind, method):
+ p = tmp_path / "capture.png"
+ data = b"png" if kind != "large" else b"x" * (8 * 1024 * 1024 + 1)
+ p.write_bytes(data)
+ result = {
+ "status": "success",
+ "path": str(p),
+ "sha256": hashlib.sha256(data).hexdigest() if kind != "changed" else "0" * 64,
+ }
+ bridge = AsyncMock()
+ bridge.call.return_value = result
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True)
+ response = await server.call_tool(method, {"path": "capture.png"})
+ images = [v for v in response.content if v.type == "image"]
+ if kind == "valid":
+ assert base64.b64decode(images[0].data) == data
+ elif kind == "large":
+ assert not images and response.structuredContent["warnings"]
+ else:
+ assert (
+ response.isError
+ and response.structuredContent["details"]["mutation_outcome"] == "committed"
+ )
+
+
+def test_folder_discovery_creation_and_safe_retry(tmp_path):
+ w = Workspace(tmp_path)
+ assert artifact_call("nx_workspace_info", {}, w)["root"] == str(tmp_path)
+ destination = tmp_path / "projects" / "controller" / "parts"
+ first = artifact_call("nx_create_directory", {"path": str(destination)}, w)
+ assert first["created"] and destination.is_dir()
+ repeated = artifact_call("nx_create_directory", {"path": "projects/controller/parts"}, w)
+ assert not repeated["created"] and repeated["path"] == first["path"]
+ (destination / "base.prt").write_bytes(b"fixture")
+ with pytest.raises(NXToolError) as error:
+ artifact_call("nx_create_directory", {"path": str(destination / "base.prt")}, w)
+ assert error.value.code == "NX_DIRECTORY_ERROR"
+ assert (destination / "base.prt").read_bytes() == b"fixture"
+
+
+@pytest.mark.asyncio
+async def test_folder_tools_are_exposed_and_absolute_part_paths_are_forwarded(tmp_path):
+ bridge = AsyncMock()
+ bridge.call.return_value = {"path": str(tmp_path / "projects" / "base.prt")}
+ server = create_server(bridge=bridge, workspace=Workspace(tmp_path), enable_experimental=True)
+ info = await server.call_tool("nx_workspace_info", {})
+ assert info.structuredContent["root"] == str(tmp_path)
+ created = await server.call_tool("nx_create_directory", {"path": "projects/parts"})
+ assert created.structuredContent["created"]
+ destination = str(tmp_path / "projects" / "base.prt")
+ opened = await server.call_tool("nx_open_part", {"path": destination})
+ assert not opened.isError
+ assert bridge.call.call_args.args[1]["path"] == destination
+
+
+@pytest.mark.asyncio
+async def test_inline_image_metadata_paging_and_output_contract(tmp_path):
+ import struct
+
+ from mcp.types import ImageContent
+
+ png = b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", 1, 1)
+ (tmp_path / "view.png").write_bytes(png)
+ for name in ["a.txt", "b.txt", "c.txt"]:
+ (tmp_path / name).write_text("artifact")
+ bridge = AsyncMock()
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True)
+ tools = {t.name: t for t in await server.list_tools()}
+ schema = tools["nx_download_file"].inputSchema["properties"]
+ assert schema["length"]["maximum"] == 262144 and schema["offset"]["minimum"] == 0
+ assert tools["nx_download_file"].outputSchema["required"] == ["status", "warnings", "units"]
+ preview = await server.call_tool("nx_download_file", {"path": "view.png", "delivery": "image"})
+ assert not preview.isError and preview.structuredContent["resolution"] == [1, 1]
+ assert "data_base64" not in preview.structuredContent
+ images = [v for v in preview.content if isinstance(v, ImageContent)]
+ assert len(images) == 1 and base64.b64decode(images[0].data) == png
+ assert png.hex() not in preview.content[0].text
+ meta = await server.call_tool("nx_download_file", {"path": "view.png", "delivery": "metadata"})
+ assert meta.structuredContent["size"] == len(png) and len(meta.content) == 1
+ page = await server.call_tool("nx_workspace_list", {"limit": 2})
+ assert page.structuredContent["count"] == 2 and page.structuredContent["next_offset"] == 2
+ second = await server.call_tool("nx_workspace_list", {"offset": 2, "limit": 2})
+ assert second.structuredContent["next_offset"] is None
+ filtered = await server.call_tool("nx_workspace_list", {"prefix": "v"})
+ assert filtered.structuredContent["total_count"] == 1
+ error = await server.call_tool("nx_workspace_list", {"path": "view.png"})
+ assert error.isError and error.structuredContent["code"] == "NX_NOT_DIRECTORY"
+ for args in [
+ {"length": 262145},
+ {"delivery": "image", "offset": 1},
+ {"delivery": "image", "path": "a.txt"},
+ ]:
+ assert (await server.call_tool("nx_download_file", {"path": "view.png", **args})).isError
+ bridge.call.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_missing_directory_and_boolean_operand_contract(tmp_path):
+ bridge = AsyncMock()
+ bridge.call.return_value = {"status": "success"}
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True)
+ missing = await server.call_tool("nx_workspace_list", {"path": "missing"})
+ assert missing.isError and missing.structuredContent["code"] == "NX_DIRECTORY_NOT_FOUND"
+ tools = {t.name: t for t in await server.list_tools()}
+ schema = tools["nx_boolean"].inputSchema["properties"]
+ assert schema["boolean_type"]["enum"] == ["unite", "subtract", "intersect"]
+ assert schema["targets"]["minItems"] == 2
+ invalid = await server.call_tool("nx_boolean", {"boolean_type": "subtract", "targets": ["a"]})
+ assert invalid.isError
+ bridge.call.assert_not_awaited()
+ await server.call_tool(
+ "nx_boolean", {"boolean_type": "subtract", "targets": ["target", "tool"]}
+ )
+ assert bridge.call.call_args.args[1]["targets"] == ["target", "tool"]
diff --git a/tests/test_assembly_loading.py b/tests/test_assembly_loading.py
new file mode 100644
index 0000000..f04b687
--- /dev/null
+++ b/tests/test_assembly_loading.py
@@ -0,0 +1,91 @@
+"""Regressions for unloaded prototypes and drawing-owned construction curves."""
+
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.assembly_loading import component_info, load_components, require_loaded
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Component, Object, Part
+
+
+def test_unloaded_component_inventory_retains_pose_and_path(rig):
+ root = Component("root")
+ child = Component("missing", parent=root)
+ child.Prototype = Object("unloaded")
+ root.children = [child]
+ rig.part.ComponentAssembly.RootComponent = root
+ rig.uf.Assem = NS(AskComponentData=lambda _: ("D:/model/child.prt", "", "", [], [], []))
+ result = rig.e._list_components(compact=True)
+ row = result["components"][0]
+ assert row["part_path"] == "D:/model/child.prt"
+ assert row["load_state"] == "unloaded"
+ assert len(row["translation"]) == 3
+ with pytest.raises(NXToolError) as error:
+ require_loaded(rig.e, rig.part)
+ assert error.value.details["mutation_outcome"] == "not_started"
+
+
+def test_unknown_prototype_path_is_explicit(rig):
+ rig.uf.Assem = NS(AskComponentData=Mock(side_effect=RuntimeError("Missing part")))
+ info = component_info(NS(Prototype=None, Tag=1))
+ assert info["part_path"] is None and info["load_state"] == "unloaded"
+ assert info["load_diagnostic"] == "Missing part"
+
+
+def test_close_referenced_prototype_rejected_before_save(rig, tmp_path):
+ parent = rig.part
+ child_part = Part(rig.session, tmp_path / "child.prt")
+ root = Component("root")
+ child = Component("child", parent=root)
+ child.Prototype = child_part
+ root.children = [child]
+ parent.ComponentAssembly.RootComponent = root
+ child_part.Save = Mock()
+ with pytest.raises(NXToolError) as error:
+ rig.e._close_part()
+ assert error.value.code == "NX_PART_IN_USE"
+ assert error.value.details["parent_assemblies"] == [parent.FullPath]
+ child_part.Save.assert_not_called()
+ assert child_part in rig.session.Parts
+
+
+def test_component_loading_restores_options_on_failure(rig):
+ c = NS(Tag=2, IsSuppressed=False)
+ rig.e._walk_components = lambda _: [(c, ["missing"])]
+ options = NS(UsePartialLoading=True)
+ rig.session.Parts.LoadOptions = options
+ status = NS(
+ NumberUnloadedParts=1,
+ GetPartName=lambda _: "missing.prt",
+ GetStatus=lambda _: 123,
+ Dispose=Mock(),
+ )
+ rig.part.ComponentAssembly.OpenOption = NS(ComponentOnly=1)
+ rig.part.ComponentAssembly.OpenComponents = Mock(return_value=(status, []))
+ with pytest.raises(NXToolError) as error:
+ load_components(rig.e, rig.part)
+ assert options.UsePartialLoading
+ status.Dispose.assert_called_once()
+ assert error.value.code == "NX_COMPONENT_LOAD_FAILED"
+ assert error.value.details["mutation_outcome"] == "partial"
+
+
+def test_construction_hiding_preserves_sheet_owned_section_curves(rig):
+ model, section = Object("model"), Object("section line")
+ rig.part.Curves = [model, section]
+ rig.e._datum_objects = lambda: []
+ rig.uf.View = NS(AskViewDependentStatus=lambda tag: (int(tag == section.Tag), "Sheet"))
+ view = NS(DependentDisplay=NS(Erase=Mock()), SetAttribute=Mock())
+ assert rig.e._drawing_construction_visibility(view, False) == 1
+ view.DependentDisplay.Erase.assert_called_once_with([model])
+
+
+def test_invisible_drawing_font_is_discoverable():
+ from nx_mcp.documentation_editing_server import ViewStyle
+
+ style = ViewStyle(hidden_lines=True, self_hidden=True, hidden_font=0)
+ assert style.hidden_font == 0
+ with pytest.raises(ValueError):
+ ViewStyle(hidden_font=-1)
diff --git a/tests/test_authoring_contracts.py b/tests/test_authoring_contracts.py
new file mode 100644
index 0000000..9ab3edd
--- /dev/null
+++ b/tests/test_authoring_contracts.py
@@ -0,0 +1,328 @@
+"""Builder arguments, coordinate frames and transaction contracts, not kernel tests."""
+
+import math
+from pathlib import Path
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.hardened import IDENTITY, matmul, rows
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Component, Curve, Feature, Sketch, point
+
+pytestmark = pytest.mark.fake_nx
+
+
+def setup_sketch_builder(rig, mismatch=False, fail=False):
+ part = rig.part
+ built = []
+ part.CoordinateSystems = NS(CreateCoordinateSystem=lambda p, m, _: NS(Origin=p, Orientation=m))
+
+ def builder(_):
+ b = NS(Destroy=Mock())
+
+ def commit():
+ if fail:
+ raise RuntimeError("builder failed")
+ sk = Sketch(rig.session)
+ sk.Origin = b.Csystem.Origin
+ sk.Orientation.Element = b.Csystem.Orientation
+ if mismatch:
+ sk.Origin = point(99, 99, 99)
+ part.Sketches.append(sk)
+ return sk
+
+ b.Commit = commit
+ built.append(b)
+ return b
+
+ part.Sketches.CreateSketchInPlaceBuilder2 = builder
+
+ def line(start, end):
+ c = Curve()
+ c.StartPoint = start
+ c.EndPoint = end
+ part.Curves.append(c)
+ return c
+
+ def arc(center, x, y, r, start, end):
+ c = Curve()
+ c.CenterPoint = center
+ c.Radius = r
+ part.Curves.append(c)
+ return c
+
+ part.Curves.CreateLine = line
+ part.Curves.CreateArc = arc
+ return built
+
+
+@pytest.mark.parametrize(
+ "plane,normal,end",
+ [("XY", [0, 0, 1], [3, 4, 0]), ("XZ", [0, -1, 0], [3, 0, 4]), ("YZ", [1, 0, 0], [0, 3, 4])],
+)
+def test_principal_sketch_curve_mapping(rig, plane, normal, end):
+ built = setup_sketch_builder(rig)
+ result = rig.e.execute("nx_create_sketch", {"plane": plane, "name": "profile"})
+ assert result["frame"]["normal"] == normal
+ sk = rig.session.ActiveSketch
+ rig.e._create_sketch_line(sk, rig.part, {"x": 0, "y": 0}, {"x": 3, "y": 4})
+ rig.e._sketch_arc_legacy(1, 2, 3, 0, 360, result["object"]["id"])
+ info = rig.e._sketch_info(result["object"]["id"])
+ assert info["curves"][0]["end"] == end and info["curve_count"] == 2
+ assert info["curves"][1]["radius"] == 3
+ assert built[0].Destroy.call_count == 1
+
+
+def test_arbitrary_basis_and_failed_frame_verification_roll_back(rig):
+ setup_sketch_builder(rig)
+ result = rig.e._create_sketch(origin=[1, 2, 3], x_axis=[0, 1, 0], y_axis=[-1, 0, 0])
+ sk = rig.session.ActiveSketch
+ p = rig.e._point_on_sketch(sk, {"x": 2, "y": 5})
+ assert [p.X, p.Y, p.Z] == [-4, 4, 3]
+ assert result["frame"]["normal"] == [0, 0, 1]
+ before = len(rig.part.Sketches)
+ built = setup_sketch_builder(rig, mismatch=True)
+ with pytest.raises(NXToolError) as error:
+ rig.e.execute("nx_create_sketch", {})
+ assert error.value.code == "NX_FRAME_MISMATCH" and len(rig.part.Sketches) == before
+ assert built[0].Destroy.call_count == 1
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"plane": "bad"},
+ {"x_axis": [1, 0, 0]},
+ {"x_axis": [2, 0, 0], "y_axis": [0, 1, 0]},
+ {"x_axis": [1, 0, 0], "y_axis": [1, 0, 0]},
+ {"origin": [0, float("inf"), 0]},
+ ],
+)
+def test_sketch_basis_preflight(rig, params):
+ built = setup_sketch_builder(rig)
+ with pytest.raises(NXToolError):
+ rig.e._create_sketch(**params)
+ assert not built
+
+
+def test_curve_owner_and_arc_limits(rig):
+ setup_sketch_builder(rig)
+ sk = Sketch(rig.session)
+ with pytest.raises(NXToolError):
+ rig.e._create_sketch_line(sk, rig.part, {"x": 0, "y": 0}, {"x": 1, "y": 1})
+ with pytest.raises(NXToolError):
+ rig.e._sketch_arc_legacy(0, 0, 1, 0, 90)
+ rig.session.ActiveSketch = sk
+ for radius, end in [(0, 90), (1, 0), (1, 361), (math.inf, 90)]:
+ with pytest.raises(NXToolError):
+ rig.e._sketch_arc_legacy(0, 0, radius, 0, end)
+ with pytest.raises(NXToolError):
+ rig.e._extrude("unresolved", float("nan"))
+
+
+def feature_builders(rig):
+ f = Feature()
+ rig.part.Features.append(f)
+
+ def expr():
+ return NS(RightHandSide="")
+
+ spacing = NS(NCopies=expr(), PitchDistance=expr())
+ b = NS(
+ Limits=NS(EndExtend=NS(Value=expr())),
+ PatternService=NS(RectangularDefinition=NS(XSpacing=spacing, YSpacing=NS(NCopies=expr()))),
+ FeatureList=NS(Add=Mock()),
+ CommitFeature=Mock(return_value=f),
+ Destroy=Mock(),
+ )
+ rig.part.Features.CreateExtrudeBuilder = lambda _: b
+ rig.part.Features.CreatePatternFeatureBuilder = lambda _: b
+ rig.nx.Features.PatternFeatureBuilder = NS(PatternMethodOptions=NS(Simple="simple"))
+ rig.nx.Features.Feature.Null = None
+ rig.nx.GeometricUtilities = NS(PatternDefinition=NS(PatternEnum=NS(Linear="linear")))
+ import sys
+
+ sys.modules["NXOpen.GeometricUtilities"] = rig.nx.GeometricUtilities
+ rig.nx.SmartObject = NS(UpdateOption=NS(WithinModeling="model"))
+ rig.part.Directions = NS(CreateDirection=lambda p, v, _: v)
+ rig.e._active_mark = 1
+ return f, b
+
+
+def test_supported_feature_edits_and_native_pattern_parameters(rig):
+ f, b = feature_builders(rig)
+ ref = rig.ref(f, "feature")
+ info = rig.e._edit_feature(ref, {"distance": 46.25})
+ assert b.Limits.EndExtend.Value.RightHandSide == "46.25" and info["modified"] == [
+ info["feature"]
+ ]
+ f.FeatureType = "PATTERN_FEATURE"
+ rig.e._edit_feature(ref, {"count": 16, "spacing": 16.5})
+ assert b.PatternService.RectangularDefinition.XSpacing.NCopies.RightHandSide == "16"
+ result = rig.e._pattern([ref], direction="-Y", count=16, spacing=16.5)
+ assert result["count_includes_seed"] and result["count"] == 16
+ assert b.PatternService.RectangularDefinition.XDirection.Y == -1
+ assert b.Destroy.call_count == 3
+ rig.session.UpdateManager.DoUpdate.return_value = 1
+ with pytest.raises(NXToolError, match="update errors"):
+ rig.e._edit_feature(ref, {"count": 3})
+
+
+@pytest.mark.parametrize("params", [{}, {"bad": 1}, {"distance": 0}, {"distance": float("inf")}])
+def test_unsupported_feature_edits_leave_builder_uncommitted(rig, params):
+ f, b = feature_builders(rig)
+ with pytest.raises(NXToolError):
+ rig.e._edit_feature(rig.ref(f, "feature"), params)
+ b.CommitFeature.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "kwargs",
+ [
+ {"pattern_type": "circular"},
+ {"count": True},
+ {"count": 1},
+ {"spacing": 0},
+ {"direction": "north"},
+ ],
+)
+def test_pattern_validation_before_builder(rig, kwargs):
+ _, b = feature_builders(rig)
+ with pytest.raises(NXToolError):
+ rig.e._pattern([], **kwargs)
+ b.CommitFeature.assert_not_called()
+
+
+def test_transform_readback_and_relative_composition(rig, tmp_path):
+ root = Component("root")
+ c = Component("instance", parent=root)
+ rig.part.ComponentAssembly.RootComponent = root
+
+ def move(obj, delta, rotation):
+ obj.position = point(
+ obj.position.X + delta.X, obj.position.Y + delta.Y, obj.position.Z + delta.Z
+ )
+ obj.rotation = rig.e._nx_matrix(matmul(rows(rotation), rows(obj.rotation)))
+
+ rig.part.ComponentAssembly.MoveComponent = move
+ ref = rig.ref(c, "component")
+ r = [[0, -1, 0], [1, 0, 0], [0, 0, 1]]
+ result = rig.e._set_component_transform(ref, [1, 2, 3], r)
+ assert result["translation"] == [1, 2, 3] and result["rotation_matrix"] == r
+ result = rig.e._reposition_component(ref, dx=4, rz=90)
+ assert result["translation"] == [5, 2, 3] and abs(result["rotation_matrix"][0][0] + 1) < 1e-8
+ rig.part.ComponentAssembly.MoveComponent = Mock()
+ with pytest.raises(NXToolError) as error:
+ rig.e._set_component_transform(ref, [9, 9, 9], IDENTITY)
+ assert error.value.code == "NX_PLACEMENT_MISMATCH"
+ c.Parent = Component("other")
+ with pytest.raises(NXToolError):
+ rig.e._set_component_transform(ref, [0, 0, 0], IDENTITY)
+ c.Parent = root
+ rig.part.ComponentAssembly.AddComponent = lambda *args: (c, NS(Dispose=Mock()))
+ assert rig.e._add_component(str(tmp_path / "proto.prt"), translation=[1, 2, 3])[
+ "translation"
+ ] == [1, 2, 3]
+ assert rig.e._list_components()["count"] == 1
+ new = rig.e._rename_object(ref, "renamed")["object"]["id"]
+ assert new != ref and c.Name == "renamed"
+ with pytest.raises(NXToolError):
+ rig.e._rename_object(new, "")
+
+
+@pytest.mark.parametrize("rotation", [[], [[1, 0, 0]] * 3, [[1, 0, 0], [0, 1, 0], [0, 0, -1]]])
+def test_rotation_rejects_scale_shear_or_reflection(rig, rotation):
+ with pytest.raises(NXToolError):
+ rig.e._validate_rotation(rotation)
+
+
+def test_batch_preflight_cancellation_and_progress(rig):
+ rig.e._current_operation = "batch-operation"
+ rig.e.store.put({"operation_id": "batch-operation", "state": "running"})
+ calls = []
+ rig.e._handlers["nx_sketch_line"] = lambda start, end: calls.append((start, end)) or {}
+ ops = [{"method": "nx_sketch_line", "params": {"start": 1, "end": 2}}] * 2
+ assert rig.e._batch(ops)["count"] == 2 and len(calls) == 2
+ assert rig.e.store.get("batch-operation")["progress"] == {"completed": 2, "total": 2}
+ for bad in [
+ [],
+ [{"method": "nx_bad", "params": {}}],
+ [{"method": "nx_sketch_line", "params": {}}],
+ ]:
+ with pytest.raises((NXToolError, TypeError)):
+ rig.e._batch(bad)
+ assert len(calls) == 2
+ rig.e.store.path("batch-operation").with_suffix(".cancel").touch()
+ with pytest.raises(NXToolError) as error:
+ rig.e._batch(ops)
+ assert error.value.code == "NX_CANCELLED" and len(calls) == 2
+
+
+def test_step_import_validates_conflicts_and_reports_no_output(rig, tmp_path):
+ source = tmp_path / "vendor.step"
+ source.write_text(
+ "ISO-10303-21; PRODUCT('test','test'); NEXT_ASSEMBLY_USAGE_OCCURRENCE END-ISO-10303-21;"
+ )
+ with pytest.raises(NXToolError) as error:
+ rig.e._import_geometry(str(source))
+ assert error.value.code == "NX_IMPORT_NAME_CONFLICT"
+ rig.nx.Step214Importer = NS(ImportToOption=NS(WorkPart="work"))
+ builder = NS(ObjectTypes=NS(), Commit=Mock(), Destroy=Mock())
+ rig.session.DexManager = NS(CreateStep214Importer=lambda: builder)
+ rig.e._current_operation = "import-operation"
+ with pytest.raises(NXToolError, match="without imported"):
+ rig.e._import_geometry(str(source), flatten=True)
+ assert (
+ builder.Destroy.call_count == 1
+ and Path(builder.InputFile).read_text() == source.read_text()
+ )
+ rig.e._current_operation = "import-operation-2"
+ builder.Commit = Mock(side_effect=lambda: rig.part.Bodies.append(Body("imported")))
+ result = rig.e._import_geometry(str(source), flatten=True)
+ assert result["body_count"] == 1 and result["translator"].endswith("WorkPart")
+ for path, kwargs in [
+ (str(tmp_path / "x.iges"), {}),
+ (str(tmp_path / "missing.step"), {}),
+ (str(source), {"target": "invalid"}),
+ (str(source), {"target": "new_part"}),
+ (str(source), {"output_path": "x.prt"}),
+ (str(source), {"target": "new_part", "output_path": str(source)}),
+ ]:
+ with pytest.raises(NXToolError):
+ rig.e._import_geometry(path, **kwargs)
+
+
+def test_save_as_creates_parents_and_rejects_existing_destination(rig, tmp_path):
+ destination = tmp_path / "project" / "revisions" / "base_r02.prt"
+
+ def save(path):
+ assert Path(path).parent.is_dir()
+ Path(path).write_bytes(b"saved fixture")
+ rig.part.FullPath = path
+ return NS(Dispose=Mock())
+
+ rig.part.SaveAs = Mock(side_effect=save)
+ result = rig.e._save_as(str(destination))
+ assert result["path"] == str(destination)
+ with pytest.raises(NXToolError) as error:
+ rig.e._save_as(str(destination))
+ assert error.value.code == "NX_FILE_EXISTS"
+ assert rig.part.SaveAs.call_count == 1
+ assert destination.read_bytes() == b"saved fixture"
+
+
+@pytest.mark.parametrize(
+ "contents",
+ ["ISO-10303-21; DATA; #1=BODY();", "garbage", "/* END-ISO-10303-21; */ ISO-10303-21;"],
+)
+def test_truncated_step_rejected_before_translator(rig, tmp_path, contents):
+ source = tmp_path / "broken.step"
+ source.write_text(contents)
+ translator = Mock()
+ rig.session.DexManager = NS(CreateStep214Importer=translator)
+ with pytest.raises(NXToolError) as error:
+ rig.e._import_geometry(str(source), flatten=True)
+ assert error.value.code == "NX_INVALID_STEP"
+ translator.assert_not_called()
diff --git a/tests/test_authoring_review.py b/tests/test_authoring_review.py
new file mode 100644
index 0000000..cb9f320
--- /dev/null
+++ b/tests/test_authoring_review.py
@@ -0,0 +1,863 @@
+"""Stateful contracts for authoring and review; native geometry is tested separately."""
+
+import json
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.authoring import finite, page
+from nx_mcp.hardened import IDENTITY, xyz
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Collection, Component, Curve, Feature, Object, Sketch, point
+
+pytestmark = pytest.mark.fake_nx
+
+
+class Expression(Object):
+ def __init__(self, name="height", formula="10", unit=None):
+ super().__init__(name)
+ self.RightHandSide = formula
+ self.Value = 10.0
+ self.Type = "Number"
+ self.Units = unit
+ self.IsNoEdit = self.IsRightHandSideLockedFromEdit = self.IsInterpartExpression = False
+ self.parents = []
+ self.dependents = []
+
+ def GetValueUsingUnits(self, option):
+ return getattr(self, "expression_value", self.Value)
+
+ def GetExpressionParents(self):
+ return self.parents
+
+ def GetReferencingExpressions(self):
+ return self.dependents
+
+
+@pytest.fixture
+def author(rig):
+ p, s = rig.part, rig.session
+ rig.nx.Expression = NS(UnitsOption=NS(Expression="expression"))
+ p.Expressions = Collection()
+ p.Expressions.CreateNumberExpression = Mock(side_effect=lambda spec, unit: create(spec, unit))
+
+ def create(spec, unit):
+ name, formula = spec.split("=", 1)
+ exp = Expression(name, formula, unit)
+ p.Expressions.append(exp)
+ return exp
+
+ def edit(exp, formula):
+ if formula == "invalid":
+ raise RuntimeError("native formula error")
+ exp.RightHandSide = formula
+
+ p.Expressions.EditExpression = Mock(side_effect=edit)
+ p.UnitCollection.FindObject = lambda n: NS(
+ Name=n,
+ Abbreviation={"MilliMeter": "mm", "Inch": "in", "Degrees": "deg", "Radian": "rad"}.get(
+ n, n
+ ),
+ )
+ rig.uf.Modeling = NS(
+ AskBodyConsistency=Mock(return_value=(0, [], [])),
+ AskFaceData=lambda tag: geom(tag),
+ AskMinimumDist3=Mock(return_value=(2.0, [0, 0, 10], [0, 0, 12], 0.0)),
+ )
+
+ def geom(tag):
+ face = next(f for b in p.Bodies for f in b.faces if f.Tag == tag)
+ return face.native_type, [0, 0, 0], face.normal, face.box, face.radius, 0, 1
+
+ rig.uf.Curve = NS(AskArcData=lambda tag: NS(Radius=5))
+ b = Body()
+ p.Bodies.append(b)
+ for f in b.faces:
+ f.box = [0, 0, 10, 10, 10, 10]
+ f.native_type = 22
+ f.normal = [0, 0, 1]
+ f.radius = 5
+ for edge in b.edges:
+ edge.box = [0, 0, 0, 10, 10, 0]
+ edge.SolidEdgeType = 1
+ rig.nx.Edge.EdgeType = NS(Circular=1, Linear=2)
+ f = Feature(bodies=[b])
+ f.Suppressed = False
+ f.GetFeatureErrorMessages = lambda: []
+ f.GetFeatureWarningMessages = lambda: []
+ p.Features.append(f)
+ exp = Expression()
+ p.Expressions.append(exp)
+ f.expressions = [exp]
+ target = Expression("p_limit")
+ p.Expressions.append(target)
+ builder = NS(
+ Limits=NS(StartExtend=NS(Value=target), EndExtend=NS(Value=target)),
+ CommitFeature=Mock(),
+ Destroy=Mock(),
+ )
+ p.Features.CreateExtrudeBuilder = lambda _: builder
+ view = p.ModelingViews.WorkView
+
+ def camera(m, o, scale):
+ view.Matrix = m
+ view.Origin = o
+ view.Scale = scale
+
+ view.SetRotationTranslationScale = Mock(side_effect=camera)
+ rig.ref(b)
+ rig.ref(f, "feature")
+ rig.ref(exp, "expression")
+ for face in b.faces:
+ rig.ref(face, "face")
+ for edge in b.edges:
+ rig.ref(edge, "edge")
+ # Save/restore expression and sketch state in addition to existing model seam state.
+ original_mark, original_undo = s.SetUndoMark, s.UndoToMark
+ extra = {}
+
+ def mark(*args):
+ m = original_mark(*args)
+ extra[m] = (
+ [(x, x.RightHandSide, x.Value) for x in p.Expressions],
+ [(sk, list(sk.geometry), list(sk.constraints)) for sk in p.Sketches],
+ )
+ return m
+
+ def undo(m, *args):
+ original_undo(m, *args)
+ expressions, sketches = extra[m]
+ p.Expressions[:] = [x for x, _, _ in expressions]
+ for x, formula, value in expressions:
+ x.RightHandSide = formula
+ x.Value = value
+ for sk, geometry, constraints in sketches:
+ sk.geometry[:] = geometry
+ sk.constraints[:] = constraints
+
+ s.SetUndoMark = mark
+ s.UndoToMark = undo
+ rig.body, rig.feature, rig.expression, rig.builder = b, f, exp, builder
+ return rig
+
+
+@pytest.mark.parametrize("value", [None, True, float("nan"), float("inf"), "1", -1, 0])
+def test_positive_number_validation(value):
+ with pytest.raises(NXToolError):
+ finite(value, "value", True)
+
+
+@pytest.mark.parametrize("offset,limit", [(-1, 1), (0, 0), (0, 201), (True, 1), (0, False)])
+def test_page_rejects_unbounded_requests(offset, limit):
+ with pytest.raises(NXToolError):
+ page([1], offset, limit)
+
+
+def test_pagination_is_stable_and_complete():
+ assert page([1, 2, 3], 0, 2) == {"items": [1, 2], "total": 3, "offset": 0, "next_offset": 2}
+ assert page([1, 2, 3], 2, 2)["next_offset"] is None
+
+
+def test_expressions_creation_binding_and_dependencies(author):
+ r = author
+ e = r.e
+ row = e.execute(
+ "nx_set_expression", {"expression": "width", "formula": "5", "create": True, "units": "mm"}
+ )["expression"]
+ assert row["units"] == "mm" and row["formula"] == "5"
+ exp = r.part.Expressions[-1]
+ exp.parents = [r.expression]
+ r.expression.dependents = [exp]
+ assert e._list_expressions("width")["items"][0]["parents"][0]["name"] == "height"
+ e.execute("nx_set_expression", {"expression": row["object"]["id"], "formula": "height*2"})
+ assert exp.RightHandSide == "height*2"
+ f = r.ref(r.feature, "feature")
+ e.execute("nx_bind_parameter", {"feature": f, "parameter": "end", "expression": "width"})
+ assert r.builder.Limits.EndExtend.Value.RightHandSide == "width"
+ r.builder.CommitFeature.assert_called_once()
+ r.builder.Destroy.assert_called_once()
+ assert e._expression_record(r.expression)["dependents"]
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"expression": "height", "formula": "5", "create": True},
+ {"expression": "a b", "formula": "5", "create": True},
+ {"expression": "a", "formula": "", "create": True},
+ {"expression": "a", "formula": "5", "create": True, "units": "bad"},
+ {"expression": "height", "formula": "5", "units": "mm"},
+ {"expression": "absent", "formula": "5"},
+ ],
+)
+def test_expression_rejections_do_not_modify(author, params):
+ before = [(e.Name, e.RightHandSide) for e in author.part.Expressions]
+ with pytest.raises(NXToolError):
+ author.e.execute("nx_set_expression", params)
+ assert [(e.Name, e.RightHandSide) for e in author.part.Expressions] == before
+
+
+@pytest.mark.parametrize(
+ "flag", ["IsNoEdit", "IsRightHandSideLockedFromEdit", "IsInterpartExpression"]
+)
+def test_locked_expression_is_never_changed(author, flag):
+ setattr(author.expression, flag, True)
+ with pytest.raises(NXToolError):
+ author.e.execute("nx_set_expression", {"expression": "height", "formula": "20"})
+ assert author.expression.RightHandSide == "10"
+
+
+def test_failed_native_update_restores_expression(author):
+ author.session.UpdateManager.DoUpdate.return_value = 1
+ with pytest.raises(NXToolError) as error:
+ author.e.execute("nx_set_expression", {"expression": "height", "formula": "25"})
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ assert author.expression.RightHandSide == "10"
+
+
+@pytest.mark.parametrize(
+ "kind,parameter,type_",
+ [("OTHER", "end", "Number"), ("EXTRUDE", "spacing", "Number"), ("EXTRUDE", "end", "String")],
+)
+def test_parameter_binding_rejects_unsupported_targets(author, kind, parameter, type_):
+ author.feature.FeatureType = kind
+ author.expression.Type = type_
+ with pytest.raises(NXToolError):
+ author.e._bind_parameter(author.ref(author.feature, "feature"), parameter, "height")
+ author.builder.CommitFeature.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"kind": "curve"},
+ {"geometry_type": "bad"},
+ {"kind": "face", "geometry_type": "circle"},
+ {"kind": "edge", "geometry_type": "plane"},
+ {"normal": [0, 0, 1]},
+ {"radius": 1},
+ {"order": "bad"},
+ {"axis": "A"},
+ {"tolerance": 0},
+ {"order": "nearest"},
+ {"near": [0, 1]},
+ {"normal": [0, 0, 0], "geometry_type": "plane"},
+ ],
+)
+def test_selection_rejects_ambiguous_or_ignored_options(author, params):
+ with pytest.raises(NXToolError):
+ author.e._find_geometry(**params)
+
+
+def test_selection_reports_plane_orientation_radius_and_rank(author):
+ e = author.e
+ r = e._find_geometry(geometry_type="plane", normal=[0, 0, 1], order="highest")
+ assert r["total"] == 1 and r["items"][0]["bounds_center"] == [5, 5, 10]
+ assert e._find_geometry(geometry_type="plane", normal=[0, 0, -1], order="lowest")["total"] == 0
+ edge = e._find_geometry(kind="edge", geometry_type="circle", radius=5, near=[0, 0, 0])["items"][
+ 0
+ ]
+ assert edge["radius"] == 5 and edge["distance_to_bounds_center"] > 0
+ assert (
+ e._find_geometry(kind="edge", geometry_type="circle", radius=6, near=[0, 0, 0])["total"]
+ == 0
+ )
+ author.body.faces[0].native_type = 16
+ assert e._find_geometry(geometry_type="cylinder", radius=5, order="lowest")["total"] == 1
+ author.body.edges[0].SolidEdgeType = 2
+ assert e._find_geometry(kind="edge", geometry_type="line", order="highest")["total"] == 1
+
+
+def test_query_highlight_failure_clears_partial_selection(author):
+ r = author
+ e = r.e
+ e._highlight_objects([r.ref(r.body)])
+ assert r.body.highlighted
+ r.body.faces[0].Highlight = Mock(side_effect=RuntimeError("highlight failed"))
+ with pytest.raises(RuntimeError):
+ e._highlight_objects([r.ref(r.body), r.ref(r.body.faces[0], "face")])
+ assert not r.body.highlighted and not e._highlighted_objects
+
+
+def test_health_reports_faults_suppression_and_paginates(author):
+ r = author
+ e = r.e
+ assert e._model_health()["healthy"]
+ r.feature.GetFeatureErrorMessages = lambda: ["broken reference"]
+ r.feature.GetFeatureWarningMessages = lambda: ["out of date"]
+ r.feature.Suppressed = True
+ r.body.IsSolidBody = False
+ r.uf.Modeling.AskBodyConsistency.return_value = (1, [100], [r.body.faces[0].Tag])
+ report = e._model_health(limit=1)
+ assert (
+ report["error_count"] == 2 and report["warning_count"] == 1 and report["next_offset"] == 1
+ )
+ assert not report["healthy"] and report["bodies_checked"] == 1
+ assert e._model_health(offset=99)["items"] == []
+
+
+def test_health_assembly_unique_prototypes_and_unloaded(author):
+ r = author
+ root = Component("root")
+ r.part.ComponentAssembly.RootComponent = root
+ a = Component("a", parent=root)
+ a.Prototype = r.part
+ b = Component("b", parent=root)
+ b.Prototype = r.part
+ b.IsSuppressed = True
+ Component("unloaded", parent=root).Prototype = None
+ report = r.e._model_health("assembly")
+ assert report["parts_checked"] == 1 and report["warning_count"] == 1
+ assert {i["kind"] for i in report["items"]} == {"suppressed_component", "unloaded_component"}
+ with pytest.raises(NXToolError):
+ r.e._model_health("bad")
+
+
+def test_health_missing_api_is_explicit(author):
+ del author.uf.Modeling.AskBodyConsistency
+ with pytest.raises(NXToolError, match="AskBodyConsistency"):
+ author.e._model_health()
+
+
+def test_model_rebuild_propagates_failure(author):
+ assert author.e.execute("nx_rebuild_model", {})["health"]["healthy"]
+ author.session.UpdateManager.DoUpdate.return_value = 2
+ with pytest.raises(NXToolError):
+ author.e.execute("nx_rebuild_model", {})
+
+
+class Line(Curve):
+ def __init__(self):
+ super().__init__("line")
+ self.StartPoint = point()
+ self.EndPoint = point(10, 0, 0)
+
+ def SetEndpoints(self, a, b):
+ self.StartPoint, self.EndPoint = a, b
+
+
+@pytest.fixture
+def sketch(author):
+ r = author
+ s = Sketch(r.session)
+ r.part.Sketches.append(s)
+ line = Line()
+ s.geometry = [line]
+ r.part.Curves.append(line)
+ r.nx.Sketch.ConstraintGeometry = lambda: NS()
+ r.nx.Sketch.ConstraintPointType = NS()
+
+ def constraint(g):
+ c = Object("constraint")
+ c.ConstraintType = 0
+ s.constraints.append(c)
+ return c
+
+ s.CreateFixedConstraint = s.CreateHorizontalConstraint = s.CreateVerticalConstraint = constraint
+
+ def delete(values):
+ for v in values:
+ if v in s.geometry:
+ s.geometry.remove(v)
+ r.part.Curves.remove(v)
+ if v in s.constraints:
+ s.constraints.remove(v)
+ return NS(Length=0, Dispose=Mock())
+
+ s.DeleteObjects = delete
+ r.part.Sketches.CreateWorkRegionBuilder = lambda: NS(Scope=None, Commit=Mock(), Destroy=Mock())
+ r.part.Curves.CreateLine = lambda a, b: create_line(a, b)
+
+ def create_line(a, b):
+ v = Line()
+ v.SetEndpoints(a, b)
+ r.part.Curves.append(v)
+ return v
+
+ r.sketch = s
+ r.line = line
+ r.sid = r.ref(s, "sketch")
+ r.cid = r.ref(line, "curve")
+ return r
+
+
+def test_sketch_edits_constraints_and_deletes_owned_objects(sketch):
+ r = sketch
+ e = r.e
+ result = e.execute(
+ "nx_edit_sketch",
+ {
+ "sketch_id": r.sid,
+ "operations": [
+ {"action": "line", "curve": r.cid, "start": [1, 2], "end": [8, 2]},
+ {"action": "constraint", "curve": r.cid, "type": "horizontal"},
+ {"action": "add_line", "start": [1, 2], "end": [1, 8]},
+ ],
+ },
+ )
+ assert result["edit_count"] == 3 and r.session.ActiveSketch is None
+ assert xyz(r.line.EndPoint) == [8, 2, 0] and len(r.sketch.constraints) == 1
+ constraint = r.ref(r.sketch.constraints[0], "constraint")
+ result = e.execute(
+ "nx_edit_sketch",
+ {
+ "sketch_id": r.sid,
+ "operations": [
+ {"action": "delete", "object": constraint},
+ {"action": "delete", "object": r.cid},
+ ],
+ },
+ )
+ assert len(r.sketch.geometry) == 1 and not r.sketch.constraints
+
+
+@pytest.mark.parametrize(
+ "op",
+ [
+ {"action": "unknown"},
+ {"action": "add_line", "start": [0, 0], "end": [0, 0]},
+ {"action": "add_line", "start": [0], "end": [1, 1]},
+ {"action": "add_line", "start": [0, 0], "end": [1, 1], "extra": True},
+ ],
+)
+def test_sketch_preflight_rejections_preserve_activation(sketch, op):
+ with pytest.raises(NXToolError):
+ sketch.e.execute("nx_edit_sketch", {"sketch_id": sketch.sid, "operations": [op]})
+ assert sketch.session.ActiveSketch is None and sketch.sketch.geometry == [sketch.line]
+
+
+def test_sketch_wrong_owner_and_active_sketch_rejected(sketch):
+ r = sketch
+ foreign = r.ref(Line(), "curve")
+ with pytest.raises(NXToolError):
+ r.e._edit_sketch(r.sid, [{"action": "delete", "object": foreign}])
+ r.session.ActiveSketch = Sketch(r.session)
+ with pytest.raises(NXToolError):
+ r.e._edit_sketch(r.sid, [{"action": "delete", "object": r.cid}])
+
+
+def test_sketch_edit_keeps_previously_active_sketch(sketch):
+ r = sketch
+ r.session.ActiveSketch = r.sketch
+ r.e.execute(
+ "nx_edit_sketch",
+ {
+ "sketch_id": r.sid,
+ "operations": [{"action": "constraint", "curve": r.cid, "type": "fixed"}],
+ },
+ )
+ assert r.session.ActiveSketch is r.sketch
+
+
+def test_sketch_arc_edit_units_and_limits(sketch):
+ r = sketch
+ r.line.SetParameters = Mock()
+ op = {
+ "action": "arc",
+ "curve": r.cid,
+ "center": [2, 3],
+ "radius": 4,
+ "start_angle": 0,
+ "end_angle": 180,
+ }
+ r.e.execute("nx_edit_sketch", {"sketch_id": r.sid, "operations": [op]})
+ args = r.line.SetParameters.call_args.args
+ assert (
+ args[0] == 4 and xyz(args[1]) == [2, 3, 0] and args[3] == pytest.approx(3.141592653589793)
+ )
+ with pytest.raises(NXToolError):
+ r.e._edit_sketch(r.sid, [{**op, "end_angle": 400}])
+
+
+@pytest.fixture
+def assembly(author):
+ r = author
+ root = Component("root")
+ a = Component("seed", parent=root)
+ r.part.ComponentAssembly.RootComponent = root
+ a.Prototype.FullPath = str(r.e.workspace.root / "prototype.prt")
+ Path(a.Prototype.FullPath).write_bytes(b"part")
+ r.part.ComponentAssembly.SuppressComponents = lambda cs: suppress(cs, True)
+ r.part.ComponentAssembly.UnsuppressComponents = lambda cs: suppress(cs, False)
+
+ def suppress(cs, value):
+ for c in cs:
+ c.IsSuppressed = value
+ return NS(Length=0, Dispose=Mock())
+
+ r.session.UpdateManager.AddToDeleteList = lambda c: root.children.remove(c)
+
+ def add(path, refset, name, pos, matrix, layer):
+ c = Component(name, parent=root)
+ c.position = pos
+ c.rotation = matrix
+ c.Prototype.FullPath = path
+ return c, NS(Dispose=Mock())
+
+ r.part.ComponentAssembly.AddComponent = add
+ builder = NS(
+ ComponentsToReplace=NS(Add=Mock()),
+ ReplaceAllOccurrences=False,
+ MaintainRelationships=True,
+ ReplacementPart=None,
+ Commit=Mock(),
+ Destroy=Mock(),
+ GetErrorList=lambda: NS(Length=0, Dispose=Mock()),
+ )
+ r.part.AssemblyManager = NS(CreateReplaceComponentBuilder=lambda: builder)
+ r.component = a
+ r.component_id = r.ref(a, "component")
+ r.replace_builder = builder
+ return r
+
+
+def test_component_maintenance_preserves_pose_and_other_occurrences(assembly):
+ r = assembly
+ e = r.e
+ for action, args in [
+ ("rename", {"name": "changed"}),
+ ("suppress", {}),
+ ("unsuppress", {}),
+ ("replace", {"part_path": r.component.Prototype.FullPath}),
+ ]:
+ result = e.execute(
+ "nx_component_action", {"component": r.component_id, "action": action, **args}
+ )
+ assert result["placement_preserved"]
+ assert r.component.Name == "changed" and not r.component.IsSuppressed
+ r.replace_builder.Destroy.assert_called_once()
+ e.execute("nx_component_action", {"component": r.component_id, "action": "remove"})
+ assert not r.part.ComponentAssembly.RootComponent.children
+ assert Path(r.component.Prototype.FullPath).exists()
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"action": "bad"},
+ {"action": "rename"},
+ {"action": "rename", "name": ""},
+ {"action": "suppress", "name": "ignored"},
+ {"action": "replace", "part_path": "missing.prt"},
+ ],
+)
+def test_component_action_invalid_args_do_not_mutate(assembly, params):
+ with pytest.raises(NXToolError):
+ assembly.e.execute("nx_component_action", {"component": assembly.component_id, **params})
+ assert assembly.component.Name == "seed"
+
+
+def test_component_nested_edits_rejected(assembly):
+ r = assembly
+ r.component.Parent = Component("nested")
+ with pytest.raises(NXToolError):
+ r.e._component_action(r.component_id, "remove")
+ with pytest.raises(NXToolError):
+ r.e._pattern_components(r.component_id, [1, 0, 0], 10, 4)
+
+
+def test_component_pattern_count_pitch_and_placement(assembly):
+ r = assembly
+ result = r.e.execute(
+ "nx_pattern_components",
+ {"component": r.component_id, "direction": [2, 0, 0], "spacing": 16.5, "count": 16},
+ )
+ assert result["total_instances"] == 16 and not result["associative"]
+ positions = [xyz(c.position) for c in r.part.ComponentAssembly.RootComponent.children]
+ assert len(positions) == 16 and positions[-1] == [247.5, 0, 0]
+ for count in [1, 101, True]:
+ with pytest.raises(NXToolError):
+ r.e._pattern_components(r.component_id, [1, 0, 0], 1, count)
+
+
+def test_native_error_lists_are_disposed_and_reported(author):
+ errors = NS(Length=1, GetErrorInfo=lambda i: "conflicting mate", Dispose=Mock())
+ with pytest.raises(NXToolError, match="conflicting mate"):
+ author.e._error_list(errors)
+ errors.Dispose.assert_called_once()
+ author.e._error_list(None)
+
+
+def sections_stub(r):
+ r.e._list_sections = lambda: {"sections": [], "view_sectioning_enabled": False}
+
+
+def test_saved_presentation_restores_camera_and_face_colors(author):
+ r = author
+ e = r.e
+ sections_stub(r)
+ path = str(r.e.workspace.root / "view.json")
+ r.body.faces[0].Color = 33
+ e.execute(
+ "nx_set_camera",
+ {"rotation": [[0, -1, 0], [1, 0, 0], [0, 0, 1]], "origin": [2, 3, 4], "scale": 2},
+ )
+ e.execute("nx_save_presentation", {"path": path})
+ r.body.faces[0].Color = 5
+ r.body.IsBlanked = True
+ e.execute("nx_set_camera", {"rotation": IDENTITY, "origin": [0, 0, 0], "scale": 1})
+ e.execute("nx_restore_presentation", {"path": path})
+ assert r.body.faces[0].Color == 33 and not r.body.IsBlanked
+ assert e._view_info()["origin"] == [2, 3, 4] and e._view_info()["scale"] == 2
+ with pytest.raises(NXToolError):
+ e._save_presentation(path)
+
+
+@pytest.mark.parametrize("fault", ["owner", "format", "missing_face", "bad_camera"])
+def test_presentation_preflight_leaves_display_unchanged(author, fault):
+ r = author
+ e = r.e
+ sections_stub(r)
+ p = r.e.workspace.root / "view.json"
+ e._save_presentation(str(p))
+ d = json.loads(p.read_text())
+ if fault == "owner":
+ d["part_path"] = "different.prt"
+ if fault == "format":
+ d["version"] = 999
+ if fault == "missing_face":
+ d["display"][-1]["locator"]["journal_id"] = "removed"
+ r.part.FindObject = Mock(side_effect=RuntimeError("not found"))
+ if fault == "bad_camera":
+ d["camera"]["scale"] = 0
+ p.write_text(json.dumps(d))
+ r.body.Color = 13
+ with pytest.raises(NXToolError):
+ e.execute("nx_restore_presentation", {"path": str(p)})
+ assert r.body.Color == 13
+
+
+def test_camera_validation_and_readback(author):
+ e = author.e
+ with pytest.raises(NXToolError):
+ e._set_camera([[1, 0, 0]] * 3, [0, 0, 0], 1)
+ with pytest.raises(NXToolError):
+ e._set_camera(IDENTITY, [1, 2], 1)
+ assert e._set_camera(IDENTITY, [1, 2, 3], 3)["origin"] == [1, 2, 3]
+
+
+def test_temporary_presentation_restores_on_exception(author):
+ e = author.e
+ before = e._view_info()
+ with pytest.raises(RuntimeError), e._temporary_view():
+ author.body.Blank()
+ e._set_camera(IDENTITY, [5, 5, 5], 2)
+ raise RuntimeError("capture")
+ assert not author.body.IsBlanked and e._view_info()["origin"] == before["origin"]
+
+
+def test_temporary_presentation_cleanup_failure_is_partial(author):
+ author.session.UndoToMark = Mock(side_effect=RuntimeError("undo"))
+ with pytest.raises(NXToolError) as err, author.e._temporary_view():
+ pass
+ assert err.value.details["mutation_outcome"] == "partial"
+
+
+@pytest.mark.parametrize(
+ "section", ["overview", "features", "components", "expressions", "sketches"]
+)
+def test_compact_summaries_have_counts_and_bounded_pages(author, section):
+ result = author.e._model_summary(section, limit=1)
+ if section == "overview":
+ assert result["counts"]["bodies"] == 1 and result["diagnostics"]["healthy"]
+ else:
+ assert len(result["items"]) <= 1
+
+
+def test_summary_empty_part_and_bad_section(author):
+ author.part.Bodies.clear()
+ assert author.e._model_summary()["bounds"] is None
+ with pytest.raises(NXToolError):
+ author.e._model_summary("bad")
+
+
+def test_inspection_report_packages_evidence_and_restores_view(author):
+ r = author
+ e = r.e
+ e._check_clearance = lambda *args: {
+ "pairs": [
+ {
+ "classification": "penetration",
+ "objects": [e._reference(r.body, "body", r.part, "Body")],
+ }
+ ],
+ "complete": True,
+ "counts": {"penetration": 1},
+ }
+
+ def capture(path=None, **kw):
+ p = Path(path)
+ p.write_bytes(b"fixture-image")
+ return {"path": str(p), "camera": e._view_info()}
+
+ e._capture_view = capture
+ e._section_view = lambda **kw: None
+ path = str(e.workspace.root / "report.zip")
+ result = e.execute(
+ "nx_inspection_report",
+ {"path": path, "section_planes": [{"origin": [0, 0, 5], "normal": [0, 0, 1]}]},
+ )
+ assert result["capture_count"] == 3 and not r.body.IsBlanked
+ assert result["artifact_path"] == "report.zip"
+ with zipfile.ZipFile(path) as z:
+ assert {
+ "index.html",
+ "report.json",
+ "manifest.json",
+ "overview.png",
+ "pair-1.png",
+ "section-1.png",
+ } == set(z.namelist())
+ assert json.loads(z.read("report.json"))["clearance"]["complete"]
+ with pytest.raises(NXToolError):
+ e._inspection_report(path)
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"path": "bad.txt"},
+ {"path": "a.zip", "section_planes": [{"normal": [0, 0, 1]}]},
+ {
+ "path": "a.zip",
+ "capture": False,
+ "section_planes": [{"normal": [0, 0, 1], "origin": [0, 0, 0]}],
+ },
+ {"path": "a.zip", "section_planes": [{}] * 7},
+ ],
+)
+def test_report_rejects_invalid_plans_before_output(author, params):
+ with pytest.raises(NXToolError):
+ author.e._inspection_report(
+ **{**params, "path": str(author.e.workspace.root / params["path"])}
+ )
+ assert not (author.e.workspace.root / "a.zip").exists()
+
+
+def test_preview_rolls_back_parameters_and_accepts_exact_plan(author):
+ r = author
+ e = r.e
+ ref = r.ref(r.expression, "expression")
+ r1 = e.execute(
+ "nx_preview_change",
+ {
+ "operations": [
+ {"method": "nx_set_expression", "params": {"expression": ref, "formula": "25"}}
+ ],
+ "capture": False,
+ },
+ )
+ assert r.expression.RightHandSide == "10" and r1["model_outcome"] == "rolled_back"
+ assert next(x for x in r1["after"]["parameters"] if x["name"] == "height")["formula"] == "25"
+ r2 = e.execute(
+ "nx_finish_preview",
+ {"preview_id": r1["preview_id"], "action": "accept", "operation_id": "accept-once"},
+ )
+ assert r.expression.RightHandSide == "25" and r2["geometry_changed"]
+ assert e.execute(
+ "nx_finish_preview",
+ {"preview_id": r1["preview_id"], "action": "accept", "operation_id": "accept-once"},
+ )["replayed"]
+
+
+def test_preview_failure_rolls_back_prior_edits(author):
+ r = author
+ e = r.e
+ with pytest.raises(NXToolError):
+ e.execute(
+ "nx_preview_change",
+ {
+ "operations": [
+ {
+ "method": "nx_set_expression",
+ "params": {"expression": "height", "formula": "20"},
+ },
+ {
+ "method": "nx_set_expression",
+ "params": {"expression": "height", "formula": "invalid"},
+ },
+ ],
+ "capture": False,
+ },
+ )
+ assert r.expression.RightHandSide == "10"
+
+
+def test_preview_expires_on_intervening_mutation(author):
+ e = author.e
+ r = e.execute(
+ "nx_preview_change",
+ {
+ "operations": [
+ {"method": "nx_set_expression", "params": {"expression": "height", "formula": "20"}}
+ ],
+ "capture": False,
+ },
+ )
+ e.execute("nx_set_expression", {"expression": "height", "formula": "30"})
+ with pytest.raises(NXToolError, match="changed"):
+ e.execute("nx_finish_preview", {"preview_id": r["preview_id"], "action": "accept"})
+ assert author.expression.RightHandSide == "30"
+ assert (
+ e.execute("nx_finish_preview", {"preview_id": r["preview_id"], "action": "discard"})[
+ "geometry_changed"
+ ]
+ is False
+ )
+
+
+@pytest.mark.parametrize(
+ "operations",
+ [[], [{"method": "nx_save_part", "params": {}}], [{"method": "nx_edit_feature", "params": {}}]],
+)
+def test_preview_preflight_rejects_unrecoverable_or_invalid_operations(author, operations):
+ with pytest.raises((NXToolError, TypeError)):
+ author.e._preview_plan(operations)
+ assert author.expression.RightHandSide == "10"
+
+
+def test_preview_discards_unknown_tokens_and_invalid_actions(author):
+ for action in ["accept", "unknown"]:
+ with pytest.raises(NXToolError):
+ author.e._finish_preview("missing", action)
+
+
+def test_expression_value_uses_its_declared_unit_not_part_unit(author):
+ exp = author.expression
+ exp.Units = NS(Abbreviation="in")
+ exp.Value = 25.4
+ exp.expression_value = 1.0
+ record = author.e._expression_record(exp)
+ assert record["value"] == 1 and record["units"] == "in"
+
+
+@pytest.mark.asyncio
+async def test_authoring_tool_schemas_are_explicit_and_path_scoped(tmp_path):
+ from unittest.mock import AsyncMock
+
+ from nx_mcp.server import create_server
+ from nx_mcp.workspace import Workspace
+
+ bridge = NS(call=AsyncMock(return_value={"status": "success"}))
+ server = create_server(bridge, Workspace(tmp_path), enable_experimental=True)
+ tools = {t.name: t for t in await server.list_tools()}
+ variants = tools["nx_edit_sketch"].inputSchema["properties"]["operations"]["items"]["oneOf"]
+ assert {v["properties"]["action"]["const"] for v in variants} == {
+ "line",
+ "arc",
+ "add_line",
+ "delete",
+ "constraint",
+ }
+ assert all(v["additionalProperties"] is False for v in variants)
+ assert tools["nx_find_geometry"].annotations.readOnlyHint
+ assert not tools["nx_preview_change"].annotations.readOnlyHint
+ await server.call_tool(
+ "nx_component_action",
+ {"component": "obj_example", "action": "replace", "part_path": "replacement.prt"},
+ )
+ args = bridge.call.call_args.args[1]
+ assert args["part_path"] == str(tmp_path / "replacement.prt") and args["operation_id"]
diff --git a/tests/test_bridge_import.py b/tests/test_bridge_import.py
index 9a621b3..8847d1f 100644
--- a/tests/test_bridge_import.py
+++ b/tests/test_bridge_import.py
@@ -22,3 +22,14 @@ def test_nx_bridge_import_succeeds_without_site_packages() -> None:
)
assert result.returncode == 0, result.stderr
+
+
+def test_hardened_runtime_import_has_no_sidecar_dependencies():
+ source_root = Path(__file__).parents[1] / "src"
+ result = subprocess.run(
+ [sys.executable, "-S", "-c", "import nx_mcp.hardened"],
+ capture_output=True,
+ text=True,
+ env=os.environ | {"PYTHONPATH": str(source_root)},
+ )
+ assert result.returncode == 0, result.stderr
diff --git a/tests/test_capability_matrix.py b/tests/test_capability_matrix.py
new file mode 100644
index 0000000..97f80eb
--- /dev/null
+++ b/tests/test_capability_matrix.py
@@ -0,0 +1,53 @@
+"""Evidence labels must not promote local or missing evidence into native claims."""
+
+import importlib.util
+from pathlib import Path
+
+import pytest
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts/generate_capability_matrix.py"
+SPEC = importlib.util.spec_from_file_location("capability_matrix", SCRIPT)
+assert SPEC and SPEC.loader
+matrix = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(matrix)
+
+
+@pytest.mark.parametrize(
+ ("status", "evidence", "expected"),
+ [
+ ("tested", "real_NX_v2606_scoped_and_local_boundary_tests", "Native-tested"),
+ ("experimental", "real_NX_v2606", "Experimental"),
+ ("tested", "local_contract_test", "Contract/sidecar-tested"),
+ ("tested", "live_sidecar_and_contract_tests", "Contract/sidecar-tested"),
+ ("tested", None, "Experimental"),
+ ("tested", "unrecognized", "Experimental"),
+ ("unavailable", "real_NX_v2606", "Unavailable"),
+ ],
+)
+def test_classification_does_not_inflate_evidence(status, evidence, expected):
+ assert matrix.classification({"status": status, "evidence_type": evidence}) == expected
+
+
+def test_render_preserves_caveats_and_is_order_independent():
+ manifest = {
+ "revision": "fixture",
+ "nx_version": "v2606",
+ "bridge_protocol": 1,
+ "tools": {
+ "nx_z": {"status": "tested", "scope": "unknown | \nsecond line"},
+ "nx_a": {
+ "status": "tested",
+ "evidence_type": "real_NX_v2606",
+ "scope": "one case only",
+ },
+ },
+ "unavailable": ["missing_feature"],
+ "limitations": ["No general certification"],
+ }
+ first = matrix.render(manifest)
+ manifest["tools"] = dict(reversed(list(manifest["tools"].items())))
+ assert first == matrix.render(manifest)
+ assert "unknown | <unsafe>
second line" in first
+ assert "one case only" in first
+ assert "- missing_feature" in first
+ assert "No general certification" in first
diff --git a/tests/test_display_lifecycle.py b/tests/test_display_lifecycle.py
new file mode 100644
index 0000000..f245ae1
--- /dev/null
+++ b/tests/test_display_lifecycle.py
@@ -0,0 +1,271 @@
+"""Stateful display, clipping and solver tests at the installed NX API seam."""
+
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from nx_mcp.visual_tools import enum_name
+from tests.fakes import Body, Component, Curve, Feature, Object, Part, Sketch, point
+
+pytestmark = pytest.mark.fake_nx
+
+
+def test_appearance_restores_per_face_attributes_and_blank_flags(rig):
+ b = Body()
+ b.faces[0].Color = 21
+ b.faces[0].transparency = 15
+ b.IsBlanked = True
+ rig.part.Bodies.append(b)
+ ref = rig.ref(b)
+ before = rig.e._display_info([ref])["objects"]
+ result = rig.e.execute("nx_set_display", {"objects": [ref], "color": "red", "transparency": 60})
+ assert b.Color == 3 and b.faces[0].Color == 3 and b.faces[0].transparency == 60
+ assert all(not m.ApplyToOwningParts for m in rig.modifications)
+ rig.e.execute("nx_restore_display", {"restore_id": result["restore_id"]})
+ assert rig.e._display_info([ref])["objects"] == before
+ assert all(m.Dispose.call_count == 1 for m in rig.modifications)
+ with pytest.raises(NXToolError):
+ rig.e._restore_display(result["restore_id"])
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"color": "invalid"},
+ {"color": "red", "color_index": 3},
+ {"color_index": True},
+ {"color_index": 0},
+ {"color_index": 217},
+ {"transparency": -1},
+ {"transparency": 101},
+ {"transparency": 1.5},
+ ],
+)
+def test_invalid_appearance_preflight_never_applies(rig, params):
+ with pytest.raises(NXToolError):
+ rig.e._set_display(["unresolved"], **params)
+ assert not rig.modifications
+
+
+def test_snapshot_order_and_stale_member_fail_before_any_restore(rig):
+ b = Body()
+ rig.part.Bodies.append(b)
+ ref = rig.ref(b)
+ first = rig.e._set_visibility([ref], "hide")["restore_id"]
+ second = rig.e._set_display([ref], color="gray")["restore_id"]
+ with pytest.raises(NXToolError) as error:
+ rig.e._restore_display(first)
+ assert error.value.code == "NX_RESTORE_ORDER" and b.IsBlanked
+ face_ref = rig.e._display_snapshots[second]["records"][1]["object"]["id"]
+ rig.e.objects._objects.pop(face_ref)
+ n = len(rig.modifications)
+ with pytest.raises(NXToolError):
+ rig.e._restore_display(second)
+ assert len(rig.modifications) == n and b.Color == 5
+
+
+def test_isolation_keeps_nested_ancestors_and_restores_hidden_siblings(rig):
+ seed = Body()
+ root = Component("root")
+ nested = Component("nested", parent=root)
+ child = Component("child", [seed], nested)
+ other = Component("other", [Body()], root)
+ rig.part.ComponentAssembly.RootComponent = root
+ root.IsBlanked = True
+ other.IsBlanked = True
+ ref = rig.ref(child, "component")
+ body = child.FindOccurrence(seed)
+ result = rig.e._set_visibility([ref], "isolate")
+ assert not body.IsBlanked and not child.IsBlanked and not nested.IsBlanked
+ assert other.IsBlanked
+ rig.e._restore_display(result["restore_id"])
+ assert other.IsBlanked and root.IsBlanked and not body.IsBlanked
+ assert rig.e._display_info([ref])["count"] == 2
+
+
+def test_display_target_kinds_and_part_guards(rig, tmp_path):
+ b = Body()
+ feature = Feature(bodies=[b])
+ sk = Sketch(rig.session)
+ sk.geometry = [Curve()]
+ assert rig.e._display_targets([rig.ref(feature, "feature")]) == [b]
+ assert rig.e._display_targets([rig.ref(sk, "sketch")]) == sk.geometry
+ face = rig.ref(b.faces[0], "face")
+ with pytest.raises(NXToolError):
+ rig.e._set_visibility([face], "hide")
+ for mode in ["invalid", "isolate"]:
+ with pytest.raises(NXToolError):
+ rig.e._set_visibility([rig.ref(sk.geometry[0], "curve")], mode)
+ with pytest.raises(NXToolError):
+ rig.e._set_display([rig.ref(sk.geometry[0], "curve")], transparency=10)
+ for values in [[], None, ["a"] * 1001]:
+ with pytest.raises(NXToolError):
+ rig.e._display_targets(values)
+ with pytest.raises(NXToolError):
+ rig.e._display_records([Curve()] * 10001)
+ sk.geometry = []
+ with pytest.raises(NXToolError):
+ rig.e._display_targets([rig.ref(sk, "sketch")])
+ old = rig.part
+ Part(rig.session, tmp_path / "other.prt")
+ rig.session.Parts.Work = old
+ with pytest.raises(NXToolError):
+ rig.e._display_info([face])
+
+
+def section_builder(rig):
+ builders = []
+
+ def create(*args):
+ target = args[0] if len(args) == 2 else Object("section")
+ if not hasattr(target, "origin"):
+ target.origin = point()
+ target.normal = point(0, 0, 1)
+ b = NS(
+ GetOrigin=lambda: target.origin,
+ GetNormal=lambda: target.normal,
+ Destroy=Mock(),
+ ShowClip=True,
+ ShowCap=True,
+ )
+ b.SetName = target.SetName
+ b.SetNormal = lambda v: setattr(target, "normal", v)
+ b.SetOrigin = lambda v: setattr(target, "origin", v)
+
+ def commit():
+ if target not in rig.part.DynamicSections:
+ rig.part.DynamicSections.append(target)
+ return target
+
+ b.Commit = Mock(side_effect=commit)
+ builders.append(b)
+ return b
+
+ rig.part.DynamicSections.CreateSectionBuilder = create
+ rig.part.DynamicSections.DeleteSections = lambda _, v: [
+ rig.part.DynamicSections.remove(x) for x in v
+ ]
+ return builders
+
+
+def test_sections_create_edit_toggle_delete_and_inspection_cleanup(rig):
+ builders = section_builder(rig)
+ result = rig.e._section_view([1, 2, 3], [0, 0, 4])
+ ref = result["object"]["id"]
+ assert result["normal"] == [0, 0, 1] and not result["geometry_changed"]
+ assert result["retained_side"] == "negative_normal"
+ info = rig.e._list_sections()
+ assert info["count"] == 1 and info["sections"][0]["active"]
+ assert not rig.session.marks
+ with pytest.raises(NXToolError):
+ rig.e._section_view([0, 0, 0], [1, 0, 0])
+ rig.e._section_view([4, 5, 6], [1, 0, 0], section=ref, cap=False)
+ rig.e._section_control(ref, "disable")
+ assert not rig.part.ModelingViews.WorkView.DisplaySectioningToggle
+ rig.e._section_control(ref, "enable")
+ assert rig.part.ModelingViews.WorkView.DisplaySectioningToggle
+ with pytest.raises(NXToolError):
+ rig.e._section_control(ref, "bad")
+ rig.e._section_control(ref, "delete")
+ assert not rig.part.DynamicSections
+ assert all(b.Destroy.call_count == 1 for b in builders)
+
+
+@pytest.mark.parametrize(
+ "origin,normal,name",
+ [
+ ([0, 0], [0, 0, 1], "x"),
+ ([0, 0, 0], [0, 0, 0], "x"),
+ ([0, 0, 0], [0, 0, 1], ""),
+ ([0, 0, 0], [float("nan"), 0, 0], "x"),
+ ],
+)
+def test_invalid_section_parameters_do_not_create_builder(rig, origin, normal, name):
+ builders = section_builder(rig)
+ with pytest.raises(NXToolError):
+ rig.e._section_view(origin, normal, name=name)
+ assert not builders
+
+
+@pytest.mark.parametrize(
+ "status,dof,expected", [(1, 4, 4), (2, 0, 0), (3, -1, None), (999, 10, None)]
+)
+@pytest.mark.parametrize("active", [True, False])
+def test_solver_status_and_constraint_links_preserve_edit_state(rig, status, dof, expected, active):
+ sk = Sketch(rig.session)
+ sk.status = (status, dof)
+ curve = Curve()
+ sk.geometry = [curve]
+ constraint = Object()
+ constraint.ConstraintType = 1
+ from tests.test_authoring_review import Expression
+
+ rig.nx.Expression = NS(UnitsOption=NS(Expression="expression"))
+ constraint.AssociatedExpression = Expression("p1")
+ constraint.AssociatedExpression.Value = 254.0
+ constraint.AssociatedExpression.expression_value = 10.0
+ sk.constraints = [constraint]
+ rig.part.Sketches.append(sk)
+ region = NS(Commit=Mock(), Destroy=Mock())
+ rig.part.Sketches.CreateWorkRegionBuilder = lambda: region
+ if active:
+ rig.session.ActiveSketch = sk
+ result = rig.e._sketch_diagnostics(rig.ref(sk, "sketch"))
+ assert result["remaining_degrees_of_freedom"] == expected
+ assert result["constraints"][0]["expression"]["value"] == 10
+ assert result["geometry"][0]["constraints"] == [result["constraints"][0]["object"]["id"]]
+ assert rig.session.ActiveSketch == (sk if active else None)
+ assert not rig.session.marks and region.Destroy.call_count == 1
+ assert region.Scope == "all" and result["conflicting_constraints"] is None
+
+
+def test_diagnostics_native_failure_restores_activation_and_marks(rig):
+ sk = Sketch(rig.session)
+ region = NS(Commit=Mock(side_effect=RuntimeError("solver failed")), Destroy=Mock())
+ rig.part.Sketches.CreateWorkRegionBuilder = lambda: region
+ with pytest.raises(RuntimeError):
+ rig.e._sketch_diagnostics(rig.ref(sk, "sketch"))
+ assert rig.session.ActiveSketch is None and not rig.session.marks
+ assert region.Destroy.call_count == 1
+ rig.session.ActiveSketch = Sketch(rig.session)
+ with pytest.raises(NXToolError):
+ rig.e._sketch_diagnostics(rig.ref(sk, "sketch"))
+
+
+def test_highlighting_only_includes_penetration_unless_contact_requested(rig):
+ a = Body("a")
+ b = Body("b")
+ refs = [rig.e._reference(v, "body", rig.part, "body") for v in (a, b)]
+ rig.e._check_interference = lambda *_: {
+ "pairs": [{"classification": "contact", "objects": refs}]
+ }
+ assert rig.e._highlight_collisions("a", "b")["highlighted_count"] == 0
+ assert rig.e._highlight_collisions("a", "b", True)["highlighted_count"] == 2
+ assert a.highlighted and b.highlighted
+ assert rig.e._clear_highlights()["cleared_count"] == 2 and not a.highlighted
+ b.Highlight = Mock(side_effect=RuntimeError("deleted"))
+ with pytest.raises(RuntimeError):
+ rig.e._highlight_collisions("a", "b", True)
+ assert not a.highlighted
+ b.Unhighlight = Mock(side_effect=RuntimeError("deleted"))
+ rig.e._highlighted_objects = [b]
+ assert rig.e._clear_highlights()["cleared_count"] == 0
+ rig.session.IsBatch = True
+ with pytest.raises(NXToolError):
+ rig.e._highlight_collisions("a", "b")
+ with pytest.raises(NXToolError):
+ rig.e._section_view([0, 0, 0], [0, 0, 1])
+ assert enum_name(999, NS(A=1)) == "unknown_999"
+
+
+def test_failed_sketch_deactivation_still_rolls_back_work_region(rig):
+ sk = Sketch(rig.session)
+ sk.Deactivate = Mock(side_effect=RuntimeError("deactivate failed"))
+ region = NS(Commit=Mock(), Destroy=Mock())
+ rig.part.Sketches.CreateWorkRegionBuilder = lambda: region
+ with pytest.raises(RuntimeError):
+ rig.e._sketch_diagnostics(rig.ref(sk, "sketch"))
+ assert rig.session.ActiveSketch is None and not rig.session.marks
diff --git a/tests/test_documentation_editing.py b/tests/test_documentation_editing.py
new file mode 100644
index 0000000..4f3bea1
--- /dev/null
+++ b/tests/test_documentation_editing.py
@@ -0,0 +1,163 @@
+"""Contracts, preflight and transaction regressions; native geometry tested separately."""
+
+import inspect
+from types import SimpleNamespace as NS
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+from nx_mcp import documentation_editing_server
+from nx_mcp.hardened import READ_ONLY
+from nx_mcp.runtime import NXToolError
+from tests.test_freeform_manufacturing import ff as _freeform_fixture
+
+
+@pytest.fixture
+def ff(rig, monkeypatch):
+ return _freeform_fixture.__wrapped__(rig, monkeypatch)
+
+
+def test_contracts(rig):
+ for name, fn in vars(documentation_editing_server).items():
+ if name.startswith("nx_") and inspect.isfunction(fn):
+ assert (
+ inspect.signature(fn).parameters.keys()
+ == inspect.signature(rig.e._handlers[name]).parameters.keys()
+ )
+ assert documentation_editing_server.READ_ONLY <= READ_ONLY
+
+
+def test_catalog_reads_local_installed_table_only(ff, tmp_path, monkeypatch):
+ directory = tmp_path / "UGII" / "modeling_standards"
+ directory.mkdir(parents=True)
+ (directory / "NX_Thread_Standard.xml").write_text(
+ ''
+ )
+ monkeypatch.setenv("UGII_BASE_DIR", str(tmp_path))
+ assert ff.e._thread_catalog()["items"] == ["Fixture"]
+ assert ff.e._thread_catalog(standard="Fixture", limit=1)["next_offset"] == 1
+ assert ff.e._thread_catalog(standard="Fixture", size="M6")["items"][0]["Pitch"] == "1"
+ with pytest.raises(NXToolError, match="Select a standard"):
+ ff.e._thread_catalog(size="M6")
+ with pytest.raises(NXToolError):
+ ff.e._thread_catalog(standard="absent")
+ monkeypatch.delenv("UGII_BASE_DIR")
+ with pytest.raises(NXToolError):
+ ff.e._thread_catalog()
+
+
+def test_standard_thread_requires_unambiguous_catalog_selection(ff):
+ row = {"Standard": "Fixture", "Size": "M6", "Method": "CUT", "RadialEngage": "0.75"}
+ ff.e._thread_rows = lambda: [row, {**row, "RadialEngage": "0.5"}]
+ with pytest.raises(NXToolError) as error:
+ ff.e._standard_thread("face", "start", "Fixture", "M6", 4)
+ assert error.value.code == "NX_AMBIGUOUS_THREAD"
+ ff.e._freeform_builder.assert_not_called()
+ result = ff.e._standard_thread(
+ ff.ref(ff.faces[0], "face"),
+ ff.ref(ff.faces[1], "face"),
+ "Fixture",
+ "M6",
+ 4,
+ radial_engage="0.75",
+ )
+ assert result["standard"] == "Fixture"
+ assert ff.b.ThreadInput == ff.nx.Features.ThreadBuilder.Input.ThreadTable
+ ff.b.Destroy.assert_called_once()
+
+
+def test_column_preflight_prevents_mutations(ff):
+ ff.e._parts_list_object = lambda _: NS(Tag=123)
+ ff.uf.Tabnot.AskNmColumns.return_value = 3
+ for args in [
+ {"action": "append"},
+ {"action": "edit", "index": 3},
+ {"action": "remove", "index": 0, "title": "bad"},
+ {"action": "append", "title": "x", "field": "x", "width": -1},
+ ]:
+ with pytest.raises(NXToolError):
+ ff.e._parts_list_column("table", **args)
+ ff.uf.Plist.CreateColumn.assert_not_called()
+ ff.uf.Tabnot.RemoveColumn.assert_not_called()
+
+
+def test_column_append_and_edit_use_native_preferences(ff, monkeypatch):
+ ff.e._parts_list_object = lambda _: NS(Tag=123)
+ ff.e._parts_list_info = lambda _: {"rows": [["one"]]}
+ ff.uf.Tabnot.AskNmColumns.return_value = 3
+ ff.uf.Plist.CreateColumn.return_value = 88
+ ff.uf.Tabnot.AskNmHeaderRows.return_value = 1
+ ff.nx.UF.Plist = NS(ColumnType=NS(COLUMN_TYPE_GENERAL=1))
+ assert ff.e._parts_list_column("table", "append", title="Part", field="native field", width=30)[
+ "rows"
+ ] == [["one"]]
+ ff.uf.Tabnot.AddColumn.assert_called_once_with(123, 88, 3)
+ ff.uf.Plist.Update.assert_called_once_with(123)
+
+
+def test_annotation_conflicting_edits_are_rejected(ff):
+ ff.e._resolve = lambda *_: NS(AnnotationOrigin=None)
+ for args in [{"delete": True, "name": "x"}, {}, {"name": ""}, {"position": [0, 1]}]:
+ with pytest.raises(NXToolError):
+ ff.e._edit_annotation("annotation", **args)
+ ff.e._update_model.assert_not_called()
+
+
+def test_managed_pmi_refresh_and_missing_source(ff):
+ obj = Mock()
+ obj.HasUserAttribute.return_value = True
+ obj.GetStringAttribute.return_value = (
+ '{"kind":"body","body":"body-handle","faces":[],"measured":{"thickness":2}}'
+ )
+ obj.AnnotationOrigin = ff.nx.Point3d(1, 2, 3)
+ ff.nx.NXObject = NS(AttributeType=NS(String=1))
+ ff.e._documentation_annotations = lambda _: [obj]
+ ff.uf.Tag.AskTagOfHandle.return_value = ff.body.Tag
+ ff.e._sm_manager = lambda: NS(GetBodyThickness=lambda _: 3)
+ ff.e._sheet_metal_annotation = Mock(return_value={"modified": ["updated"]})
+ assert ff.e._refresh_annotations()["updated"] == ["updated"]
+ assert ff.e._sheet_metal_annotation.call_args.kwargs["automatic"]
+ ff.uf.Tag.AskTagOfHandle.side_effect = RuntimeError("stale")
+ with pytest.raises(NXToolError) as error:
+ ff.e._refresh_annotations()
+ assert error.value.code == "NX_STALE_ANNOTATION_SOURCE"
+ obj.GetStringAttribute.return_value = "disabled"
+ assert ff.e._refresh_annotations()["updated_count"] == 0
+
+
+def test_annotation_refresh_failure_rolls_back_the_model_edit(rig):
+ rig.e._handlers["nx_extrude"] = Mock(return_value={})
+ rig.e._refresh_annotations = Mock(
+ side_effect=NXToolError("NX_STALE_ANNOTATION_SOURCE", "stale source")
+ )
+ rig.session.UndoToMark = Mock()
+ with pytest.raises(NXToolError) as error:
+ rig.e.execute("nx_extrude", {})
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ rig.session.UndoToMark.assert_called_once()
+
+
+def test_automatic_bend_table_rebuild_and_opt_out(ff):
+ from tests.fakes import Object
+
+ table = Object("bend table")
+ builder = MagicMock()
+ builder.Style.BendTable.AutomaticUpdate = True
+ builder.Commit.return_value = table
+
+ class Tables(list):
+ def CreateBendTableBuilder(self, existing):
+ assert existing is table
+ return builder
+
+ ff.part.Annotations = NS(BendTables=Tables([table]))
+ ff.e._documentation_annotations = lambda _: []
+ ff.e._table_cells = Mock(side_effect=[[["80"]], [["85"]]])
+ assert ff.e._refresh_annotations()["updated_count"] == 1
+ builder.Commit.assert_called_once()
+ builder.Destroy.assert_called_once()
+ builder.reset_mock()
+ builder.Style.BendTable.AutomaticUpdate = False
+ assert ff.e._refresh_annotations()["updated_count"] == 0
+ builder.Commit.assert_not_called()
+ builder.Destroy.assert_called_once()
diff --git a/tests/test_engineering.py b/tests/test_engineering.py
new file mode 100644
index 0000000..7bdc6d5
--- /dev/null
+++ b/tests/test_engineering.py
@@ -0,0 +1,877 @@
+"""Regression contracts for observed NX failures; native receipts validate geometry.
+
+These seams verify validation, result cardinality, transform semantics, and cleanup.
+They do not claim to emulate the NX geometric kernel.
+"""
+
+import contextlib
+import struct
+import sys
+from pathlib import Path
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.hardened import IDENTITY
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Edge, Feature, Object, Sketch, point
+
+pytestmark = pytest.mark.fake_nx
+
+
+@pytest.fixture
+def eng(rig, monkeypatch):
+ r = rig
+ r.body = Body()
+ r.feature = Feature(bodies=[r.body])
+ r.sketch = Sketch(r.session)
+ monkeypatch.setattr(Sketch, "Feature", r.feature, raising=False)
+ r.part.Bodies.append(r.body)
+ r.part.Features.append(r.feature)
+ r.part.Sketches.append(r.sketch)
+ for o in [r.body, r.feature, r.sketch, *r.body.faces, *r.body.edges]:
+ o.OwningPart = r.part
+ o.IsOccurrence = False
+ for o in [*r.body.faces, *r.body.edges]:
+ o.GetBody = lambda: r.body
+ r.nx.SmartObject = NS(UpdateOption=NS(WithinModeling=1))
+ r.nx.Features.MoveObject = Feature
+ r.part.Directions = NS(CreateDirection=lambda p, v, _: NS(origin=p, vector=v))
+ r.part.CoordinateSystems = NS(
+ CreateCoordinateSystem=lambda p, m, _: NS(Origin=p, Orientation=m)
+ )
+ r.part.ScRuleFactory = NS(CreateRuleOptions=lambda: NS(Dispose=Mock()))
+ for kind in ["Edge", "Body", "Face"]:
+ setattr(r.part.ScRuleFactory, "CreateRule" + kind + "Dumb", lambda objects: objects)
+ r.part.ScRuleFactory.CreateRuleCurveFeature = lambda objects, *_: objects
+
+ class Collector:
+ def ReplaceRules(self, rules, *_):
+ self.rules = rules
+
+ def GetRules(self):
+ return self.rules
+
+ r.part.ScCollectors = NS(CreateCollector=Collector)
+ r.part.Sections = NS(CreateSection=lambda: NS(AddToSection=Mock()))
+ r.nx.Section = NS(Mode=NS(Create=1))
+ g = NS(
+ Extend=NS(ExtendType=NS(Value="value", ThroughAll="all", UntilSelected="face")),
+ BooleanOperation=NS(
+ BooleanType=NS(
+ Create="create", Unite="unite", Subtract="subtract", Intersect="intersect"
+ )
+ ),
+ )
+ monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", g)
+ r.nx.GeometricUtilities = g
+ r.e._update_model = Mock()
+ r.e._check_sketch_result = Mock(return_value={"valid": True})
+ r.e._editing_sketch = lambda _: contextlib.nullcontext()
+ r.builders = []
+
+ def builder(*_):
+ def expression():
+ return NS(RightHandSide="0")
+
+ b = NS(
+ Destroy=Mock(),
+ CommitFeature=Mock(return_value=r.feature),
+ Commit=Mock(return_value=r.feature),
+ DefaultThickness=expression(),
+ StationaryReference=Collector(),
+ FaceSetAngleExpressionList=NS(Append=Mock()),
+ Type=NS(Face=1),
+ SectionsList=NS(Append=Mock()),
+ BodyPreferenceTypes=NS(Solid=1, Sheet=2),
+ Limits=NS(StartExtend=NS(Value=expression()), EndExtend=NS(Value=expression())),
+ BooleanOperation=NS(SetTargetBodies=Mock()),
+ BooleanOption=NS(SetTargetBodies=Mock()),
+ Diameter=expression(),
+ Height=expression(),
+ FirstOffsetExp=expression(),
+ ChamferOption=NS(SymmetricOffsets=1),
+ AddChainset=Mock(),
+ ExtractType=NS(Body=1),
+ ExtractBodyCollector=Collector(),
+ ObjectToMoveObject=NS(Add=Mock()),
+ MoveObjectResultOptions=NS(MoveOriginal=1),
+ TransformMotion=NS(Options=NS(CsysToCsys=1)),
+ MirrorBodyCollector=Collector(),
+ Plane=NS(),
+ SectionList=NS(Append=Mock()),
+ GuideList=NS(Append=Mock()),
+ )
+ r.builders.append(b)
+ return b
+
+ r.builder = builder
+ for name in [
+ "Extrude",
+ "Shell",
+ "ThroughCurves",
+ "Draft",
+ "EdgeBlend",
+ "Chamfer",
+ "Cylinder",
+ "ExtractFace",
+ "MirrorBody",
+ "Swept",
+ ]:
+ setattr(r.part.Features, "Create" + name + "Builder", builder)
+ r.part.BaseFeatures = NS(CreateMoveObjectBuilder=builder)
+ r.part.Datums = NS(CreateFixedDatumPlane=Mock(return_value=Object("plane")))
+ r.part.CreateExpressionCollectorSet = lambda *args: args
+ return r
+
+
+def test_owned_geometry_rejects_occurrences_and_other_parts(eng):
+ ref = eng.ref(eng.body)
+ eng.body.IsOccurrence = True
+ with pytest.raises(NXToolError, match="owned"):
+ eng.e._engineering_owned(ref, "body")
+ eng.body.IsOccurrence = False
+ eng.body.OwningPart = object()
+ with pytest.raises(NXToolError, match="owned"):
+ eng.e._engineering_owned(ref, "body")
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"distance": 8, "start": -2},
+ {"distance": 8, "symmetric": True},
+ {"distance": 8, "direction": [0, 1, 1], "reverse": True},
+ {"end_type": "through_all", "boolean": "subtract"},
+ {"end_type": "up_to_face"},
+ ],
+)
+def test_extended_extrude_preserves_limits_normal_and_multibody_results(eng, params):
+ r = eng
+ if params.get("boolean"):
+ params = {**params, "targets": [r.ref(r.body)]}
+ if params.get("end_type") == "up_to_face":
+ params = {**params, "target_face": r.ref(r.body.faces[0], "face")}
+ second = Body()
+ r.feature.bodies.append(second)
+ out = r.e._extrude(r.ref(r.sketch, "sketch"), **params)
+ assert out["body_count"] == 2 and len(out["bodies"]) == 2
+ b = r.builders[-1]
+ assert b.Destroy.call_count == 1
+ if params.get("symmetric"):
+ assert float(b.Limits.StartExtend.Value.RightHandSide) == -4
+ assert float(b.Limits.EndExtend.Value.RightHandSide) == 4
+ if params.get("reverse"):
+ assert out["limits"]["direction"][1] < 0
+ if params.get("boolean"):
+ b.BooleanOperation.SetTargetBodies.assert_called_once_with([r.body])
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"start": 1, "end_type": "bogus"},
+ {"start": 1, "boolean": "Create"},
+ {"start": 1, "symmetric": True, "distance": 4},
+ {"end_type": "up_to_face"},
+ {"start": 5, "distance": 4},
+ {"end_type": "through_all", "distance": 2},
+ {"end_type": "through_all"},
+ {"start": 1, "distance": 4, "boolean": "subtract"},
+ {"distance": float("nan"), "start": 1},
+ {"start": 1, "distance": 4, "direction": [0, 0, 0]},
+ ],
+)
+def test_invalid_extrude_arguments_do_not_allocate_builders(eng, params):
+ with pytest.raises(NXToolError):
+ eng.e._extrude(eng.ref(eng.sketch, "sketch"), **params)
+ assert not eng.builders
+
+
+@pytest.mark.parametrize("outward,flipped", [(False, True), (True, False)])
+def test_shell_direction_regression_and_removed_face_ownership(eng, outward, flipped):
+ r = eng
+ r.e._shell(r.ref(r.body), 1, [r.ref(r.body.faces[0], "face")], outward)
+ assert r.builders[-1].DefaultThicknessFlip is flipped
+ assert r.builders[-1].Tolerance > 0
+ r.body.faces[0].GetBody = lambda: Body()
+ with pytest.raises(NXToolError, match="belong"):
+ r.e._shell(r.ref(r.body), 1, [r.ref(r.body.faces[0], "face")])
+ assert len(r.builders) == 1
+
+
+@pytest.mark.parametrize(
+ "method,args",
+ [
+ ("_shell", {"thickness": 0}),
+ ("_blend", {"radius": 0}),
+ ("_chamfer", {"offset": -1}),
+ ("_draft", {"angle": 90}),
+ ("_loft", {"sketches": []}),
+ ],
+)
+def test_invalid_feature_dimensions_rejected(eng, method, args):
+ if method == "_shell":
+ args["body"] = eng.ref(eng.body)
+ elif method in {"_blend", "_chamfer"}:
+ args["edges"] = []
+ elif method == "_draft":
+ args.update(faces=[], stationary_face="", direction=[0, 0, 1])
+ with pytest.raises(NXToolError):
+ getattr(eng.e, method)(**args)
+ assert not eng.builders
+
+
+@pytest.mark.parametrize("solid", [True, False])
+def test_loft_sections_are_ordered_and_all_results_reported(eng, solid):
+ other = Sketch(eng.session)
+ other.IsOccurrence = False
+ other.OwningPart = eng.part
+ eng.part.Sketches.append(other)
+ result = eng.e._loft([eng.ref(eng.sketch, "sketch"), eng.ref(other, "sketch")], solid)
+ assert result["body_count"] == 1
+ assert eng.builders[-1].SectionsList.Append.call_count == 2
+ assert eng.builders[-1].BodyPreference == (1 if solid else 2)
+
+
+def test_builder_failure_always_destroys_native_handle(eng):
+ b = eng.builder()
+ b.CommitFeature.side_effect = RuntimeError("native failed")
+ eng.part.Features.CreateShellBuilder = lambda _: b
+ with pytest.raises(RuntimeError, match="native failed"):
+ eng.e._shell(eng.ref(eng.body), 1)
+ b.Destroy.assert_called_once()
+
+
+@pytest.mark.parametrize("method,arg", [("_blend", "radius"), ("_chamfer", "offset")])
+def test_edge_features_resolve_real_edges_and_reject_mixed_bodies(eng, method, arg):
+ r = eng
+ edge = r.body.edges[0]
+ out = getattr(r.e, method)([r.ref(edge, "edge")], **{arg: 1})
+ assert out["modified"][0]["id"] == r.ref(r.body)
+ assert r.builders[-1].Destroy.call_count == 1
+ foreign = Edge()
+ foreign.OwningPart = r.part
+ foreign.IsOccurrence = False
+ foreign.GetBody = lambda: Body()
+ with pytest.raises(NXToolError, match="one owned body"):
+ getattr(r.e, method)([r.ref(edge, "edge"), r.ref(foreign, "edge")], **{arg: 1})
+
+
+def test_hole_numeric_conversion_explicit_target_and_direction(eng):
+ r = eng
+ out = r.e._hole(2, 5, 1, 2, 3, body=r.ref(r.body), direction=[0, 0, -2])
+ assert out["location"] == [1.0, 2.0, 3.0] and out["direction"] == [0, 0, -1]
+ assert r.builders[-1].Origin.Z == 3.0
+ r.part.Bodies.append(Body())
+ with pytest.raises(NXToolError, match="one owned solid"):
+ r.e._hole(2, 5, 1, 2, 3)
+
+
+def test_draft_excludes_stationary_face_and_sets_tolerances(eng):
+ from tests.fakes import Face
+
+ r = eng
+ fixed = r.body.faces[0]
+ side = Face()
+ side.IsOccurrence = False
+ side.OwningPart = r.part
+ side.GetBody = lambda: r.body
+ r.e._draft([r.ref(side, "face")], r.ref(fixed, "face"), [0, 0, 1], 5)
+ assert r.builders[-1].AngleTolerance > 0 and r.builders[-1].DistanceTolerance > 0
+ with pytest.raises(NXToolError, match="exclude"):
+ r.e._draft([r.ref(fixed, "face")], r.ref(fixed, "face"), [0, 0, 1], 5)
+
+
+@pytest.mark.parametrize("copy", [False, True])
+def test_body_transform_is_absolute_and_copy_uses_associative_extract(eng, copy):
+ r = eng
+ out = r.e._transform_bodies([20, 30, 40], IDENTITY, [r.ref(r.body)], copy)
+ move = r.builders[-1]
+ assert move.MoveParents is False and move.Associative is True
+ assert move.TransformMotion.FromCsys.Origin.X == 0
+ assert move.TransformMotion.ToCsys.Origin.X == 20
+ assert bool(out["copy_feature"]) == copy
+ edited = r.e._transform_bodies([50, 30, 40], IDENTITY, feature=out["feature"]["id"])
+ assert edited["translation"] == [50, 30, 40]
+ assert r.builders[-1].TransformMotion.ToCsys.Origin.X == 50
+ with pytest.raises(NXToolError, match="omit"):
+ r.e._transform_bodies([0, 0, 0], IDENTITY, [r.ref(r.body)], feature=out["feature"]["id"])
+ with pytest.raises(NXToolError, match="Select bodies"):
+ r.e._transform_bodies([0, 0, 0], IDENTITY)
+
+
+def test_nonassociative_copy_result_is_rejected(eng):
+ eng.nx.Features.MoveObject = type("MoveObject", (Feature,), {})
+ with pytest.raises(NXToolError, match="editable MoveObject"):
+ eng.e._transform_bodies([1, 0, 0], IDENTITY, [eng.ref(eng.body)])
+ assert eng.builders[-1].Destroy.call_count == 1
+
+
+@pytest.mark.parametrize("plane", ["XY", "XZ", "YZ"])
+def test_mirror_preserves_original_and_uses_origin_plane(eng, plane):
+ result = eng.e._mirror_body(eng.ref(eng.body), plane)
+ assert result["body_count"] == 1 and eng.builders[-1].DeleteSourceBody is False
+ assert eng.part.Datums.CreateFixedDatumPlane.call_args.args[0].X == 0
+ with pytest.raises(NXToolError):
+ eng.e._mirror_body(eng.ref(eng.body), "custom")
+
+
+def test_sweep_requires_distinct_owned_sketches_and_explicit_boolean_target(eng):
+ r = eng
+ other = Sketch(r.session)
+ other.IsOccurrence = False
+ other.OwningPart = r.part
+ a, b = r.ref(r.sketch, "sketch"), r.ref(other, "sketch")
+ out = r.e._sweep(a, b)
+ assert out["body_count"] == 1 and r.builders[-1].GuideList.Append.call_count == 1
+ for args in [
+ (a, a, "none", None),
+ (a, b, "bad", None),
+ (a, b, "subtract", None),
+ (a, b, "none", [r.ref(r.body)]),
+ (a, b, "subtract", []),
+ ]:
+ with pytest.raises(NXToolError):
+ r.e._sweep(*args)
+
+
+@pytest.fixture
+def rendering(eng):
+ r = eng
+ r.nx.View.RenderingStyleType.Studio = "studio"
+ view = NS(RenderingStyle="original", UpdateDisplay=Mock())
+ r.part.ModelingViews = NS(WorkView=view)
+ r.e._view_info = lambda: {"camera": "unchanged"}
+ lights = NS(
+ LightsShadedViewsLightingCollection="old",
+ LightingCollectionType=NS(**{"Lighting" + str(i): i for i in range(1, 6)}),
+ Commit=Mock(),
+ Destroy=Mock(),
+ )
+ capture = NS(
+ SourceType=NS(WorkView=1),
+ UnitsEnumType=NS(Pixels=1),
+ BackgroundOptions=NS(Original=1, CustomColor=2, Transparent=3),
+ SetCustomBackgroundColor=Mock(),
+ Destroy=Mock(),
+ )
+
+ def dimensions(v):
+ capture.dimensions = v
+
+ capture.SetImageDimensionsInteger = dimensions
+
+ def commit():
+ height, width = capture.dimensions
+ data = b"\x89PNG\r\n\x1a\n" + b"\0" * 8 + struct.pack(">II", width, height)
+ Path(capture.NativeFileBrowser).write_bytes(data)
+
+ capture.Commit = Mock(side_effect=commit)
+ r.part.Views = NS(
+ CreateStudioImageCaptureBuilder=lambda: capture, CreateLighting=lambda _: lights
+ )
+ r.view, r.lights, r.capture = view, lights, capture
+ return r
+
+
+@pytest.mark.parametrize(
+ "background,color",
+ [("white", None), ("original", None), ("transparent", None), ("color", [0.2, 0.3, 0.4])],
+)
+def test_render_artifact_dimensions_checksum_and_view_restoration(rendering, background, color):
+ import hashlib
+
+ r = rendering
+ out = r.e._render_view(width=640, height=480, background=background, color=color, lighting=2)
+ assert out["resolution"] == [640, 480]
+ assert out["sha256"] == hashlib.sha256(Path(out["path"]).read_bytes()).hexdigest()
+ assert r.view.RenderingStyle == "original"
+ assert r.lights.LightsShadedViewsLightingCollection == "old"
+ r.lights.Destroy.assert_called_once()
+ r.capture.Destroy.assert_called_once()
+ with pytest.raises(NXToolError):
+ r.e._render_view(path=out["path"])
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"width": True},
+ {"height": 100},
+ {"background": "bad"},
+ {"style": "bad"},
+ {"color": [1, 1, 1]},
+ {"background": "color"},
+ {"background": "color", "color": [0, 0, 2]},
+ {"lighting": 0},
+ {"lighting": True},
+ {"path": "bad.jpg"},
+ ],
+)
+def test_invalid_render_requests_have_no_side_effects(rendering, params):
+ if "path" in params:
+ params = {**params, "path": str(rendering.e.workspace.root / params["path"])}
+ with pytest.raises(NXToolError):
+ rendering.e._render_view(**params)
+ rendering.capture.Commit.assert_not_called()
+ assert rendering.view.RenderingStyle == "original"
+
+
+@pytest.mark.parametrize("failure", ["native", "invalid_png", "wrong_resolution"])
+def test_render_failure_removes_partial_file_and_restores_view(rendering, failure):
+ r = rendering
+ file = r.e.workspace.root / "bad.png"
+
+ def commit():
+ file.write_bytes(b"partial")
+ if failure == "native":
+ raise RuntimeError("failed")
+ if failure == "wrong_resolution":
+ file.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\0" * 8 + struct.pack(">II", 1, 1))
+
+ r.capture.Commit.side_effect = commit
+ with pytest.raises((RuntimeError, NXToolError)):
+ r.e._render_view(path=str(file), lighting=1)
+ assert not file.exists() and r.view.RenderingStyle == "original"
+ assert r.lights.LightsShadedViewsLightingCollection == "old"
+
+
+def test_render_requires_interactive_display_part(rendering):
+ r = rendering
+ r.session.IsBatch = True
+ with pytest.raises(NXToolError, match="interactive"):
+ r.e._render_view()
+ r.session.IsBatch = False
+ r.session.Parts.Display = None
+ with pytest.raises(NXToolError, match="display part"):
+ r.e._render_view()
+
+
+@pytest.fixture
+def material(eng):
+ r = eng
+ r.nx.PhysicalMaterial = NS(Type=NS(Isotropic=1))
+ materials = []
+
+ class Materials(list):
+ def CreatePhysicalMaterialBuilder(self, _):
+ b = NS(
+ PropertyTable=NS(
+ SetBaseScalarWithDataPropertyValue=lambda key, value, unit: setattr(
+ r, "density", value
+ )
+ ),
+ Destroy=Mock(),
+ )
+
+ def commit():
+ m = NS(Name=b.Name, AssignObjects=Mock())
+ self.append(m)
+ return m
+
+ b.Commit = commit
+ r.material_builder = b
+ return b
+
+ def AskMaterialOfObject(self, _):
+ return self[0] if self else None
+
+ r.materials = Materials(materials)
+ r.part.MaterialManager = NS(PhysicalMaterials=r.materials)
+ r.part.UnitCollection = NS(FindObject=lambda name: name)
+ r.nx.UF.Modl = NS(DensityUnits=NS(KILOGRAMS_METERS=1))
+ r.density = 1000
+ r.uf.Modeling = NS(AskBodyDensity=lambda *_: r.density)
+ return r
+
+
+def test_material_assignment_checks_native_density_and_names(material):
+ r = material
+ assert r.e._material_info()["bodies"][0]["material_name"] is None
+ out = r.e._set_material([r.ref(r.body)], "Aluminum", 2700)
+ assert out["verified_densities_kg_m3"] == [2700]
+ assert r.e._material_info()["bodies"][0]["material_name"] == "Aluminum"
+ r.materials[0].AssignObjects.assert_called_once_with([r.body])
+ with pytest.raises(NXToolError, match="new material name"):
+ r.e._set_material([r.ref(r.body)], "ALUMINUM", 1000)
+ r.material_builder.Destroy.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "name,density,bodies", [("", 1000, True), ("a", -1, True), ("a", 1000, False)]
+)
+def test_invalid_material_assignment_precedes_creation(material, name, density, bodies):
+ r = material
+ with pytest.raises(NXToolError):
+ r.e._set_material([r.ref(r.body)] if bodies else [], name, density)
+ assert not r.materials
+
+
+def test_material_density_readback_mismatch_is_error(material):
+ r = material
+ r.uf.Modeling.AskBodyDensity = lambda *_: 42
+ with pytest.raises(NXToolError, match="densities differ"):
+ r.e._set_material([r.ref(r.body)], "Aluminum", 2700)
+ r.material_builder.Destroy.assert_called_once()
+
+
+def test_mass_tensor_uses_centroidal_values_and_product_signs(material):
+ from tests.fakes import matrix
+
+ r = material
+ values = list(range(47))
+ r.uf.Modeling.AskMassProps3d = Mock(return_value=(values, [0] * 13))
+ r.part.WCS = NS(CoordinateSystem=NS(Origin=point(1, 2, 3), Orientation=NS(Element=matrix())))
+ out = r.e._mass_properties()
+ assert out["center_of_gravity_m"] == [3, 4, 5]
+ assert out["inertia_tensor_centroid_kg_m2"] == [[12, -19, -21], [-19, 13, -20], [-21, -20, 14]]
+ assert out["wcs_origin_in_part_units"] == [1, 2, 3]
+ assert out["semantics"] == "sum_of_included_bodies_with_assigned_densities"
+
+
+def test_pdf_export_reports_actual_artifact_and_refuses_overwrite(eng):
+ r = eng
+ from contextlib import nullcontext
+
+ r.e._drawing_save_context = lambda *_, **__: nullcontext()
+ sheet = Object("Sheet1")
+ sheet.Open = Mock()
+ sheet.GetDraftingViews = Mock(return_value=[Object("View")])
+ r.part.DrawingSheets = [sheet]
+ b = NS(
+ ActionOption=NS(Native=1),
+ SizeOption=NS(FullScale=1),
+ UnitsOption=NS(Metric=1),
+ OutputTextOption=NS(Text=1),
+ ImageResolutionOption=NS(High=3),
+ Color=NS(AsDisplayed=0),
+ SourceBuilder=NS(SetSheets=Mock()),
+ Destroy=Mock(),
+ )
+ b.Commit = lambda: Path(b.Filename).write_bytes(b"%PDF-1.7\nfixture")
+ r.e._active_mark = None
+ r.e._update_model = Mock(side_effect=AssertionError("Export has no modeling mark"))
+ r.part.PlotManager = NS(CreatePrintPdfbuilder=lambda: b)
+ r.part.DraftingViews = NS(UpdateViews=Mock())
+ file = r.e.workspace.root / "drawings" / "test.pdf"
+ result = r.e._export_drawing_pdf(str(file))
+ assert result["sheet_count"] == 1 and result["size"] == file.stat().st_size
+ b.SourceBuilder.SetSheets.assert_called_once_with([sheet])
+ assert b.RasterImages and not b.ShadedGeometry
+ assert b.ImageResolution == b.ImageResolutionOption.High
+ r.part.DraftingViews.UpdateViews.assert_called_once_with(sheet.GetDraftingViews())
+ b.Destroy.assert_called_once()
+ with pytest.raises(NXToolError):
+ r.e._export_drawing_pdf(str(file))
+ file.unlink()
+ r.part.DrawingSheets = []
+ with pytest.raises(NXToolError, match="drawing sheet"):
+ r.e._export_drawing_pdf(str(file))
+
+
+def test_invalid_pdf_output_is_removed(eng):
+ r = eng
+ from contextlib import nullcontext
+
+ r.e._drawing_save_context = lambda *_, **__: nullcontext()
+ sheet = Object("sheet")
+ sheet.Open = Mock()
+ sheet.GetDraftingViews = Mock(return_value=[Object("View")])
+ r.part.DrawingSheets = [sheet]
+ b = NS(
+ ActionOption=NS(Native=1),
+ SizeOption=NS(FullScale=1),
+ UnitsOption=NS(Metric=1),
+ OutputTextOption=NS(Text=1),
+ ImageResolutionOption=NS(High=3),
+ Color=NS(AsDisplayed=0),
+ SourceBuilder=NS(SetSheets=Mock()),
+ Destroy=Mock(),
+ )
+ b.Commit = lambda: Path(b.Filename).write_bytes(b"not PDF")
+ r.e._active_mark = None
+ r.e._update_model = Mock(side_effect=AssertionError("Export has no modeling mark"))
+ r.part.PlotManager = NS(CreatePrintPdfbuilder=lambda: b)
+ r.part.DraftingViews = NS(UpdateViews=Mock())
+ file = r.e.workspace.root / "bad.pdf"
+ with pytest.raises(NXToolError, match="did not produce"):
+ r.e._export_drawing_pdf(str(file))
+ assert not file.exists()
+ b.Destroy.assert_called_once()
+
+
+@pytest.fixture
+def project(eng):
+ import shutil
+
+ from tests.fakes import Component, Part
+
+ r = eng
+ source = Path(r.part.FullPath)
+ source.write_bytes(b"original assembly")
+ prototype = source.parent / "prototype" / "body.prt"
+ prototype.parent.mkdir()
+ prototype.write_bytes(b"original body")
+ root = Component("root")
+ child = Component("child", parent=root)
+ child.Prototype = NS(FullPath=str(prototype))
+ root.children = [child]
+ r.part.ComponentAssembly.RootComponent = root
+ naming = {}
+ clone = NS(
+ OperationClass=NS(CLONE_OPERATION=1),
+ Action=NS(CLONE=1),
+ NamingTechnique=NS(USER_NAME=1),
+ Initialise=Mock(),
+ SetDefAction=Mock(),
+ AddAssembly=Mock(),
+ InitNamingFailures=lambda: None,
+ Terminate=Mock(),
+ )
+ clone.SetNaming = lambda src, _, dst: naming.update({src: dst})
+ clone.PerformClone = lambda _: [shutil.copyfile(src, dst) for src, dst in naming.items()]
+ r.uf.Clone = clone
+
+ def open_part(path):
+ part = Part(r.session, Path(path))
+ part.IsModified = False
+ parent = Component("root")
+ kid = Component("child", parent=parent)
+ kid.Prototype = NS(FullPath=naming[str(prototype.resolve())])
+ parent.children = [kid]
+ part.ComponentAssembly.RootComponent = parent
+ return {}
+
+ r.e._open_part = open_part
+ r.part.IsModified = False
+ r.clone, r.naming, r.prototype = clone, naming, prototype
+ return r
+
+
+@pytest.mark.parametrize("activate", [False, True])
+def test_project_copy_preserves_subfolders_sources_and_rewrites_dependencies(project, activate):
+ r = project
+ dest = r.e.workspace.root / "new" / "project"
+ result = r.e._copy_project(str(dest), "COPY_", activate)
+ assert result["references_verified"] and result["dependency_count"] == 1
+ assert (dest / "prototype" / "COPY_body.prt").read_bytes() == b"original body"
+ assert Path(r.part.FullPath).read_bytes() == b"original assembly"
+ assert r.prototype.read_bytes() == b"original body"
+ assert (dest / "nx-project-manifest.json").is_file()
+ assert (r.session.Parts.Work == r.part) is (not activate)
+ r.clone.Terminate.assert_called_once()
+
+
+def test_project_copy_failure_removes_only_new_destination(project):
+ r = project
+ dest = r.e.workspace.root / "failed"
+
+ def failure(_):
+ first = next(iter(r.naming.values()))
+ Path(first).write_bytes(b"partial")
+ raise RuntimeError("translator stopped")
+
+ r.clone.PerformClone = failure
+ with pytest.raises(RuntimeError, match="translator stopped"):
+ r.e._copy_project(str(dest), "COPY_")
+ assert not dest.exists() and r.prototype.read_bytes() == b"original body"
+ r.clone.Terminate.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "failure", ["existing", "prefix", "unsaved", "missing", "unloaded", "collision"]
+)
+def test_project_copy_preflight_prevents_unsafe_clone(project, failure):
+ r = project
+ dest = r.e.workspace.root / "new"
+ prefix = "COPY_"
+ if failure == "existing":
+ dest.mkdir()
+ if failure == "prefix":
+ prefix = "../"
+ if failure == "unsaved":
+ r.part.IsModified = True
+ if failure == "missing":
+ r.prototype.unlink()
+ if failure == "unloaded":
+ r.part.ComponentAssembly.RootComponent.children[0].Prototype = None
+ if failure == "collision":
+ prefix = ""
+ prefix = Path(r.part.FullPath).name.split(".")[0] # explicit loaded collision below
+ if failure == "collision":
+ from tests.fakes import Part
+
+ Part(r.session, r.e.workspace.root / ("COPY_" + Path(r.part.FullPath).name))
+ prefix = "COPY_"
+ with pytest.raises(NXToolError):
+ r.e._copy_project(str(dest), prefix)
+ r.clone.Initialise.assert_not_called()
+
+
+@pytest.fixture
+def assembly_constraints(eng, monkeypatch):
+ from tests.fakes import Component, Face
+
+ r = eng
+
+ class Constraint(Object):
+ Type = NS(Fix=1, Distance=2, Angle=3, Touch=4, Parallel=5, Perpendicular=6, Concentric=7)
+ Alignment = NS(InferAlign=1, CoAlign=2, ContraAlign=3)
+ SolverStatus = NS(Solved=1, NotSolved=2)
+
+ def __init__(self):
+ super().__init__("constraint")
+ self.Suppressed = False
+ self.Expression = 5
+ self.refs = []
+ self.status = 1
+
+ def CreateConstraintReference(self, obj, geom, *_):
+ self.refs.append(NS(GetMovableObject=lambda: obj, GetGeometry=lambda: geom))
+
+ def SetExpression(self, value):
+ self.Expression = float(value)
+
+ def GetConstraintStatus(self):
+ return self.status
+
+ def GetReferences(self):
+ return self.refs
+
+ module = NS(Constraint=Constraint)
+ monkeypatch.setitem(sys.modules, "NXOpen.Positioning", module)
+ r.nx.Positioning = module
+ r.nx.Assemblies = NS(Component=Component)
+ root = Component("root")
+ moving = Component("moving", parent=root)
+ target = Component("target", parent=root)
+ root.children = [moving, target]
+ r.part.ComponentAssembly.RootComponent = root
+ faces = []
+ for c in [moving, target]:
+ f = Face()
+ f.IsOccurrence = True
+ f.OwningPart = r.part
+ f.OwningComponent = c
+ faces.append(f)
+ pos = NS(
+ Constraints=[],
+ BeginAssemblyConstraints=Mock(),
+ EndAssemblyConstraints=Mock(),
+ ClearNetwork=Mock(),
+ )
+
+ def create(_):
+ c = Constraint()
+ pos.Constraints.append(c)
+ return c
+
+ pos.CreateConstraint = create
+ networks = []
+
+ def network():
+ captured = {c.Tag: c.Expression for c in pos.Constraints}
+ net = NS(AddConstraint=Mock(), Solve=Mock(), ApplyToModel=Mock(), captured=captured)
+ networks.append(net)
+ return net
+
+ pos.EstablishNetwork = network
+ r.part.ComponentAssembly.Positioner = pos
+ r.e._expression_record = lambda e: {"value": e}
+ r.pos, r.networks, r.Constraint = pos, networks, Constraint
+ r.moving, r.target, r.faces = moving, target, faces
+ return r
+
+
+def test_assembly_distance_edit_builds_network_after_changing_expression(assembly_constraints):
+ r = assembly_constraints
+ result = r.e._assembly_constraint(
+ "distance",
+ r.ref(r.moving, "component"),
+ r.ref(r.faces[0], "face"),
+ r.ref(r.target, "component"),
+ r.ref(r.faces[1], "face"),
+ 5,
+ "opposite",
+ )
+ ref = result["object"]["id"]
+ constraint = r.pos.Constraints[0]
+ result = r.e._edit_assembly_constraint(ref, value=12, alignment="same")
+ assert r.networks[-1].captured[constraint.Tag] == 12, "Network captured the old expression"
+ assert result["expression"]["value"] == 12 and result["alignment"] == "CoAlign"
+ r.e._edit_assembly_constraint(ref, suppressed=True)
+ assert r.e._list_assembly_constraints()["constraints"][0]["suppressed"]
+ assert r.pos.ClearNetwork.call_count == 3 and r.pos.EndAssemblyConstraints.call_count == 3
+
+
+def test_assembly_fix_and_solver_failure_cleanup(assembly_constraints):
+ r = assembly_constraints
+ result = r.e._assembly_constraint("fix", r.ref(r.moving, "component"))
+ constraint = r.pos.Constraints[0]
+ constraint.status = 2
+ with pytest.raises(NXToolError, match="not solved"):
+ r.e._edit_assembly_constraint(result["object"]["id"], suppressed=False)
+ assert r.pos.ClearNetwork.call_count == 2 and r.pos.EndAssemblyConstraints.call_count == 2
+ with pytest.raises(NXToolError):
+ r.e._edit_assembly_constraint(result["object"]["id"], value=12)
+ with pytest.raises(NXToolError):
+ r.e._edit_assembly_constraint(result["object"]["id"])
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "same_component",
+ "wrong_owner",
+ "bad_type",
+ "bad_alignment",
+ "missing_value",
+ "negative",
+ "fix_extra",
+ "suppressed",
+ ],
+)
+def test_invalid_assembly_constraints_do_not_create_network(assembly_constraints, change):
+ r = assembly_constraints
+ args = {
+ "constraint_type": "distance",
+ "component": r.ref(r.moving, "component"),
+ "geometry": r.ref(r.faces[0], "face"),
+ "target_component": r.ref(r.target, "component"),
+ "target_geometry": r.ref(r.faces[1], "face"),
+ "value": 5,
+ }
+ if change == "same_component":
+ args["target_component"] = args["component"]
+ if change == "wrong_owner":
+ args["geometry"] = args["target_geometry"]
+ if change == "bad_type":
+ args["constraint_type"] = "bogus"
+ if change == "bad_alignment":
+ args["alignment"] = "bogus"
+ if change == "missing_value":
+ args["value"] = None
+ if change == "negative":
+ args["value"] = -5
+ if change == "fix_extra":
+ args["constraint_type"] = "fix"
+ if change == "suppressed":
+ r.moving.IsSuppressed = True
+ with pytest.raises(NXToolError):
+ r.e._assembly_constraint(**args)
+ r.pos.BeginAssemblyConstraints.assert_not_called()
+
+
+@pytest.mark.parametrize("handle", ["capture", "lights"])
+def test_render_cleanup_failure_still_restores_style_and_removes_artifact(rendering, handle):
+ r = rendering
+ getattr(r, handle).Destroy.side_effect = RuntimeError("cleanup failed")
+ path = r.e.workspace.root / "cleanup.png"
+ with pytest.raises(NXToolError) as exc:
+ r.e._render_view(path=str(path), lighting=1)
+ assert exc.value.details["mutation_outcome"] == "partial"
+ assert r.view.RenderingStyle == "original" and not path.exists()
+ r.lights.Destroy.assert_called_once()
diff --git a/tests/test_exploded_views.py b/tests/test_exploded_views.py
new file mode 100644
index 0000000..381893d
--- /dev/null
+++ b/tests/test_exploded_views.py
@@ -0,0 +1,601 @@
+"""Observed NX explosion contracts; native fixtures separately verify NX geometry."""
+
+import copy
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.hardened import IDENTITY, matmul, matvec, rows, transpose, xyz
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Collection, Component, Object, point
+
+pytestmark = pytest.mark.fake_nx
+
+
+def compose(a, b):
+ ar, at = a
+ br, bt = b
+ return matmul(ar, br), [x + y for x, y in zip(matvec(ar, bt), at, strict=True)]
+
+
+def inverse(pose):
+ rotation, position = pose
+ inv = transpose(rotation)
+ return inv, matvec(inv, [-x for x in position])
+
+
+@pytest.fixture
+def explosions(rig):
+ r = rig
+ r.e._sheet_units = lambda _: "mm"
+ r.e._view_sheet = lambda _: None
+ r.e._place_drawing_view = Mock()
+ r.uf.Disp = NS(RegenerateDisplay=Mock())
+ r.uf.View = NS(AskViewDependentStatus=lambda _: (0, ""))
+ root = Component("root")
+ parent = Component("parent", parent=root)
+ leaf = Component("leaf", parent=parent)
+ base = Component("base", parent=root)
+ for c, pos in [(parent, [10, 20, 30]), (leaf, [10, 40, 30])]:
+ c.position = point(*pos)
+ c.rotation = r.e._nx_matrix([[0, -1, 0], [1, 0, 0], [0, 0, 1]])
+ r.part.ComponentAssembly.RootComponent = root
+ for c in (root, parent, leaf, base):
+ c.OwningPart = r.part
+ r.parent, r.leaf, r.base = parent, leaf, base
+ exs = Collection()
+ r.part.ComponentAssembly.Explosions = exs
+ r.part.DrawingSheets = Collection()
+ r.part.DrawingSheets.CurrentDrawingSheet = None
+ r.part.Drafting = NS(ExitDraftingApplication=Mock())
+ r.part.ModelingViews = Collection()
+ work = Object("Work")
+ work.OwningPart = r.part
+ work.Fit = Mock()
+ r.part.ModelingViews.append(work)
+ r.part.ModelingViews.WorkView = work
+ r.part.ModelingViews.FindObject = lambda name: work
+ r.part.DraftingViews = Collection()
+ r.part.DraftingViews.UpdateViews = Mock()
+ association = {}
+
+ class ExplodedComponent:
+ def __init__(self, component, explosion):
+ self.component, self.explosion = component, explosion
+
+ def GetComponent(self):
+ return self.component
+
+ def GetChildren(self):
+ return [ExplodedComponent(c, self.explosion) for c in self.component.GetChildren()]
+
+ def GetPosition(self):
+ c = self.component
+ baseline = rows(c.rotation), xyz(c.position)
+ if c.Parent:
+ pp, pr = ExplodedComponent(c.Parent, self.explosion).GetPosition()
+ inherited = compose(
+ (rows(pr), xyz(pp)), inverse((rows(c.Parent.rotation), xyz(c.Parent.position)))
+ )
+ baseline = compose(inherited, baseline)
+ delta = self.explosion.deltas.get(c.Tag, IDENTITY4)
+ rotation, position = compose(
+ baseline, ([v[:3] for v in delta[:3]], [v[3] for v in delta[:3]])
+ )
+ return point(*position), r.e._nx_matrix(rotation)
+
+ class Explosion(Object):
+ def __init__(self, name):
+ super().__init__(name)
+ self.OwningPart = r.part
+ self.deltas = {}
+ self.RootComponent = ExplodedComponent(root, self)
+
+ def Delete(self):
+ exs.remove(self)
+
+ def create(name):
+ ex = Explosion(name)
+ exs.append(ex)
+ return ex
+
+ exs.Create = Mock(side_effect=create)
+ r.ex = create("Service")
+
+ def ex_by_tag(tag):
+ return next(e for e in exs if e.Tag == tag)
+
+ r.uf.Assem = NS(
+ AskViewExplosion=lambda tag: association.get(tag, 0),
+ SetViewExplosion=Mock(side_effect=lambda view, ex: association.__setitem__(view, ex)),
+ UnexplodeComponent=Mock(side_effect=lambda ex, c: ex_by_tag(ex).deltas.pop(c, None)),
+ ExplodeComponent=Mock(
+ side_effect=lambda ex, c, transform: ex_by_tag(ex).deltas.__setitem__(c, transform)
+ ),
+ )
+ r.exref = r.ref(r.ex, "explosion")
+ r.pref, r.lref, r.bref = [r.ref(c, "component") for c in (parent, leaf, base)]
+ r.association = association
+ original_mark, original_undo = r.session.SetUndoMark, r.session.UndoToMark
+ snapshots = {}
+
+ def mark(*args):
+ value = original_mark(*args)
+ snapshots[value] = list(exs), [(e, copy.deepcopy(e.deltas)) for e in exs], dict(association)
+ return value
+
+ def undo(value, *args):
+ original_undo(value, *args)
+ saved, poses, views = snapshots[value]
+ exs[:] = saved
+ for e, delta in poses:
+ e.deltas = delta
+ association.clear()
+ association.update(views)
+
+ r.session.SetUndoMark = mark
+ r.session.UndoToMark = undo
+ return r
+
+
+IDENTITY4 = [IDENTITY[i] + [0] for i in range(3)] + [[0, 0, 0, 1]]
+
+
+def test_native_local_transform_converted_to_absolute_nested_pose(explosions):
+ r = explosions
+ placements = [
+ {
+ "component": r.lref,
+ "translation": [70, 60, 50],
+ "rotation_matrix": [[-1, 0, 0], [0, -1, 0], [0, 0, 1]],
+ },
+ {"component": r.pref, "translation": [50, 20, 30]},
+ ]
+ result = r.e.execute(
+ "nx_edit_explosion",
+ {"explosion": r.exref, "placements": placements, "operation_id": "layout-01"},
+ )
+ assert result["assembled_placements_unchanged"]
+ assert result["affected_component_count"] == 2
+ actual = {c["occurrence_path"][-1]: c for c in result["components"]}
+ assert actual["leaf"]["translation"] == [70, 60, 50]
+ assert actual["parent"]["translation"] == [50, 20, 30]
+ assert xyz(r.parent.position) == [10, 20, 30]
+ assert r.ex.deltas[r.parent.Tag][:3] == [[1, 0, 0, 0], [0, 1, 0, -40], [0, 0, 1, 0]]
+ calls = r.uf.Assem.ExplodeComponent.call_count
+ assert r.e.execute(
+ "nx_edit_explosion",
+ {"explosion": r.exref, "placements": placements, "operation_id": "layout-01"},
+ )["replayed"]
+ assert r.uf.Assem.ExplodeComponent.call_count == calls
+ assert r.e._edit_explosion(r.exref, placements)["affected_component_count"] == 0
+ reset = r.e._edit_explosion(r.exref, reset_components=[r.lref])
+ assert reset["components"][0]["translation"] == [50, 40, 30]
+
+
+@pytest.mark.parametrize(
+ "payload",
+ [
+ {},
+ {"placements": []},
+ {"placements": True},
+ {"reset_components": "bad"},
+ {"placements": [{}]},
+ {"placements": [{"component": "p", "translation": [1, 2, 3], "ignored": 1}]},
+ {"placements": [{"component": "p", "translation": [1, 2]}]},
+ {"placements": [{"component": "p", "translation": [1, 2, float("inf")]}]},
+ {
+ "placements": [
+ {
+ "component": "p",
+ "translation": [1, 2, 3],
+ "rotation_matrix": [[1, 0, 0], [0, 1, 0], [0, 0, -1]],
+ }
+ ]
+ },
+ {
+ "placements": [
+ {"component": "p", "translation": [1, 2, 3]},
+ {"component": "p", "translation": [2, 3, 4]},
+ ]
+ },
+ {"placements": [{"component": "p", "translation": [1, 2, 3]}], "reset_components": ["p"]},
+ {"reset_components": [False]},
+ {"reset_components": ["missing"]},
+ {"reset_components": ["p"] * 1001},
+ ],
+)
+def test_preflight_rejects_all_invalid_items_before_native_mutation(explosions, payload):
+ r = explosions
+
+ def substitute(v):
+ if isinstance(v, list):
+ return [substitute(x) for x in v]
+ if isinstance(v, dict):
+ return {k: substitute(x) for k, x in v.items()}
+ return r.pref if v == "p" else v
+
+ with pytest.raises(NXToolError):
+ r.e._edit_explosion(r.exref, **substitute(payload))
+ r.uf.Assem.UnexplodeComponent.assert_not_called()
+ r.uf.Assem.ExplodeComponent.assert_not_called()
+
+
+def test_native_partial_failure_rolls_back_all_placements(explosions):
+ r = explosions
+ apply = r.uf.Assem.ExplodeComponent.side_effect
+
+ def fail(ex, component, matrix):
+ if component == r.leaf.Tag:
+ raise RuntimeError("native failure")
+ apply(ex, component, matrix)
+
+ r.uf.Assem.ExplodeComponent.side_effect = fail
+ with pytest.raises(NXToolError) as error:
+ r.e.execute(
+ "nx_edit_explosion",
+ {
+ "explosion": r.exref,
+ "placements": [
+ {"component": r.pref, "translation": [50, 20, 30]},
+ {"component": r.lref, "translation": [70, 60, 50]},
+ ],
+ },
+ )
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ assert r.ex.deltas == {}
+
+
+def test_native_readback_mismatch_is_not_reported_as_success(explosions):
+ r = explosions
+ r.uf.Assem.ExplodeComponent.side_effect = None
+ with pytest.raises(NXToolError, match="differs"):
+ r.e._edit_explosion(r.exref, [{"component": r.pref, "translation": [100, 20, 30]}])
+
+
+def test_changed_real_assembly_is_not_reported_as_safe(explosions):
+ r = explosions
+ apply = r.uf.Assem.ExplodeComponent.side_effect
+
+ def invalid(ex, component, transform):
+ apply(ex, component, transform)
+ r.base.position = point(100, 0, 0)
+
+ r.uf.Assem.ExplodeComponent.side_effect = invalid
+ with pytest.raises(NXToolError, match="assembled placements"):
+ r.e._edit_explosion(r.exref, [{"component": r.pref, "translation": [50, 20, 30]}])
+
+
+def test_listing_pagination_and_read_only_history(explosions):
+ r = explosions
+ result = r.e.execute("nx_explosion_info", {"explosion": r.exref, "limit": 1})
+ assert result["total"] == 3 and result["next_offset"] == 1
+ assert result["items"][0]["assembled_translation"] == [10, 20, 30]
+ assert r.e._history == []
+ assert r.e._list_explosions()["explosions"][0]["name"] == "Service"
+ with pytest.raises(NXToolError):
+ r.e._explosion_info(r.exref, limit=201)
+
+
+@pytest.mark.parametrize(
+ "name", ["", " ", " Service", "A/Other", "A\\Other", "A\nB", "a" * 133, "service"]
+)
+def test_invalid_or_duplicate_names_do_not_create(explosions, name):
+ r = explosions
+ with pytest.raises(NXToolError):
+ r.e._create_explosion(name)
+ r.part.ComponentAssembly.Explosions.Create.assert_not_called()
+
+
+def test_create_and_delete_track_typed_reference_lifecycle(explosions):
+ r = explosions
+ result = r.e.execute("nx_create_explosion", {"name": "Maintenance"})
+ ref = result["object"]["id"]
+ assert result["component_count"] == 3
+ assert any(x["kind"] == "explosion" for x in result["changes"]["created"])
+ deleted = r.e.execute("nx_delete_explosion", {"explosion": ref})
+ assert deleted["deleted"][0]["id"] == ref
+ with pytest.raises(NXToolError):
+ r.e._explosion(ref)
+
+
+def test_show_hide_and_guarded_deletion(explosions):
+ r = explosions
+ shown = r.e._show_explosion(r.exref)
+ assert shown["view_kind"] == "modeling"
+ with pytest.raises(NXToolError, match="Detach"):
+ r.e._delete_explosion(r.exref)
+ r.e._show_explosion()
+ r.e._delete_explosion(r.exref)
+ assert not r.part.ComponentAssembly.Explosions
+
+
+def test_saved_model_and_drawing_view_targets(explosions):
+ r = explosions
+ drawing = Object("Assembly view")
+ drawing.OwningPart = r.part
+ r.part.DraftingViews.append(drawing)
+ ref = r.ref(drawing, "drawing_view")
+ r.e._show_explosion(r.exref, drawing_view=ref)
+ r.part.DraftingViews.UpdateViews.assert_called_with([drawing])
+ r.e._edit_explosion(r.exref, [{"component": r.pref, "translation": [50, 20, 30]}])
+ assert r.e._explosion_info(r.exref)["views"][0]["object"]["id"] == ref
+ saved = r.ref(r.part.ModelingViews.WorkView, "modeling_view")
+ r.e._show_explosion(r.exref, model_view=saved)
+ with pytest.raises(NXToolError):
+ r.e._show_explosion(r.exref, ref, saved)
+ r.uf.Assem.SetViewExplosion.side_effect = None
+ with pytest.raises(NXToolError, match="did not accept"):
+ r.e._show_explosion(None, model_view=saved)
+
+
+@pytest.mark.parametrize("case", ["sketch", "display", "owner", "suppressed", "foreign"])
+def test_context_and_occurrence_ownership_guards(explosions, case):
+ r = explosions
+ if case == "sketch":
+ r.session.ActiveSketch = object()
+ elif case == "display":
+ r.session.Parts.Display = object()
+ elif case == "owner":
+ r.ex.OwningPart = object()
+ elif case == "suppressed":
+ r.parent.IsSuppressed = True
+ else:
+ r.lref = r.ref(Component("foreign"), "component")
+ with pytest.raises(NXToolError):
+ r.e._edit_explosion(r.exref, [{"component": r.lref, "translation": [0, 0, 0]}])
+ r.uf.Assem.UnexplodeComponent.assert_not_called()
+
+
+def test_empty_assembly_explosion_rejected(explosions):
+ r = explosions
+ r.part.ComponentAssembly.RootComponent.children.clear()
+ with pytest.raises(NXToolError, match="assembly part"):
+ r.e._create_explosion("Empty")
+
+
+@pytest.fixture
+def drawing_save(rig):
+ r = rig
+ sheet = Object("Sheet")
+ r.part.DrawingSheets = Collection([sheet])
+ r.part.DrawingSheets.CurrentDrawingSheet = None
+ r.part.SaveOptions = NS(DrawingCgmData=True)
+
+ def show():
+ r.part.DrawingSheets.CurrentDrawingSheet = sheet
+
+ def exit_drawing():
+ r.part.DrawingSheets.CurrentDrawingSheet = None
+
+ sheet.Open = Mock(side_effect=show)
+ r.part.Drafting = NS(ExitDraftingApplication=Mock(side_effect=exit_drawing))
+ r.sheet = sheet
+ return r
+
+
+def test_save_preserves_cgm_and_restores_modeling_without_dirtying(drawing_save):
+ r = drawing_save
+ original = r.part.Save
+
+ def save(*args):
+ assert r.part.DrawingSheets.CurrentDrawingSheet is r.sheet
+ assert r.part.SaveOptions.DrawingCgmData is True
+ return original(*args)
+
+ r.part.Save = save
+ r.e._save_part()
+ assert r.part.DrawingSheets.CurrentDrawingSheet is None
+ assert not r.part.IsModified
+ r.part.Drafting.ExitDraftingApplication.assert_called_once()
+
+
+def test_save_restores_presentation_on_save_error(drawing_save):
+ r = drawing_save
+ r.part.Save = Mock(side_effect=RuntimeError("disk error"))
+ with pytest.raises(NXToolError) as error:
+ r.e._save_part()
+ assert error.value.details["native_save_errors"] == [{"message": "disk error"}]
+ assert r.part.DrawingSheets.CurrentDrawingSheet is None
+
+
+def test_save_preserves_existing_active_drawing(drawing_save):
+ r = drawing_save
+ r.part.DrawingSheets.CurrentDrawingSheet = r.sheet
+ r.e._save_part()
+ assert r.part.DrawingSheets.CurrentDrawingSheet is r.sheet
+ r.part.Drafting.ExitDraftingApplication.assert_not_called()
+
+
+def test_save_does_not_change_disabled_cgm_preference(drawing_save):
+ r = drawing_save
+ r.part.SaveOptions.DrawingCgmData = False
+ r.e._save_part()
+ r.sheet.Open.assert_not_called()
+ assert r.part.SaveOptions.DrawingCgmData is False
+
+
+def test_save_restore_failure_is_explicitly_partial(drawing_save):
+ r = drawing_save
+ r.part.Drafting.ExitDraftingApplication.side_effect = RuntimeError("restore failure")
+ with pytest.raises(NXToolError) as error:
+ r.e.execute("nx_save_part", {})
+ assert error.value.code == "NX_SAVE_VIEW_RESTORE_FAILED"
+ assert error.value.details["mutation_outcome"] == "partial"
+
+
+def test_drawing_save_active_sketch_rejected_before_open(drawing_save):
+ r = drawing_save
+ r.session.ActiveSketch = object()
+ with pytest.raises(NXToolError):
+ r.e._save_part()
+ r.sheet.Open.assert_not_called()
+
+
+def test_save_as_and_close_use_drawing_preview_context(drawing_save, tmp_path):
+ r = drawing_save
+ r.e._save_as(str(tmp_path / "copy.prt"))
+ assert r.part.DrawingSheets.CurrentDrawingSheet is None
+ r.e._close_part(save=True)
+ assert r.sheet.Open.call_count == 2
+ assert r.part not in r.session.Parts
+
+
+def test_save_hidden_part_restores_original_work_and_display(drawing_save, monkeypatch):
+ r = drawing_save
+ other = Object("Original")
+ other.FullPath = "original.prt"
+ r.session.Parts.Work = r.session.Parts.Display = other
+
+ def activate(ref, work, display):
+ obj = r.e.objects.resolve(ref, expected_kind="part")
+ if work:
+ r.session.Parts.Work = obj
+ if display:
+ r.session.Parts.Display = obj
+
+ monkeypatch.setattr(r.e, "_activate_part", activate)
+ with r.e._drawing_save_context(r.part):
+ assert r.session.Parts.Work is r.part and r.session.Parts.Display is r.part
+ assert r.session.Parts.Work is other and r.session.Parts.Display is other
+
+
+def test_modified_component_drawing_saved_once_before_parent(drawing_save):
+ r = drawing_save
+ prototype = r.part
+ prototype.IsModified = True
+ parent = NS()
+ r.e._walk_components = lambda p: [
+ (NS(Prototype=prototype), ["a"]),
+ (NS(Prototype=prototype), ["b"]),
+ ]
+ save = Mock(wraps=prototype.Save)
+ prototype.Save = save
+ r.e._save_component_drawing_previews(parent)
+ save.assert_called_once()
+ assert not prototype.IsModified
+
+
+@pytest.fixture
+def assembly_drawing(explosions):
+ r = explosions
+ sheet = Object("Sheet")
+ sheet.OwningPart = r.part
+ sheet.GetScale = lambda: (2.0, 1.0)
+ sheet.Open = Mock()
+ r.part.DrawingSheets.append(sheet)
+ r.sheetref = r.ref(sheet, "drawing_sheet")
+ r.builder = NS(
+ SelectModelView=NS(),
+ Placement=NS(Placement=NS(SetValue=Mock())),
+ Destroy=Mock(),
+ Scale=NS(Type=NS(Ratio=1)),
+ Style=NS(ViewStyleHiddenLines=NS(), ViewStyleVisibleLines=NS()),
+ )
+
+ def commit():
+ view = Object("Base")
+ view.SetAttribute = Mock()
+ view.OwningPart = r.part
+ r.part.DraftingViews.append(view)
+ return view
+
+ r.builder.Commit = Mock(side_effect=commit)
+ r.part.DraftingViews.CreateBaseViewBuilder = lambda _: r.builder
+ return r
+
+
+def test_assembly_drawing_explosion_is_explicit_and_verified(assembly_drawing):
+ r = assembly_drawing
+ result = r.e._add_base_view(r.sheetref, scope="assembly", explosion=r.exref, position=[80, 100])
+ view = r.e._resolve(result["object"]["id"], {"drawing_view"})
+ assert r.association[view.Tag] == r.ex.Tag
+ r.part.DraftingViews.UpdateViews.assert_called_with([view])
+ r.builder.Destroy.assert_called_once()
+ assert result["scope"] == "assembly"
+ assert result["explosion"]["id"] == r.exref
+
+
+@pytest.mark.parametrize(
+ "kw",
+ [
+ {},
+ {"scope": "invalid"},
+ {"scope": "assembly", "body": "body"},
+ {"scope": "body", "body": "body", "explosion": "ex"},
+ {"scope": "assembly", "view": "unknown"},
+ {"scope": "assembly", "position": [1]},
+ ],
+)
+def test_drawing_scope_validation_precedes_builder(assembly_drawing, kw):
+ r = assembly_drawing
+ with pytest.raises(NXToolError):
+ r.e._add_base_view(r.sheetref, **kw)
+ r.builder.Commit.assert_not_called()
+
+
+def test_drawing_builder_destroyed_on_failure(assembly_drawing):
+ r = assembly_drawing
+ r.builder.Commit.side_effect = RuntimeError("native view failure")
+ with pytest.raises(RuntimeError):
+ r.e._add_base_view(r.sheetref, scope="assembly")
+ r.builder.Destroy.assert_called_once()
+
+
+def test_projected_view_inherits_explosion(assembly_drawing, monkeypatch):
+ from nx_mcp.engineering import EngineeringMixin
+
+ r = assembly_drawing
+ parent = r.e._add_base_view(r.sheetref, scope="assembly", explosion=r.exref)["object"]["id"]
+ child = Object("Projected")
+ child.OwningPart = r.part
+ r.part.DraftingViews.append(child)
+ child_ref = r.ref(child, "drawing_view")
+ monkeypatch.setattr(
+ EngineeringMixin, "_add_projection_view", lambda *args: {"object": {"id": child_ref}}
+ )
+ result = r.e._add_projection_view(parent, "right")
+ assert result["exploded"] and r.association[child.Tag] == r.ex.Tag
+ r.part.DraftingViews.UpdateViews.assert_called_with([child])
+
+
+@pytest.mark.parametrize(
+ "extra",
+ [
+ {"translation": [True, 0, 0]},
+ {"translation": ["1", 0, 0]},
+ {"rotation_matrix": None},
+ {"rotation_matrix": [1, 2, 3]},
+ {"rotation_matrix": [[True, 0, 0], [0, 1, 0], [0, 0, 1]]},
+ ],
+)
+def test_strict_pose_types(explosions, extra):
+ r = explosions
+ ex = r.e._reference(r.ex, "explosion", r.part, "Explosion")["id"]
+ component = r.e._reference(r.base, "component", r.part, "Component")["id"]
+ with pytest.raises(NXToolError, match="numeric"):
+ r.e._edit_explosion(ex, [{"component": component, "translation": [1, 2, 3], **extra}])
+ assert not r.ex.deltas
+
+
+def test_forced_drawing_display_restores_sheet_on_failure(drawing_save):
+ r = drawing_save
+ r.part.SaveOptions.DrawingCgmData = False
+ original = r.sheet
+ r.part.DrawingSheets.CurrentDrawingSheet = original
+ with (
+ pytest.raises(RuntimeError, match="plot failed"),
+ r.e._drawing_save_context(r.part, force_display=True),
+ ):
+ r.part.DrawingSheets.CurrentDrawingSheet = Object("Other sheet")
+ raise RuntimeError("plot failed")
+ assert r.part.DrawingSheets.CurrentDrawingSheet is original
+
+
+def test_forced_drawing_display_restores_modeling_view(drawing_save):
+ r = drawing_save
+ r.part.SaveOptions.DrawingCgmData = False
+ with r.e._drawing_save_context(r.part, force_display=True):
+ assert r.part.DrawingSheets.CurrentDrawingSheet is r.sheet
+ assert r.part.DrawingSheets.CurrentDrawingSheet is None
diff --git a/tests/test_freeform_manufacturing.py b/tests/test_freeform_manufacturing.py
new file mode 100644
index 0000000..5b80293
--- /dev/null
+++ b/tests/test_freeform_manufacturing.py
@@ -0,0 +1,469 @@
+"""Boundary/cleanup regressions; native geometry evidence is kept separately."""
+
+import inspect
+import math
+import sys
+from types import SimpleNamespace as NS
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+from nx_mcp import assembly_documentation_server, freeform_server, manufacturing_server
+from nx_mcp.animation import interpolate_rotation
+from nx_mcp.freeform import points3
+from nx_mcp.hardened import IDENTITY, READ_ONLY
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Face, Feature, Object, point
+
+
+@pytest.fixture
+def ff(rig, monkeypatch):
+ r = rig
+ r.body = Body()
+ r.body.IsOccurrence = False
+ r.body.OwningPart = r.part
+ r.part.Bodies.append(r.body)
+ r.feature = Feature(bodies=[r.body])
+ r.feature.FeatureType = "STUDIO_SPLINE"
+ r.feature.OwningPart = r.part
+ r.feature.IsOccurrence = False
+ r.part.Features.append(r.feature)
+ r.body.faces.append(Face("other"))
+ r.faces = list(r.body.GetFaces())
+ for obj in [*r.faces, *r.body.GetEdges()]:
+ obj.IsOccurrence = False
+ obj.OwningPart = r.part
+ obj.GetBody = lambda: r.body
+ r.nx.SmartObject = NS(UpdateOption=NS(WithinModeling=1))
+ r.nx.Point3d = lambda *coords: point(*coords)
+ r.part.Points = NS(CreatePoint=Mock(return_value=Object()))
+ r.part.ScRuleFactory = MagicMock()
+ r.part.Sections = MagicMock()
+ r.nx.Section = NS(AllowTypes=NS(OnlyCurves=1), Mode=NS(Create=1))
+ f = NS(
+ StudioSplineBuilderEx=NS(Types=NS(ThroughPoints=1, ByPoles=2)),
+ ThroughCurveMeshBuilder=NS(BodyPreferenceTypes=NS(Sheet=1)),
+ BridgeSurfaceBuilder=NS(EndObjectType=NS(Edge=1)),
+ SewBuilder=NS(Types=NS(Sheet=1), BodyPreferenceTypes=NS(Solid=2, Sheet=1)),
+ DeleteFaceBuilder=NS(SelectTypes=NS(Face=1)),
+ TrimSheetBuilder=NS(KeepDiscardOption=NS(Keep=1, Discard=2)),
+ ThreadBuilder=NS(
+ Input=NS(Manual=1, ThreadTable=2),
+ Type=NS(Detailed=2, Symbolic=1),
+ LimitOption=NS(Value=1),
+ Handedness=NS(LeftHand=1, RightHand=2),
+ ),
+ )
+ g = NS(
+ Continuity=NS(ContinuityTypes=NS(G0=0, G1=1, G2=2)), ModlMotion=NS(Options=NS(Distance=1))
+ )
+ monkeypatch.setitem(sys.modules, "NXOpen.Features", f)
+ monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", g)
+ r.nx.Features = f
+ r.nx.GeometricUtilities = g
+ r.uf = MagicMock()
+ r.uf.Modeling.AskFaceData.return_value = [16, None, None, None, 1.0]
+ u = NS(UFSession=NS(GetUFSession=lambda: r.uf))
+ monkeypatch.setitem(sys.modules, "NXOpen.UF", u)
+ r.nx.UF = u
+ r.b = MagicMock()
+ r.b.Validate.return_value = True
+ r.b.CommitFeature.return_value = r.feature
+ r.b.Curve = Object()
+ r.b.Curve.Order = 4
+ r.b.Curve.Periodic = False
+ r.b.GetUnsewnBodies.return_value = []
+ r.e._freeform_builder = Mock(return_value=r.b)
+ r.e._engineering_direction = Mock(return_value=Object())
+ r.e._update_model = Mock()
+ r.e._model_health = Mock(return_value={"healthy": True, "issues": []})
+ r.ref = lambda o, kind: r.e._reference(o, kind, r.part, kind)["id"]
+ return r
+
+
+def test_new_contracts_match_native_handlers(rig):
+ for module in [freeform_server, manufacturing_server, assembly_documentation_server]:
+ for name, function in vars(module).items():
+ if name.startswith("nx_") and inspect.isfunction(function):
+ assert name in rig.e._handlers
+ assert (
+ inspect.signature(function).parameters.keys()
+ == inspect.signature(rig.e._handlers[name]).parameters.keys()
+ )
+ assert module.READ_ONLY <= READ_ONLY
+
+
+@pytest.mark.parametrize("value", [[], [[0, 1]], [[0, 0, math.inf]], [[True, 0, 0]], "points"])
+def test_invalid_point_sets(value):
+ with pytest.raises(NXToolError):
+ points3(value)
+
+
+@pytest.mark.parametrize("axis", range(3))
+def test_rotation_interpolation_stays_orthonormal_at_half_turn(axis):
+ end = [[float(i == j) * (1 if i == axis else -1) for j in range(3)] for i in range(3)]
+ for fraction in [0, 0.25, 0.5, 0.75, 1]:
+ matrix = interpolate_rotation(IDENTITY, end, fraction)
+ for i in range(3):
+ for j in range(3):
+ assert sum(matrix[i][k] * matrix[j][k] for k in range(3)) == pytest.approx(
+ float(i == j)
+ )
+ assert interpolate_rotation(IDENTITY, IDENTITY, 0.5) == IDENTITY
+ for row, expected in zip(interpolate_rotation(IDENTITY, end, 1), end, strict=True):
+ assert row == pytest.approx(expected)
+
+
+@pytest.mark.parametrize("method", ["through_points", "poles"])
+def test_spline_edit_replaces_constraints_and_returns_curve(ff, method):
+ result = ff.e._spline(
+ [[0, 0, 0], [1, 0, 1], [2, 1, 2], [3, 0, 4]],
+ method=method,
+ feature=ff.ref(ff.feature, "feature"),
+ )
+ assert result["curve"]["kind"] == "curve"
+ ff.b.ConstraintManager.Clear.assert_called_once()
+ assert ff.b.ConstraintManager.Append.call_count == 4
+ assert ff.b.HasPlaneConstraint is False
+ ff.b.Destroy.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "kwargs", [{"degree": True}, {"degree": 8}, {"degree": 4}, {"method": "unknown"}]
+)
+def test_spline_rejects_invalid_parameters_before_builder(ff, kwargs):
+ with pytest.raises(NXToolError):
+ ff.e._spline([[0, 0, 0], [1, 0, 1], [2, 1, 2], [3, 0, 4]], **kwargs)
+ ff.e._freeform_builder.assert_not_called()
+
+
+def test_builder_rejection_always_destroys_and_does_not_commit(ff):
+ ff.b.Validate.return_value = False
+ with pytest.raises(NXToolError):
+ ff.e._thicken([ff.ref(ff.faces[0], "face")], 2)
+ ff.b.CommitFeature.assert_not_called()
+ ff.b.Destroy.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "action,kwargs",
+ [
+ ("move", {"distance": 2, "direction": [0, 0, 4]}),
+ ("offset", {"distance": -2}),
+ ("replace", {}),
+ ("heal", {}),
+ ],
+)
+def test_native_face_edit_configuration_and_cleanup(ff, action, kwargs):
+ if action == "replace":
+ kwargs["replacement"] = ff.ref(ff.faces[1], "face")
+ result = ff.e._edit_faces([ff.ref(ff.faces[0], "face")], action, **kwargs)
+ assert result["body_count"] == 1
+ ff.b.Destroy.assert_called_once()
+ if action == "move":
+ assert ff.b.Motion.DistanceValue.RightHandSide == "2.0"
+ elif action == "offset":
+ assert ff.b.Distance.RightHandSide == "2.0" and ff.b.Direction is True
+ elif action == "heal":
+ assert ff.b.Heal is True and ff.b.AllowPartialDelete is False
+
+
+@pytest.mark.parametrize(
+ "action,kwargs",
+ [
+ ("move", {}),
+ ("heal", {"distance": 2}),
+ ("replace", {"direction": [0, 0, 1]}),
+ ("unsupported", {}),
+ ],
+)
+def test_ignored_face_edit_arguments_are_rejected(ff, action, kwargs):
+ with pytest.raises(NXToolError):
+ ff.e._edit_faces([ff.ref(ff.faces[0], "face")], action, **kwargs)
+ ff.e._freeform_builder.assert_not_called()
+
+
+def test_thicken_uses_explicit_native_tolerance(ff):
+ ff.e._thicken([ff.ref(ff.faces[0], "face")], 2, -1)
+ assert ff.b.Tolerance == 0.001
+ assert ff.b.FirstOffset.RightHandSide == "2.0"
+ assert ff.b.SecondOffset.RightHandSide == "-1.0"
+ with pytest.raises(NXToolError):
+ ff.e._thicken([ff.ref(ff.faces[0], "face")], 2, 2)
+
+
+@pytest.mark.parametrize("detailed,left", [(False, False), (True, True)])
+def test_thread_uses_actual_cylinder_size_and_explicit_start(ff, detailed, left):
+ result = ff.e._thread(
+ ff.ref(ff.faces[0], "face"),
+ ff.ref(ff.faces[1], "face"),
+ 0.4,
+ 2.4,
+ 1.9,
+ 4,
+ detailed=detailed,
+ left_hand=left,
+ )
+ assert result["representation"] == ("detailed" if detailed else "symbolic")
+ assert ff.b.TapDrillDiameterExp.RightHandSide == "2.0"
+ assert ff.b.CylindricalFace.Value is ff.faces[0]
+ assert ff.b.StartObject.Value is ff.faces[1]
+ ff.b.Destroy.assert_called_once()
+
+
+def test_sampled_thickness_retains_unresolved_samples(ff):
+ source = ff.faces[0]
+ data = ([0.0, 0.0, 5.0], None, None, None, None, [0.0, 0.0, 1.0], [1e30, 1e30])
+ ff.e._face_samples = Mock(return_value=([(source, [0, 0], data)] * 2, 1))
+ ff.uf.Modeling.AskPointContainment.side_effect = [1, 2]
+ ff.uf.Modeling.TraceARay.return_value = (1, [NS(HitPoint=[0.0, 0.0, 0.0], HitFace=source.Tag)])
+ result = ff.e._wall_thickness(ff.ref(ff.body, "body"))
+ assert result["minimum_sampled_thickness"] == 5
+ assert result["measured_count"] == 1
+ assert result["sample_count"] == 2
+ assert result["samples"][1]["status"] == "unresolved"
+ assert result["skipped_outside_trim"] == 1
+
+
+def test_face_sampling_reports_signed_draft_and_ignores_trimmed_outside(ff):
+ ff.uf.Modeling.AskFaceUvMinmax.return_value = [0.0, 1.0, 0.0, 1.0]
+ ff.uf.Modeling.AskFaceProps.return_value = (
+ [0.0, 0.0, 0.0],
+ None,
+ None,
+ None,
+ None,
+ [0.0, 0.0, -1.0],
+ [1e30, 2.0],
+ )
+ ff.uf.Modeling.AskPointContainment.side_effect = [1, 2, 1, 1]
+ result = ff.e._face_analysis(
+ [ff.ref(ff.faces[0], "face")], samples_per_axis=2, pull_direction=[0, 0, 1]
+ )
+ assert result["sample_count"] == 3 and result["skipped_outside_trim"] == 1
+ assert result["samples"][0]["signed_draft_degrees"] == -90
+ assert result["samples"][0]["principal_curvatures"] == [0, 0.5]
+
+
+def test_shape_operator_is_parameterization_invariant():
+ from nx_mcp.surface_math import continuity_difference, shape_operator
+
+ plane = shape_operator([1, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0])
+ reversed_plane = shape_operator([0, 3, 0], [2, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0])
+ assert continuity_difference(plane, reversed_plane) == (0, 0)
+ cylinder = shape_operator([0, 2, 0], [0, 0, 1], [-2, 0, 0], [0, 0, 0], [0, 0, 0])
+ scaled = shape_operator([0, 4, 0], [0, 0, 3], [-8, 0, 0], [0, 0, 0], [0, 0, 0])
+ assert continuity_difference(cylinder, scaled) == pytest.approx((0, 0))
+ assert abs(cylinder[1][1][1]) == 0.5
+ with pytest.raises(NXToolError):
+ shape_operator([0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0])
+
+
+def test_sew_rejects_partial_or_sheet_fallback(ff):
+ other = Body("other")
+ other.OwningPart = ff.part
+ ff.part.Bodies.append(other)
+ target, tool = ff.ref(ff.body, "body"), ff.ref(other, "body")
+ assert ff.e._sew(target, [tool])["body_count"] == 1
+ ff.b.GetUnsewnBodies.return_value = [other]
+ with pytest.raises(NXToolError, match="could not be sewn"):
+ ff.e._sew(target, [tool])
+ ff.b.GetUnsewnBodies.return_value = []
+ ff.body.IsSolidBody = False
+ with pytest.raises(NXToolError, match="instead of"):
+ ff.e._sew(target, [tool], solid=True)
+ with pytest.raises(NXToolError, match="also"):
+ ff.e._sew(target, [target])
+
+
+def test_surface_sections_and_trim_use_native_section_objects(ff):
+ curve = Object("curve")
+ curve.IsOccurrence = False
+ curve.OwningPart = ff.part
+ ref = ff.ref(curve, "curve")
+ ff.e._surface_mesh([[ref], [ref]], [[ref], [ref]])
+ assert ff.b.PrimaryCurvesList.Append.call_count == 2
+ assert ff.b.CrossCurvesList.Append.call_count == 2
+ with pytest.raises(NXToolError):
+ ff.e._surface_mesh([[ref]], [[ref], [ref]])
+ ff.body.IsSolidBody = False
+ ff.part.CreateRegionPoint = Mock(return_value=Object())
+ ff.e._trim_sheet(ff.ref(ff.body, "body"), [ref], [0, 0, 0], keep=False)
+ ff.b.BoundaryObjects.Add.assert_called_with(ff.part.Sections.CreateSection.return_value)
+ ff.b.Regions.Append.assert_called_once()
+
+
+def test_bridge_explicit_continuity_and_reverse(ff):
+ edge = ff.body.GetEdges()[0]
+ result = ff.e._bridge_surface(
+ ff.ref(edge, "edge"), ff.ref(edge, "edge"), continuity="G2", reverse_second=True
+ )
+ assert result["body_count"] == 1
+ assert ff.b.FirstEdgeContinuity.ContinuityType == 2
+ assert ff.b.IsSecondEdgeReversed is True
+ with pytest.raises(NXToolError):
+ ff.e._bridge_surface("x", "y", continuity="C9")
+
+
+def test_curve_analysis_keeps_singular_samples(ff):
+ edge = ff.body.GetEdges()[0]
+ ff.nx.Edge = type(edge)
+ ff.uf.ModlGeneral.EvaluateCurve.side_effect = [
+ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 2.0, 0.0],
+ ]
+ result = ff.e._curve_analysis(ff.ref(edge, "curve"), samples=2)
+ assert result["samples"][0]["singular"]
+ assert result["samples"][1]["curvature"] == 2
+ assert result["samples"][1]["radius"] == 0.5
+ with pytest.raises(NXToolError):
+ ff.e._curve_analysis("x", samples=1)
+
+
+def test_pmi_association_and_existing_edit(ff, monkeypatch):
+ datum = Object("datum")
+ datum.OwningPart = ff.part
+ datum.IsOccurrence = False
+ fcf = Object("fcf")
+ fcf.OwningPart = ff.part
+ fcf.IsOccurrence = False
+ ff.part.Annotations = MagicMock()
+ db, fb = MagicMock(), MagicMock()
+ db.Letter = "A"
+ db.Commit.return_value = datum
+ fb.Commit.return_value = fcf
+ ff.part.Annotations.Datums.CreatePmiDatumFeatureSymbolBuilder.return_value = db
+ ff.part.Annotations.CreatePmiFeatureControlFrameBuilder.return_value = fb
+ a = NS(
+ FeatureControlFrameBuilder=NS(
+ FcfCharacteristic=NS(Parallelism=1, Flatness=2, Position=3),
+ FcfFrameStyle=NS(SingleFrame=1),
+ ),
+ FeatureControlFrameDataBuilder=NS(
+ ToleranceMaterialModifier=NS(
+ NotSet=0,
+ MaximumMaterialCondition=1,
+ LeastMaterialCondition=2,
+ RegardlessOfFeatureSize=3,
+ ),
+ ToleranceZoneShape=NS(NotSet=0, Diameter=1, SphericalDiameter=2, Square=3),
+ ),
+ DatumReferenceBuilder=NS(
+ DatumReferenceMaterialCondition=NS(
+ NotSet=0,
+ MaximumMaterialCondition=1,
+ LeastMaterialCondition=2,
+ RegardlessOfFeatureSize=3,
+ )
+ ),
+ )
+ monkeypatch.setitem(sys.modules, "NXOpen.Annotations", a)
+ ff.nx.Annotations = a
+ face = ff.ref(ff.faces[0], "face")
+ result = ff.e._pmi_datum([face], "A", [10, 10, 0])
+ assert result["geometry_associated"]
+ db.AssociatedObjects.Nxobjects.Add.assert_called_with([ff.faces[0]])
+ fc = ff.e._pmi_fcf([face], "Parallelism", 0.1, [10, 10, 0], datums=[result["annotation"]["id"]])
+ assert fc["datum_letters"] == ["A"]
+ assert fb.FeatureControlFrameDataList.FindItem.return_value.ToleranceValue == "0.1"
+ updated = ff.e._pmi_fcf([face], "Flatness", 0.2, [10, 10, 0], annotation=fc["annotation"]["id"])
+ assert updated["created"] == [] and len(updated["modified"]) == 1
+ with pytest.raises(NXToolError):
+ ff.e._pmi_fcf([face], "Flatness", 0.1, [0, 0, 0], datums=[result["annotation"]["id"]])
+ with pytest.raises(NXToolError):
+ ff.e._pmi_datum([face], "a", [0, 0, 0])
+ db.Validate.return_value = False
+ with pytest.raises(NXToolError):
+ ff.e._pmi_datum([face], "B", [0, 0, 0])
+
+
+def test_animation_renders_absolute_poses_and_restores_after_failure(ff, tmp_path):
+ from pathlib import Path
+
+ ff.e._explosion_context = lambda: ff.part
+ ff.e._explosion = lambda _: NS(Tag=99)
+ ff.e._exploded_tree = lambda _: {1: (None, None, None, False)}
+ ff.e._exploded_record = lambda _: {
+ "component": {"id": "component"},
+ "assembled_translation": [0, 0, 0],
+ "translation": [20, 10, 0],
+ "assembled_rotation_matrix": IDENTITY,
+ "rotation_matrix": IDENTITY,
+ }
+ ff.e._explosion_uf = lambda: ff.uf.Assem
+ ff.uf.Assem.AskViewExplosion.return_value = 0
+ ff.session.IsBatch = False
+ ff.part.DrawingSheets = NS(CurrentDrawingSheet=None)
+ ff.part.ModelingViews = NS(WorkView=NS(Tag=2, UpdateDisplay=Mock()))
+ ff.session.UndoToMark = Mock()
+ ff.session.DeleteUndoMark = Mock()
+ ff.e._edit_explosion = Mock()
+
+ def render(path, **_):
+ Path(path).write_bytes(b"native png fixture")
+ return {"camera": {"scale": 1}}
+
+ ff.e._render_view = Mock(side_effect=render)
+ destination = ff.e.workspace.root / "animation.html"
+ result = ff.e._export_explosion_animation("ex", str(destination), frames=3)
+ assert result["model_restored"] and result["frame_count"] == 3
+ assert "data:image/png;base64," in destination.read_text()
+ placements = ff.e._edit_explosion.call_args_list
+ assert placements[1].kwargs["placements"][0]["translation"] == [10, 5, 0]
+ ff.session.UndoToMark.assert_called_once()
+ ff.e._render_view.side_effect = RuntimeError("capture failed")
+ failure_path = ff.e.workspace.root / "failed.html"
+ with pytest.raises(RuntimeError, match="capture failed"):
+ ff.e._export_explosion_animation("ex", str(failure_path), frames=2)
+ assert not failure_path.exists()
+ assert not list(ff.e.workspace.root.glob(".nx-animation-*"))
+ assert ff.session.UndoToMark.call_count == 2
+
+
+def test_trace_anchor_maps_assembled_to_exploded_coordinates(ff):
+ source = NS(Coordinates=point(10, 25, 0))
+ ff.part.Points.CreatePoint.return_value = source
+ ff.part.Scalars = NS(CreateScalar=Mock(return_value=Object()))
+ ff.nx.Scalar = NS(DimensionalityType=NS(NotSet=0))
+ ff.nx.PointCollection = NS(PointOnCurveLocationOption=NS(PercentArcLength=1))
+ ff.session.UpdateManager.DoUpdate = Mock(return_value=0)
+ component = NS(
+ Tag=123,
+ JournalIdentifier="component",
+ GetPosition=lambda: (point(10, 20, 0), ff.e._nx_matrix([[0, -1, 0], [1, 0, 0], [0, 0, 1]])),
+ )
+ edge = ff.body.GetEdges()[0]
+ edge.JournalIdentifier = "edge"
+ ff.uf.Tag.AskTagOfHandle.side_effect = lambda name: {
+ "component": 123,
+ "edge": int(edge.Tag),
+ }.get(name, 0)
+ component.Prototype = NS(Bodies=[ff.body])
+ component.FindOccurrence = lambda _: edge
+ exploded = NS(GetPosition=lambda: (point(100, 0, 0), ff.e._nx_matrix(IDENTITY)))
+ ff.e._exploded_tree = lambda _: {1: (exploded, component, ["component"], False)}
+ assert ff.e._trace_position(
+ "ex", {"edge": "edge", "percent": 50, "component": "component"}
+ ) == [105, 0, 0]
+ with pytest.raises(NXToolError, match="missing"):
+ ff.e._trace_position("ex", {"edge": "edge", "percent": 50, "component": "missing"})
+
+
+def test_managed_trace_refresh_handles_collapsed_and_expanded_states(ff):
+ import json
+
+ line = MagicMock()
+ line.AskExplosion.return_value = "ex"
+ line.HasUserAttribute.return_value = True
+ line.GetStringAttribute.return_value = json.dumps([{"point": "first"}, {"point": "second"}])
+ ff.part.Tracelines = [line]
+ ff.nx.NXObject = NS(AttributeType=NS(String=1))
+ ff.session.UpdateManager.DoUpdate = Mock(return_value=0)
+ ff.e._trace_position = Mock(side_effect=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
+ ff.e._refresh_explosion_traces("ex")
+ line.Blank.assert_called_once()
+ line.StartPoint.SetCoordinates.assert_not_called()
+ ff.e._trace_position.side_effect = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]
+ ff.e._refresh_explosion_traces("ex")
+ line.Unblank.assert_called_once()
+ assert line.EndPoint.SetCoordinates.call_args.args[0].X == 10
diff --git a/tests/test_hardening.py b/tests/test_hardening.py
new file mode 100644
index 0000000..5d8ad9a
--- /dev/null
+++ b/tests/test_hardening.py
@@ -0,0 +1,215 @@
+"""Safety and MCP contract regressions; these do not substitute for real NX tests."""
+
+import asyncio
+import base64
+import hashlib
+from threading import Event
+from types import SimpleNamespace
+
+import pytest
+
+from nx_mcp.bridge import BridgeClient, BridgeServer
+from nx_mcp.hardened import HardenedExecutor
+from nx_mcp.integration_server import artifact_call
+from nx_mcp.runtime import NXToolError
+from nx_mcp.server import create_server
+from nx_mcp.workspace import Workspace, WorkspaceViolation
+
+
+class FakeSession:
+ def __init__(self):
+ self.Parts = SimpleNamespace(Work=None)
+ self.values = []
+ self.marks = {}
+ self.next_mark = 0
+
+ def SetUndoMark(self, *args):
+ self.next_mark += 1
+ self.marks[self.next_mark] = list(self.values)
+ return self.next_mark
+
+ def UndoToMark(self, mark, *args):
+ self.values[:] = self.marks[mark]
+
+ def DeleteUndoMark(self, mark, *args):
+ self.marks.pop(mark, None)
+
+ def DoesUndoMarkExist(self, mark, *args):
+ return mark in self.marks
+
+
+@pytest.fixture
+def executor(tmp_path):
+ session = FakeSession()
+ e = HardenedExecutor(
+ session,
+ SimpleNamespace(Session=SimpleNamespace(MarkVisibility=SimpleNamespace(Visible=1))),
+ "test",
+ Workspace(tmp_path),
+ enable_experimental=True,
+ )
+
+ def mutate(value=1, fail=False):
+ session.values.append(value)
+ if fail:
+ raise NXToolError("TEST_FAIL", "failure after a model change")
+ return {"values": list(session.values)}
+
+ e._handlers["nx_test_mutate"] = mutate
+ return e
+
+
+def test_retry_committed_mutation_is_deduplicated(executor):
+ p = {"value": 1, "operation_id": "same-request-123"}
+ a = executor.execute("nx_test_mutate", p)
+ b = executor.execute("nx_test_mutate", p)
+ assert executor.session.values == [1]
+ assert b["replayed"] and a["operation_id"] == b["operation_id"]
+ with pytest.raises(NXToolError, match="different arguments"):
+ executor.execute("nx_test_mutate", dict(p, value=2))
+
+
+def test_failed_partial_mutation_rolls_back_and_cannot_reapply(executor):
+ p = {"value": 4, "fail": True, "operation_id": "failed-request-123"}
+ with pytest.raises(NXToolError) as error:
+ executor.execute("nx_test_mutate", p)
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ assert executor.session.values == []
+ assert executor.store.get(p["operation_id"])["state"] == "failed"
+ with pytest.raises(NXToolError):
+ executor.execute("nx_test_mutate", p)
+ assert executor.session.values == []
+
+
+def test_restarted_pending_receipt_becomes_unknown(executor):
+ store = executor.store
+ store.put(
+ {
+ "operation_id": "crashed-request-123",
+ "session_id": "old",
+ "state": "running",
+ "fingerprint": "a",
+ }
+ )
+ store.recover(executor.session_id)
+ assert store.get("crashed-request-123")["state"] == "unknown"
+ assert store.get("never-seen-request")["mutation_outcome"] == "unknown"
+
+
+@pytest.mark.asyncio
+async def test_transport_timeout_does_not_duplicate_mutation(executor):
+ handler = executor._handlers["nx_test_mutate"]
+ entered, release = Event(), Event()
+
+ def delayed(**params):
+ entered.set()
+ assert release.wait(15), "Test did not release the received mutation"
+ return handler(**params)
+
+ executor._handlers["nx_test_mutate"] = delayed
+ server = BridgeServer(executor.execute, token="test-token")
+ server.start()
+ p = {"value": 9, "operation_id": "lost-response-123"}
+ first = asyncio.create_task(
+ BridgeClient("127.0.0.1", server.port, token="test-token", timeout=10).call(
+ "nx_test_mutate", p
+ )
+ )
+ try:
+ # Start the response deadline only after the request reached the server.
+ # A 20ms connect deadline can expire before receipt on Windows CI.
+ assert await asyncio.to_thread(entered.wait, 5), "Server did not receive first request"
+ with pytest.raises(asyncio.TimeoutError):
+ await asyncio.wait_for(first, timeout=0.02)
+ release.set()
+ result = await BridgeClient("127.0.0.1", server.port, token="test-token", timeout=10).call(
+ "nx_test_mutate", p
+ )
+ assert result["replayed"] and executor.session.values == [9]
+ finally:
+ release.set()
+ first.cancel()
+ await asyncio.gather(first, return_exceptions=True)
+ server.stop()
+
+
+@pytest.mark.parametrize(
+ "path", ["../outside.step", "/tmp/outside.step", ".nx-mcp/operations/x.json"]
+)
+def test_artifacts_reject_outside_or_reserved_paths(tmp_path, path):
+ with pytest.raises((WorkspaceViolation, NXToolError)):
+ artifact_call(
+ "nx_download_file", {"path": path, "offset": 0, "length": 1}, Workspace(tmp_path)
+ )
+
+
+def test_chunk_upload_retry_checksum_download_and_no_overwrite(tmp_path):
+ w = Workspace(tmp_path)
+ data = b"0123456789" * 100
+ digest = hashlib.sha256(data).hexdigest()
+
+ def upload(offset, chunk, sha=digest):
+ return artifact_call(
+ "nx_upload_file",
+ {
+ "path": "vendor/test.step",
+ "offset": offset,
+ "total_size": len(data),
+ "sha256": sha,
+ "data_base64": base64.b64encode(chunk).decode(),
+ },
+ w,
+ )
+
+ assert not upload(0, data[:400])["committed"]
+ assert not upload(0, data[:400])["committed"]
+ assert upload(400, data[400:])["committed"]
+ assert upload(400, data[400:])["replayed"]
+ result = artifact_call(
+ "nx_download_file", {"path": "vendor/test.step", "offset": 0, "length": 262144}, w
+ )
+ assert base64.b64decode(result["data_base64"]) == data
+ assert result["sha256"] == digest and result["eof"]
+ with pytest.raises(NXToolError):
+ upload(0, b"x" * 1000, "0" * 64)
+ assert (tmp_path / "vendor/test.step").read_bytes() == data
+
+
+@pytest.mark.asyncio
+async def test_uniform_structured_success_failure_and_schema(tmp_path):
+ class Caller:
+ async def call(self, method, params):
+ if method == "nx_edit_feature":
+ raise NXToolError("NX_UNSUPPORTED_EDIT", "unsupported edit")
+ return {"status": "success", "method": method, "params": params}
+
+ server = create_server(Caller(), Workspace(tmp_path), enable_experimental=True)
+ tools = await server.list_tools()
+ pattern = next(t for t in tools if t.name == "nx_pattern")
+ assert pattern.inputSchema["properties"]["pattern_type"]["const"] == "linear"
+ assert "operation_id" in pattern.inputSchema["properties"]
+ success = await server.call_tool("nx_status", {})
+ assert success.structuredContent["status"] == "success" and not success.isError
+ for name, args in [
+ ("nx_edit_feature", {"name": "f", "params": {"x": 1}}),
+ ("nx_pattern", {"features": [], "ignored": 123}),
+ ]:
+ result = await server.call_tool(name, args)
+ assert result.isError and result.structuredContent["status"] == "error"
+ assert not next(t for t in tools if t.name == "nx_sketch_rectangle").annotations.readOnlyHint
+
+
+def test_reference_namespace_rejects_previous_session(executor, tmp_path):
+ obj = SimpleNamespace(Tag=123, Name="Body")
+ first = executor.objects.register(obj, kind="body", name="Body", part_id="part_test")
+ assert first.id.startswith("obj_" + executor.session_id + "_")
+ another = HardenedExecutor(
+ FakeSession(),
+ SimpleNamespace(Session=SimpleNamespace(MarkVisibility=SimpleNamespace(Visible=1))),
+ "test",
+ Workspace(tmp_path / "other"),
+ enable_experimental=True,
+ )
+ with pytest.raises(NXToolError) as error:
+ another.objects.resolve(first.id)
+ assert error.value.code == "NX_OBJECT_STALE"
diff --git a/tests/test_interactive.py b/tests/test_interactive.py
new file mode 100644
index 0000000..294aabf
--- /dev/null
+++ b/tests/test_interactive.py
@@ -0,0 +1,191 @@
+import threading
+
+import pytest
+
+from nx_mcp.bridge import MainThreadDispatcher
+from nx_mcp.inspection import envelope_gap
+from nx_mcp.runtime import NXToolError
+
+
+def test_expired_queued_mutation_never_executes():
+ calls = []
+ dispatcher = MainThreadDispatcher(lambda m, p: calls.append(m) or {}, timeout=0.01)
+ with pytest.raises(NXToolError) as error:
+ dispatcher.call("nx_reposition_component", {})
+ assert error.value.details["mutation_outcome"] == "not_started"
+ dispatcher.drain()
+ assert calls == []
+
+
+def test_timeout_of_running_mutation_reports_unknown_without_duplicate():
+ started = threading.Event()
+ release = threading.Event()
+ errors = []
+ calls = []
+
+ def execute(m, p):
+ calls.append(m)
+ started.set()
+ release.wait(2)
+ return {"done": True}
+
+ dispatcher = MainThreadDispatcher(execute, timeout=0.05)
+
+ def caller():
+ try:
+ dispatcher.call("mutation", {})
+ except NXToolError as e:
+ errors.append(e)
+
+ client = threading.Thread(target=caller)
+ client.start()
+ worker = threading.Thread(target=lambda: dispatcher.drain(timeout=1))
+ worker.start()
+ assert started.wait(1)
+ client.join(1)
+ assert errors and errors[0].details["mutation_outcome"] == "unknown"
+ release.set()
+ worker.join(1)
+ assert calls == ["mutation"]
+
+
+def test_conservative_bounds_gap():
+ assert envelope_gap([0, 0, 0, 10, 10, 10], [12, 0, 0, 22, 10, 10]) == 2
+ assert envelope_gap([0, 0, 0, 10, 10, 10], [5, 0, 0, 15, 10, 10]) == 0
+ assert envelope_gap([0, 0, 0, 10, 10, 10], [13, 14, 10, 23, 24, 20]) == 5
+
+
+@pytest.mark.asyncio
+async def test_mcp_points_cross_bridge_as_json_objects(tmp_path):
+ from nx_mcp.server import create_server
+ from nx_mcp.workspace import Workspace
+
+ class Bridge:
+ async def call(self, method, params):
+ assert params["corner1"] == {"x": 0.0, "y": 0.0}
+ assert params["corner2"] == {"x": 10.0, "y": 10.0}
+ import json
+
+ json.dumps(params)
+ return {"status": "success"}
+
+ server = create_server(Bridge(), Workspace(tmp_path), enable_experimental=True)
+ result = await server.call_tool(
+ "nx_sketch_rectangle",
+ {"sketch_id": "sketch-test", "corner1": {"x": 0, "y": 0}, "corner2": {"x": 10, "y": 10}},
+ )
+ assert not result.isError, result
+
+
+@pytest.mark.asyncio
+async def test_capture_returns_inline_image_and_rejects_changed_artifact(tmp_path):
+ import hashlib
+
+ from nx_mcp.server import create_server
+ from nx_mcp.workspace import Workspace
+
+ data = b"\x89PNG\r\n\x1a\n" + b"fixture"
+ file = tmp_path / "view.png"
+ file.write_bytes(data)
+
+ class Bridge:
+ async def call(self, method, params):
+ return {
+ "path": str(file),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "model_preview": True,
+ }
+
+ server = create_server(Bridge(), Workspace(tmp_path), enable_experimental=True)
+ result = await server.call_tool("nx_screenshot", {})
+ assert not result.isError and result.structuredContent["model_preview"]
+ assert len([item for item in result.content if item.type == "image"]) == 1
+ file.write_bytes(b"changed")
+ result = await server.call_tool("nx_screenshot", {})
+ assert result.isError and result.structuredContent["code"] == "NX_ARTIFACT_CHANGED"
+
+
+def test_agent_ui_lock_is_not_nested_and_manual_handoff_releases_input():
+ from types import SimpleNamespace
+
+ from nx_mcp.interactive import InteractiveHost
+
+ class UI:
+ count = 1
+
+ def AskLockStatus(self):
+ return int(self.count > 0)
+
+ def LockAccess(self):
+ self.count += 1
+
+ def UnlockAccess(self):
+ self.count = max(0, self.count - 1)
+
+ def CanOpenPart(self):
+ return self.count == 0
+
+ class Parts:
+ Display = None
+
+ def __iter__(self):
+ return iter(())
+
+ host = InteractiveHost.__new__(InteractiveHost)
+ host.thread = threading.get_ident()
+ host.mode = "agent"
+ host.owns_lock = True
+ host.window_disabled = False
+ host.ui = UI()
+ host.nx = SimpleNamespace(UI=SimpleNamespace(Status=SimpleNamespace(Lock=1)))
+ host.main_hwnd = 1
+ enabled = []
+ host.user = SimpleNamespace(EnableWindow=lambda h, v: enabled.append(v))
+ host.session = SimpleNamespace(Parts=Parts())
+ host.completed = 0
+ host.status = lambda: {}
+ host.publish = lambda: None
+
+ def execute(method, params):
+ if method == "nx_create_part":
+ assert host.ui.count == 0 # FileNew must run without an NX UI lock.
+ return {"status": "success"}
+
+ host.executor = SimpleNamespace(execute=execute, _history=[1], _checkpoints={"cp": 1})
+ host.execute("nx_list_bodies", {})
+ assert host.ui.count == 1 # Do not increment an already-held lock.
+ host.execute("nx_create_part", {})
+ assert host.ui.count == 1 # Reacquire the lock released by FileNew exactly once.
+ host.control("manual")
+ assert host.ui.count == 0 and not enabled
+ assert not host.executor._history and not host.executor._checkpoints
+
+
+@pytest.mark.asyncio
+async def test_interactive_transport_services_health_during_blocked_request():
+ import asyncio
+
+ from nx_mcp.bridge import BridgeClient, BridgeServer
+
+ entered, release = threading.Event(), threading.Event()
+
+ def dispatch(method, params):
+ if method == "work":
+ entered.set()
+ release.wait(5)
+ return {"done": True}
+ return {"activity": "running", "snapshot_only": True}
+
+ server = BridgeServer(dispatch, token="test", concurrent_requests=True)
+ server.start()
+ try:
+ client = BridgeClient("127.0.0.1", server.port, token="test", timeout=2)
+ running = asyncio.create_task(client.call("work", {}))
+ assert await asyncio.to_thread(entered.wait, 1)
+ status = await asyncio.wait_for(client.call("nx_ui_control", {}), 1)
+ assert status["snapshot_only"] and not running.done()
+ release.set()
+ assert (await running)["done"]
+ finally:
+ release.set()
+ server.stop()
diff --git a/tests/test_inventory_reference_geometry.py b/tests/test_inventory_reference_geometry.py
new file mode 100644
index 0000000..8194906
--- /dev/null
+++ b/tests/test_inventory_reference_geometry.py
@@ -0,0 +1,61 @@
+"""Selection and mutation preflight regressions; native geometry is tested separately."""
+
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.inventory import compact_reference, page
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Object
+
+
+@pytest.mark.parametrize("offset,limit", [(-1, None), (True, 2), (0, 0), (0, 1001), (0, True)])
+def test_invalid_paging(offset, limit):
+ with pytest.raises(NXToolError):
+ page([], offset, limit)
+
+
+def test_compact_identity_keeps_occurrence_context_and_page_totals():
+ reference = {
+ "id": "id",
+ "kind": "component",
+ "name": "bolt",
+ "part_id": "owner",
+ "occurrence_path": ["A", "bolt"],
+ "session_id": "session",
+ }
+ assert compact_reference(reference) == {k: v for k, v in reference.items() if k != "session_id"}
+ assert page([1, 2, 3], 1, 1) == (
+ [2],
+ {"total_count": 3, "count": 1, "offset": 1, "next_offset": 2},
+ )
+
+
+def test_topology_face_filter_rejects_foreign_faces_and_exposes_both_directions(rig):
+ body = Body()
+ rig.part.Bodies.append(body)
+ face, edge = body.faces[0], body.edges[0]
+ face.GetEdges = lambda: [edge]
+ edge.GetFaces = lambda: [face]
+ bid, fid = rig.ref(body), rig.ref(face, "face")
+ result = rig.e._list_topology(bid, face=fid, include_adjacency=True, compact=True)
+ assert result["face_edges"][0]["face"] == fid
+ assert result["edge_faces"][0]["faces"] == [fid]
+ assert result["edge_count"] == 1 and "session_id" not in result["faces"][0]
+ with pytest.raises(NXToolError, match="belong"):
+ rig.e._list_topology(bid, face=rig.ref(Body().faces[0], "face"))
+
+
+def test_duplicate_reference_set_is_rejected_before_creation(rig):
+ existing = Object("SOLIDS")
+ rig.part.GetAllReferenceSets = lambda: [existing]
+ rig.part.CreateReferenceSet = Mock()
+ with pytest.raises(NXToolError, match="already exists"):
+ rig.e._create_reference_set("solids", ["unresolved"])
+ rig.part.CreateReferenceSet.assert_not_called()
+
+
+@pytest.mark.parametrize("name", ["", "Entire Part", "Empty", "x" * 133])
+def test_invalid_reference_set_name_never_resolves_members(rig, name):
+ with pytest.raises(NXToolError):
+ rig.e._create_reference_set(name, ["unresolved"])
diff --git a/tests/test_legacy_repairs.py b/tests/test_legacy_repairs.py
new file mode 100644
index 0000000..f70081f
--- /dev/null
+++ b/tests/test_legacy_repairs.py
@@ -0,0 +1,110 @@
+"""Legacy entry points resolve typed references and reject unsupported geometry."""
+
+from types import SimpleNamespace as S
+
+import pytest
+
+from nx_mcp.legacy_repairs import LegacyRepairsMixin
+from nx_mcp.runtime import NXToolError
+
+
+class Line:
+ def __init__(self, tag, end):
+ self.Tag = tag
+ self.StartPoint = S(X=0, Y=0, Z=0)
+ self.EndPoint = S(X=end[0], Y=end[1], Z=end[2])
+
+
+class Harness(LegacyRepairsMixin):
+ def __init__(self):
+ self.nxopen = S(Line=Line, Edge=type("Edge", (), {}), Face=type("Face", (), {}))
+ self.a, self.b = Line(1, [1, 0, 0]), Line(2, [0, 1, 0])
+ self.part = S(Sketches=[S(GetAllGeometry=lambda: [self.a, self.b])])
+ self.calls = []
+
+ def _resolve(self, ref, kinds):
+ return {"a": self.a, "b": self.b}[ref]
+
+ def _work_part(self):
+ return self.part
+
+ def _reference(self, *args):
+ return {"id": "sketch"}
+
+ def _edit_sketch(self, *args):
+ self.calls.append(args)
+ return {"edited": True}
+
+
+@pytest.mark.parametrize(("end", "expected"), [([0, 1, 0], 90), ([1, 0, 0], 0), ([-1, 0, 0], 180)])
+def test_angle_uses_actual_directions(end, expected):
+ h = Harness()
+ h.b = Line(2, end)
+ result = h._measure_angle("a", "b")
+ assert result["angle_deg"] == pytest.approx(expected)
+ assert result["units"] == "deg"
+ assert result["coordinate_frame"] == "work_part"
+
+
+def test_zero_length_rejected():
+ h = Harness()
+ h.a = Line(1, [0, 0, 0])
+ with pytest.raises(NXToolError, match="degenerate"):
+ h._measure_angle("a", "b")
+
+
+@pytest.mark.parametrize(
+ ("kind", "targets", "value"),
+ [
+ ("midpoint", ["a"], None),
+ ("horizontal", ["a", "b"], None),
+ ("horizontal", ["a"], 5),
+ ("radius", ["a"], None),
+ ],
+)
+def test_constraint_preflight_has_no_mutation(kind, targets, value):
+ h = Harness()
+ with pytest.raises(NXToolError):
+ h._sketch_constraint(kind, targets, value)
+ assert not h.calls
+
+
+def test_fixed_constraint_routes_to_owned_sketch_editor():
+ h = Harness()
+ h._sketch_constraint("fix", ["a"])
+ assert h.calls == [("sketch", [{"action": "constraint", "curve": "a", "type": "fixed"}])]
+
+
+def test_constraint_rejects_cross_sketch_targets():
+ h = Harness()
+ h.part.Sketches = [S(GetAllGeometry=lambda: [h.a]), S(GetAllGeometry=lambda: [h.b])]
+ with pytest.raises(NXToolError, match="one sketch"):
+ h._sketch_constraint("parallel", ["a", "b"])
+
+
+def test_delete_feature_updates_native_delete_list_and_reports_reference():
+ h = Harness()
+ h.session = S(
+ UpdateManager=S(
+ AddToDeleteList=lambda feature: h.calls.append(feature), DoUpdate=lambda: None
+ )
+ )
+ h._require_api = lambda *args: None
+ h._update_model = lambda: h.calls.append("update")
+ assert h._delete_feature("a") == {"deleted": [{"id": "sketch"}]}
+ assert h.calls == [h.a, "update"]
+
+
+@pytest.mark.parametrize("batch", [True, False])
+def test_canned_view_reports_actual_host_mode(batch):
+ from nx_mcp.nx_bridge import NXOpenExecutor
+
+ calls = []
+ executor = S(
+ session=S(IsBatch=batch),
+ nxopen=S(View=S(Canned=S(Top=1), ScaleAdjustment=S(Fit=2))),
+ _work_part=lambda: S(ModelingViews=S(WorkView=S(Orient=lambda *a: calls.append(a)))),
+ )
+ result = NXOpenExecutor._set_view(executor, "Top")
+ assert calls == [(1, 2)]
+ assert result["orientation"] == "top" and result["viewport_available"] is not batch
diff --git a/tests/test_lookup_contract.py b/tests/test_lookup_contract.py
new file mode 100644
index 0000000..acf795d
--- /dev/null
+++ b/tests/test_lookup_contract.py
@@ -0,0 +1,25 @@
+"""Lookup tests use iterable NX collection seams, never unsupported ToArray()."""
+
+from types import SimpleNamespace
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from nx_mcp.utils.geometry import resolve_object_by_name
+
+
+def test_lookup_accepts_journal_identifier_and_casefolded_name():
+ body = SimpleNamespace(Name="", JournalIdentifier="BODY(1)")
+ named = SimpleNamespace(Name="Profile", JournalIdentifier="SKETCH(2)")
+ assert resolve_object_by_name(None, "body(1)", iter([body])) is body
+ assert resolve_object_by_name(None, "PROFILE", iter([named])) is named
+ assert resolve_object_by_name(None, "missing", [body, named]) is None
+
+
+def test_lookup_rejects_ambiguous_names_but_deduplicates_same_object():
+ first = SimpleNamespace(Name="Part", JournalIdentifier="BODY(1)")
+ second = SimpleNamespace(Name="PART", JournalIdentifier="BODY(2)")
+ assert resolve_object_by_name(None, "part", [first], [first]) is first
+ with pytest.raises(NXToolError) as error:
+ resolve_object_by_name(None, "part", [first, second])
+ assert error.value.code == "NX_AMBIGUOUS_REFERENCE"
diff --git a/tests/test_manufacturing_report.py b/tests/test_manufacturing_report.py
new file mode 100644
index 0000000..b4c8641
--- /dev/null
+++ b/tests/test_manufacturing_report.py
@@ -0,0 +1,347 @@
+"""Manufacturing report regressions: contracts, transport recovery and analytic DXF."""
+
+import pytest
+from pydantic import TypeAdapter, ValidationError
+
+from nx_mcp.integration_server import BatchOperation
+from nx_mcp.planar_dxf import dxf_text
+from nx_mcp.result_transport import bound_result, encoded, read_result
+from nx_mcp.runtime import NXToolError
+
+
+def test_batch_discriminator_matches_native_sketch_contract():
+ adapter = TypeAdapter(BatchOperation)
+ rectangle = {
+ "method": "nx_sketch_rectangle",
+ "params": {
+ "sketch_id": "sketch",
+ "corner1": {"x": 0, "y": 0},
+ "corner2": {"x": 80, "y": 30},
+ },
+ }
+ assert adapter.validate_python(rectangle).params.corner2.x == 80
+ with pytest.raises(ValidationError):
+ adapter.validate_python(
+ {"method": "nx_sketch_rectangle", "params": {"x1": 0, "y1": 0, "x2": 1, "y2": 1}}
+ )
+ with pytest.raises(ValidationError):
+ adapter.validate_python({"method": "nx_save_part", "params": {}})
+ assert (
+ adapter.validate_python(
+ {
+ "method": "nx_sketch_arc",
+ "params": {"cx": 0, "cy": 0, "radius": 2, "start_angle": 0, "end_angle": 360},
+ }
+ ).params.radius
+ == 2
+ )
+
+
+def test_result_storage_failure_preserves_commit_and_restore(tmp_path, monkeypatch):
+ from pathlib import Path
+
+ def fail(*_):
+ raise OSError("disk full")
+
+ monkeypatch.setattr(Path, "write_bytes", fail)
+ with pytest.raises(NXToolError) as error:
+ bound_result(
+ {
+ "operation_id": "op",
+ "mutation_outcome": "committed",
+ "restore_id": "restore",
+ "objects": ["x" * 4096] * 300,
+ },
+ tmp_path,
+ )
+ assert error.value.code == "NX_RESULT_STORAGE_FAILED"
+ assert error.value.details["mutation_outcome"] == "committed"
+ assert error.value.details["restore_id"] == "restore"
+
+
+def test_nested_oversize_pages_remain_bounded_and_addressable(tmp_path):
+ value = {str(i): ["x" * 2048] * 150 for i in range(120)}
+ full = {
+ "status": "success",
+ "operation_id": "op",
+ "mutation_outcome": "committed",
+ "objects": value,
+ }
+ receipt = bound_result(full, tmp_path)
+ key = receipt["full_result"]["id"]
+ page = read_result(tmp_path, key, "/objects", offset=100, limit=20)
+ assert list(page["value"]) == [str(i) for i in range(100, 120)]
+ assert page["total_count"] == 120 and page["next_offset"] is None
+ assert len(encoded(page)) < 512 * 1024
+ child = read_result(tmp_path, key, "/objects/101", offset=140, limit=10)
+ assert len(child["value"]) == 10 and child["value"][0] == "x" * 2048
+
+
+def test_dxf_retains_analytic_entities_and_units():
+ text = dxf_text(
+ [
+ {"type": "LINE", "layer": "OUTLINE", "start": [0.0, 0.0], "end": [80.0, 0.0]},
+ {
+ "type": "ARC",
+ "layer": "OUTLINE",
+ "center": [80.0, 2.0],
+ "radius": 2.0,
+ "start_angle": 270.0,
+ "end_angle": 0.0,
+ },
+ {"type": "CIRCLE", "layer": "HOLES", "center": [10.0, 10.0], "radius": 2.0},
+ ]
+ )
+ tokens = text.splitlines()
+ pairs = list(zip(tokens[::2], tokens[1::2], strict=True))
+ assert ("9", "$INSUNITS") in pairs
+ assert pairs[pairs.index(("9", "$INSUNITS")) + 1] == ("70", "4")
+ assert [v for k, v in pairs if k == "0" and v in {"LINE", "ARC", "CIRCLE"}] == [
+ "LINE",
+ "ARC",
+ "CIRCLE",
+ ]
+ assert ("50", "270") in pairs and ("51", "0") in pairs and ("2", "HOLES") in pairs
+
+
+def test_new_part_failure_closes_created_parts_and_restores_selection():
+ from types import SimpleNamespace as NS
+
+ from nx_mcp.hardened import HardenedExecutor
+
+ class Parts(list):
+ Work = None
+ Display = None
+
+ parts = Parts()
+ e = HardenedExecutor.__new__(HardenedExecutor)
+ e.session = NS(Parts=parts)
+ e._reference = lambda p, *_: {"id": str(p.Tag)}
+ e._close_part = lambda part, save: parts.remove(next(p for p in parts if str(p.Tag) == part))
+
+ def fail(*_):
+ parts.append(NS(Tag=10))
+ raise NXToolError("NX_IMPORT_NO_OUTPUT", "no geometry")
+
+ e._import_geometry_inner = fail
+ with pytest.raises(NXToolError) as error:
+ e._import_geometry("vendor.step", target="new_part", output_path="vendor.prt")
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ assert not parts
+
+ def precondition(*_):
+ raise NXToolError("NX_FILE_NOT_FOUND", "missing")
+
+ e._import_geometry_inner = precondition
+ with pytest.raises(NXToolError) as error:
+ e._import_geometry("missing.step", target="new_part", output_path="vendor.prt")
+ assert error.value.details["mutation_outcome"] == "not_started"
+
+
+@pytest.fixture
+def dimension_fixture(monkeypatch):
+ import sys
+ from types import SimpleNamespace as NS
+ from unittest.mock import Mock
+
+ from nx_mcp.drawing_preferences import DrawingPreferencesMixin
+
+ annotation = NS(
+ ToleranceType=NS(
+ NotSet=0, BilateralTwoLines=1, BilateralOneLine=2, LimitTwoLines=3, Basic=4, Reference=5
+ ),
+ DimensionUnit=NS(Millimeters=0, Inches=1, Meters=2, Micrometers=3),
+ DecimalPointCharacter=NS(Period=0, Comma=1),
+ )
+ monkeypatch.setitem(sys.modules, "NXOpen", NS(Annotations=annotation))
+ monkeypatch.setitem(sys.modules, "NXOpen.Annotations", annotation)
+ formatting = NS(
+ DisplayTrailingZeros=False, PrimaryDimensionUnit=0, DecimalPointCharacter=0, Dispose=Mock()
+ )
+ prefs = NS(
+ GetUnitsFormatPreferences=lambda: formatting,
+ SetUnitsFormatPreferences=Mock(),
+ Dispose=Mock(),
+ )
+ dim = NS(
+ GetDimensionPreferences=lambda: prefs,
+ SetDimensionPreferences=Mock(),
+ ComputedSize=318.61,
+ IsRetained=False,
+ NumberOfAssociativities=2,
+ NominalDecimalPlaces=1,
+ ToleranceDecimalPlaces=1,
+ UpperToleranceValue=0,
+ LowerToleranceValue=0,
+ UpperMetricToleranceValue=0,
+ LowerMetricToleranceValue=0,
+ MetricNominalDecimalPlaces=1,
+ MetricToleranceDecimalPlaces=1,
+ ToleranceType=0,
+ RedisplayObject=Mock(),
+ )
+ e = DrawingPreferencesMixin()
+ e._engineering_owned = lambda *args: dim
+ e._reference = lambda *args: {"id": "dimension"}
+ e._work_part = lambda: None
+ e._units = lambda: "mm"
+ return e, dim, formatting
+
+
+def test_dimension_preferences_preserve_value_and_associations(dimension_fixture):
+ e, dim, formatting = dimension_fixture
+ result = e._edit_dimension_format(
+ "dimension",
+ decimal_places=2,
+ trailing_zeros=True,
+ units="mm",
+ decimal_separator="comma",
+ tolerance_type="bilateral",
+ upper_tolerance=0.05,
+ lower_tolerance=-0.02,
+ tolerance_decimal_places=2,
+ )
+ assert result["computed_value"] == 318.61 and result["association_count"] == 2
+ assert result["decimal_places"] == 2 and result["decimal_separator"] == "Comma"
+ assert result["upper_tolerance"] == 0.05 and result["lower_tolerance"] == -0.02
+ assert result["tolerance_type"] == "BilateralTwoLines"
+ assert dim.NumberOfAssociativities == 2 and formatting.DisplayTrailingZeros
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"decimal_places": 9},
+ {"units": "feet"},
+ {"decimal_separator": "colon"},
+ {"tolerance_type": "invented"},
+ ],
+)
+def test_dimension_format_preflight_rejects_unsupported_changes(dimension_fixture, params):
+ e, dim, _ = dimension_fixture
+ with pytest.raises(NXToolError):
+ e._edit_dimension_format("dimension", **params)
+ dim.SetDimensionPreferences.assert_not_called()
+
+
+def test_dimension_format_rejects_changed_measurement(dimension_fixture):
+ e, dim, _ = dimension_fixture
+ dim.SetDimensionPreferences.side_effect = lambda *_: setattr(dim, "ComputedSize", 0)
+ with pytest.raises(NXToolError, match="measured value"):
+ e._edit_dimension_format("dimension", decimal_places=2)
+
+
+@pytest.fixture
+def planar_fixture(tmp_path, monkeypatch):
+ import math
+ import sys
+ from types import SimpleNamespace as NS
+
+ from nx_mcp.planar_dxf import PlanarDxfMixin
+ from nx_mcp.workspace import Workspace
+
+ class Sketch:
+ pass
+
+ curves = [
+ NS(Tag=1, kind="line", limits=[0, 1]),
+ NS(Tag=2, kind="circle", limits=[0, 2 * math.pi]),
+ NS(Tag=3, kind="arc", limits=[0, math.pi / 2]),
+ ]
+ arc = NS(Center=[1, 1, 0], Radius=0.25, XAxis=[1, 0, 0], YAxis=[0, 1, 0])
+
+ def evaluate(curve, t):
+ return (
+ [
+ [2 * t, 0, 0]
+ if curve.kind == "line"
+ else [1 + 0.25 * math.cos(t), 1 + 0.25 * math.sin(t), 0]
+ ],
+ )
+
+ # UF returns a coordinate vector as the first tuple element.
+ def point(curve, t):
+ return evaluate(curve, t)[0]
+
+ evaluator = NS(
+ Initialize2=lambda tag: curves[tag - 1],
+ AskLimits=lambda c: c.limits,
+ EvaluateUnitVectors=lambda c, t: point(c, t),
+ IsLine=lambda c: c.kind == "line",
+ IsArc=lambda c: c.kind in {"arc", "circle"},
+ AskArc=lambda _: arc,
+ )
+ uf = NS(Eval=evaluator)
+ nx = NS(Sketch=Sketch)
+ monkeypatch.setitem(sys.modules, "NXOpen", nx)
+ monkeypatch.setitem(sys.modules, "NXOpen.UF", NS(UFSession=NS(GetUFSession=lambda: uf)))
+ nx.UF = sys.modules["NXOpen.UF"]
+ part = NS()
+ sketch = Sketch()
+ sketch.IsOccurrence = False
+ sketch.OwningPart = part
+ sketch.GetAllGeometry = lambda: curves
+ e = PlanarDxfMixin()
+ e.nxopen = nx
+ e.workspace = Workspace(tmp_path)
+ e._resolve = lambda *_: sketch
+ e._work_part = lambda: part
+ e._units = lambda: "inch"
+ e._sketch_frame = lambda _: {
+ "origin": [0, 0, 0],
+ "x_axis": [1, 0, 0],
+ "y_axis": [0, 1, 0],
+ "normal": [0, 0, 1],
+ }
+ e._reference = lambda c, *_: {"id": str(c.Tag)}
+ return e, sketch, curves
+
+
+def test_planar_export_converts_inches_and_preserves_arc_layers(planar_fixture, tmp_path):
+ e, _, _ = planar_fixture
+ r = e._export_planar_dxf("sketch", str(tmp_path / "outline.dxf"), layers={"2": "HOLES"})
+ assert r["units"] == "mm" and r["entity_counts"] == {"LINE": 1, "CIRCLE": 1, "ARC": 1}
+ line, circle, arc = r["entities"]
+ assert line["end"] == [50.8, 0] and circle["radius"] == 6.35
+ assert circle["layer"] == "HOLES" and arc["start_angle"] == 0 and arc["end_angle"] == 90
+ assert (tmp_path / "outline.dxf").stat().st_size == r["size"]
+ with pytest.raises(NXToolError, match="existing"):
+ e._export_planar_dxf("sketch", str(tmp_path / "outline.dxf"))
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {"origin": [0, 0, 1]},
+ {"x_axis": [1, 0, 0]},
+ {"x_axis": [1, 0, 0], "y_axis": [1, 0, 0]},
+ {"layers": {"missing": "HOLES"}},
+ {"layer": "invalid/name"},
+ ],
+)
+def test_planar_export_preflights_before_writing(planar_fixture, tmp_path, params):
+ e, _, _ = planar_fixture
+ with pytest.raises(NXToolError):
+ e._export_planar_dxf("sketch", str(tmp_path / "rejected.dxf"), **params)
+ assert not (tmp_path / "rejected.dxf").exists()
+
+
+def test_save_work_part_does_not_save_modified_components(rig):
+ from unittest.mock import Mock
+
+ original = rig.part.Save
+ child = type("Child", (), {"IsModified": True})()
+
+ def save(components, close):
+ if components:
+ child.IsModified = False
+ return original(components, close)
+
+ rig.part.Save = save
+ rig.e._save_component_drawing_previews = Mock(
+ side_effect=AssertionError("Implicit component save")
+ )
+ rig.e._save_part()
+ assert child.IsModified
+ rig.e._save_component_drawing_previews.assert_not_called()
diff --git a/tests/test_native_inspection.py b/tests/test_native_inspection.py
new file mode 100644
index 0000000..f52bd2e
--- /dev/null
+++ b/tests/test_native_inspection.py
@@ -0,0 +1,252 @@
+"""Native inspection seam tests: reports, temporary solids and cleanup failures."""
+
+import struct
+from pathlib import Path
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Component, point
+
+pytestmark = pytest.mark.fake_nx
+
+
+def test_occurrence_geometry_bounds_and_volume_are_explicit(rig):
+ base = Body("base", [0, 0, 0, 2, 3, 4])
+ base.volume = 24
+ rig.part.Bodies.append(base)
+ root = Component("root")
+ a = Component("a", [Body()], root)
+ nested = Component("nested", [Body()], a)
+ rig.part.ComponentAssembly.RootComponent = root
+ assert len(rig.e._geometry(scope="assembly")) == 3
+ assert len(rig.e._geometry(rig.ref(a, "component"))) == 2
+ a.IsSuppressed = True
+ assert rig.e._occurrence_bodies(nested) == []
+ assert rig.e._geometry(scope="assembly") == [base]
+ rig.ref(base)
+ for precision in ["exact", "conservative"]:
+ result = rig.e._get_bounding_box(precision=precision)
+ assert result["dimensions"] == [2, 3, 4] and result["units"] == "mm"
+ assert rig.e._measure_volume()["volume_mm3"] == 24
+ base.IsSolidBody = False
+ with pytest.raises(NXToolError):
+ rig.e._measure_volume()
+ with pytest.raises(NXToolError):
+ rig.e._get_bounding_box(precision="bad")
+ rig.part.WCS.CoordinateSystem.Orientation.Element.Xx = 0
+ with pytest.raises(NXToolError):
+ rig.e._get_bounding_box(precision="exact")
+ with pytest.raises(NXToolError):
+ rig.e._geometry(scope="bad")
+ rig.part.Bodies.clear()
+ with pytest.raises(NXToolError):
+ rig.e._geometry(scope="part")
+
+
+def native_pair(rig, result=1, temporary=True):
+ b = NS(FirstBody=NS(Value=None), SecondBody=NS(Value=None), Reset=Mock(), Destroy=Mock())
+ solid = Body()
+ solid.volume = 125
+ sheet = Body()
+ sheet.IsSolidBody = False
+
+ def perform():
+ if temporary:
+ rig.part.Bodies.append(solid)
+ return result
+
+ b.PerformCheck = Mock(side_effect=perform)
+ b.GetInterferenceResults = lambda: [solid, sheet]
+ rig.part.AnalysisManager = NS(CreateSimpleInterferenceObject=lambda: b)
+ return b
+
+
+@pytest.mark.parametrize(
+ "enum,label,volume", [(1, "penetration", 125), (2, "contact", 0), (3, "clear", 0)]
+)
+def test_native_interference_removes_temporary_solids(rig, enum, label, volume):
+ a = Body()
+ b = Body()
+ rig.part.Bodies.extend([a, b])
+ builder = native_pair(rig, enum)
+ result = rig.e._interference_pair(a, b)
+ assert result == {"classification": label, "interference_volume_mm3": volume}
+ assert rig.part.Bodies == [a, b] and not rig.session.marks
+ builder.Reset.assert_called_once()
+ builder.Destroy.assert_called_once()
+
+
+@pytest.mark.parametrize("failure", ["unresolved", "perform", "reset", "undo", "leaked_body"])
+def test_interference_failure_cleanup_and_partial_outcome(rig, failure):
+ a = Body()
+ b = Body()
+ builder = native_pair(rig, 99 if failure == "unresolved" else 1)
+ if failure == "perform":
+ builder.PerformCheck = Mock(side_effect=RuntimeError("native failure"))
+ if failure == "reset":
+ builder.Reset = Mock(side_effect=RuntimeError("reset failure"))
+ if failure == "undo":
+ rig.session.UndoToMark = Mock(side_effect=RuntimeError("undo failed"))
+ if failure == "leaked_body":
+ rig.session.UndoToMark = Mock()
+ with pytest.raises((NXToolError, RuntimeError)) as error:
+ rig.e._interference_pair(a, b)
+ assert builder.Destroy.call_count == 1
+ if failure in ["undo", "leaked_body"]:
+ assert error.value.code == "NX_ROLLBACK_FAILED"
+ assert error.value.details["mutation_outcome"] == "partial"
+ else:
+ assert not rig.part.Bodies
+
+
+def test_clearance_prunes_only_separated_boxes_and_returns_native_points(rig):
+ a = Body("a")
+ b = Body("b", [12, 0, 0, 22, 10, 10])
+ rig.part.Bodies.extend([a, b])
+ refs = [rig.ref(v) for v in (a, b)]
+ measured = Mock(return_value=(2.0, point(10, 0, 0), point(12, 0, 0), 123))
+ rig.session.Measurement = NS(GetMinimumDistance=measured)
+ skipped = rig.e._check_clearance(refs)
+ assert skipped["broad_phase_clear_pairs"] == 1 and measured.call_count == 0
+ result = rig.e._check_clearance(refs, minimum_clearance=3)
+ assert result["counts"]["below_clearance"] == 1
+ assert result["pairs"][0]["closest_points"] == [[10, 0, 0], [12, 0, 0]]
+ assert result["pairs"][0]["accuracy"] is None
+ assert rig.e._measure_distance(*refs)["distance"] == 2
+ native_pair(rig, 2, False)
+ measured.return_value = (0, point(), point(), None)
+ result = rig.e._check_interference(*refs)
+ assert result["counts"]["contact"] == 1
+ with pytest.raises(NXToolError):
+ rig.e._check_interference(refs[0], refs[0])
+ b.IsSolidBody = False
+ with pytest.raises(NXToolError):
+ rig.e._check_interference(*refs)
+ with pytest.raises(NXToolError):
+ rig.e._check_clearance()
+
+
+@pytest.mark.parametrize(
+ "kwargs",
+ [
+ {"minimum_clearance": -1},
+ {"minimum_clearance": float("inf")},
+ {"max_pairs": True},
+ {"max_pairs": 0},
+ {"max_pairs": 10001},
+ {"objects": []},
+ {"objects": ["one"]},
+ {"objects": "bad"},
+ ],
+)
+def test_invalid_clearance_rejected_before_geometry(rig, kwargs):
+ with pytest.raises(NXToolError):
+ rig.e._check_clearance(**kwargs)
+
+
+def test_pair_limit_prevents_partial_clearance_report(rig):
+ rig.part.Bodies.extend([Body(), Body(), Body()])
+ with pytest.raises(NXToolError) as error:
+ rig.e._check_clearance(max_pairs=1)
+ assert error.value.code == "NX_PAIR_LIMIT"
+
+
+def image_builder(rig, payload=None, fail=False):
+ b = NS(Destroy=Mock(), SetCustomBackgroundColor=Mock())
+
+ def commit():
+ if fail:
+ raise RuntimeError("graphics failed")
+ Path(b.FileName).write_bytes(
+ payload
+ if payload is not None
+ else b"\x89PNG\r\n\x1a\n" + b"\0" * 8 + struct.pack(">II", 320, 240)
+ )
+
+ b.Commit = commit
+ rig.part.Views = NS(CreateImageExportBuilder=lambda: b)
+ return b
+
+
+@pytest.mark.parametrize(
+ "background,style", [("white", "shaded"), ("original", "current"), ("transparent", "wireframe")]
+)
+def test_viewport_metadata_and_style_restoration(rig, background, style):
+ b = image_builder(rig)
+ result = rig.e._screenshot(width=320, height=240, background=background, style=style, fit=True)
+ assert result["resolution"] == [320, 240] and not result["warnings"]
+ assert result["capture_kind"] == "nx_model_viewport"
+ assert rig.part.ModelingViews.WorkView.RenderingStyle == "shaded"
+ assert b.Destroy.call_count == 1
+ rig.part.ModelingViews.WorkView.RenderingStyle = 99
+ assert rig.e._view_info()["rendering_style"] == "nx_style_99"
+
+
+@pytest.mark.parametrize(
+ "failure",
+ [
+ "native",
+ "invalid_png",
+ "wrong_size",
+ "missing_display",
+ "batch",
+ "exists",
+ "extension",
+ "resolution",
+ "style",
+ ],
+)
+def test_capture_failure_does_not_leave_style_changed(rig, tmp_path, failure):
+ b = image_builder(
+ rig, payload=b"broken" if failure == "invalid_png" else None, fail=failure == "native"
+ )
+ p = tmp_path / "test.png"
+ kwargs = {"path": str(p)}
+ if failure == "missing_display":
+ rig.session.Parts.Display = None
+ if failure == "batch":
+ rig.session.IsBatch = True
+ if failure == "exists":
+ p.write_bytes(b"preserved")
+ if failure == "extension":
+ kwargs["path"] = str(tmp_path / "test.jpg")
+ if failure == "resolution":
+ kwargs["width"] = True
+ if failure == "style":
+ kwargs["style"] = "bad"
+ if failure == "wrong_size":
+ result = rig.e._capture_view(**kwargs)
+ assert result["warnings"]
+ return
+ with pytest.raises((NXToolError, RuntimeError)):
+ rig.e._capture_view(**kwargs)
+ assert rig.part.ModelingViews.WorkView.RenderingStyle == "shaded"
+ if failure in ["native", "invalid_png"]:
+ assert b.Destroy.call_count == 1
+ if failure == "exists":
+ assert p.read_bytes() == b"preserved"
+
+
+def test_failed_builder_destroy_still_restores_view_style(rig):
+ builder = image_builder(rig)
+ builder.Destroy = Mock(side_effect=RuntimeError("destroy failed"))
+ with pytest.raises(RuntimeError):
+ rig.e._capture_view(style="wireframe")
+ assert rig.part.ModelingViews.WorkView.RenderingStyle == "shaded"
+
+
+def test_read_only_cleanup_failure_keeps_partial_outcome_in_receipt(rig):
+ a = Body()
+ b = Body()
+ rig.part.Bodies.extend([a, b])
+ refs = [rig.ref(v) for v in (a, b)]
+ native_pair(rig)
+ rig.session.Measurement = NS(GetMinimumDistance=lambda *_: (0, point(), point(), None))
+ rig.session.UndoToMark = Mock(side_effect=RuntimeError("undo failed"))
+ with pytest.raises(NXToolError) as error:
+ rig.e.execute("nx_check_interference", dict(zip(["obj1", "obj2"], refs, strict=True)))
+ assert error.value.code == "NX_ROLLBACK_FAILED"
+ assert error.value.details["mutation_outcome"] == "partial"
diff --git a/tests/test_native_release_runner.py b/tests/test_native_release_runner.py
new file mode 100644
index 0000000..6e10c60
--- /dev/null
+++ b/tests/test_native_release_runner.py
@@ -0,0 +1,72 @@
+"""Native release receipts must distinguish failures and changed user sessions."""
+
+import hashlib
+import runpy
+from pathlib import Path
+
+import pytest
+
+RUNNER = runpy.run_path(
+ str(Path(__file__).resolve().parents[1] / "scripts/validate_native_release.py")
+)
+
+
+def test_session_verification_checks_placements_and_unsaved_state():
+ before = {
+ "parts": [{"path": "a.prt", "work": True, "display": True, "modified": False}],
+ "components": [{"path": "p.prt", "translation": [1, 2, 3]}],
+ }
+ RUNNER["verify_session"](before, before)
+ with pytest.raises(RuntimeError, match="component source"):
+ RUNNER["verify_session"](before, {**before, "components": []})
+ with pytest.raises(RuntimeError, match="saved state"):
+ RUNNER["verify_session"](
+ before, {**before, "parts": [{**before["parts"][0], "modified": True}]}
+ )
+
+
+def test_receipt_manifest_hashes_nested_artifacts(tmp_path):
+ (tmp_path / "sheets").mkdir()
+ (tmp_path / "sheets" / "view.png").write_bytes(b"fixture")
+ (tmp_path / "release-validation.json").write_text("not self-hashed")
+ assert RUNNER["manifest_files"](tmp_path) == [
+ {"path": "sheets/view.png", "size": 7, "sha256": hashlib.sha256(b"fixture").hexdigest()}
+ ]
+
+
+@pytest.mark.asyncio
+async def test_agent_ux_client_forwards_tool_name_arguments(tmp_path):
+ from types import SimpleNamespace
+ from unittest.mock import AsyncMock
+
+ runner = runpy.run_path(
+ str(Path(__file__).resolve().parents[1] / "scripts/validate_agent_ux.py")
+ )
+ session = SimpleNamespace(
+ call_tool=AsyncMock(
+ return_value=SimpleNamespace(isError=False, structuredContent={"status": "success"})
+ )
+ )
+ client = runner["Client"](session, tmp_path / "ux")
+ client.schemas = {"nx_create_reference_set": {"properties": {}}}
+ await client.call("nx_create_reference_set", name="SOLIDS", objects=["body"])
+ session.call_tool.assert_awaited_once_with(
+ "nx_create_reference_set", {"name": "SOLIDS", "objects": ["body"]}
+ )
+
+
+def test_native_examples_accept_runner_profile_count():
+ """An added tool must not strand unrelated native suites on an old literal."""
+ examples = Path(__file__).resolve().parents[1] / "examples"
+ for name in [
+ "advanced_tools",
+ "authoring_tools",
+ "engineering_tools",
+ "freeform_manufacturing",
+ "project_folders",
+ "sheet_metal",
+ "visual_tools",
+ ]:
+ source = (examples / f"validate_{name}.py").read_text()
+ assert 'os.environ.get("NX_EXPECTED_TOOL_COUNT", "189")' in source
+ assert "== 179" not in source
diff --git a/tests/test_nx_executor.py b/tests/test_nx_executor.py
index a397075..b9d5940 100644
--- a/tests/test_nx_executor.py
+++ b/tests/test_nx_executor.py
@@ -204,8 +204,15 @@ def Dispose(self):
class FakeStepCreator:
+ def __init__(self):
+ self.ObjectTypes = SimpleNamespace()
+
def Commit(self):
- Path(self.OutputFile).write_text("STEP", encoding="utf-8")
+ assert self.ExportFrom == "existing-part"
+ assert self.ObjectTypes.Solids is True
+ Path(self.OutputFile).write_text(
+ "ISO-10303-21;\nMANIFOLD_SOLID_BREP();\nEND-ISO-10303-21;", encoding="utf-8"
+ )
def Destroy(self):
pass
@@ -275,8 +282,9 @@ def UndoToMark(self, mark, name):
FAKE_NXOPEN = SimpleNamespace(
+ StepCreator=SimpleNamespace(ExportFromOption=SimpleNamespace(ExistingPart="existing-part")),
BasePart=SimpleNamespace(
- SaveComponents=SimpleNamespace(TrueValue=True),
+ SaveComponents=SimpleNamespace(TrueValue=True, FalseValue=False),
CloseAfterSave=SimpleNamespace(FalseValue=False),
CloseWholeTree=SimpleNamespace(TrueValue="whole-tree"),
CloseModified=SimpleNamespace(CloseModified="close"),
@@ -367,7 +375,7 @@ def test_open_save_export_and_close_part_lifecycle(tmp_path: Path):
assert opened["part"]["name"] == "existing"
assert saved["message"] == "Saved part: existing"
assert exported["path"].endswith("part.stp")
- assert Path(exported["path"]).read_text(encoding="utf-8") == "STEP"
+ assert "MANIFOLD_SOLID_BREP" in Path(exported["path"]).read_text(encoding="utf-8")
assert closed["message"] == "Closed part: existing"
assert session.Parts.last_closed.close_args[:2] == ("whole-tree", "close")
assert executor.execute("nx_status", {})["active_part"] is None
diff --git a/tests/test_output_schemas.py b/tests/test_output_schemas.py
new file mode 100644
index 0000000..8f3c683
--- /dev/null
+++ b/tests/test_output_schemas.py
@@ -0,0 +1,123 @@
+"""Contract acceptance and rejection, including MCP image/error transport."""
+
+import copy
+from unittest.mock import AsyncMock
+
+import pytest
+from jsonschema import Draft202012Validator, ValidationError
+from mcp.shared.memory import create_connected_server_and_client_session
+
+from nx_mcp.integration_server import IntegrationEnvelope, envelope
+from nx_mcp.output_schemas import PAYLOADS, output_schema
+from nx_mcp.runtime import NXToolError
+from nx_mcp.server import create_server
+from nx_mcp.workspace import Workspace
+
+
+def validate(name, payload):
+ schema = output_schema(name, IntegrationEnvelope.model_json_schema())
+ Draft202012Validator.check_schema(schema)
+ Draft202012Validator(schema).validate(envelope(payload).structuredContent)
+
+
+@pytest.mark.parametrize("name", list(PAYLOADS))
+def test_success_requires_payload_but_error_has_independent_contract(name):
+ with pytest.raises(ValidationError):
+ validate(name, {})
+ validate(
+ name,
+ NXToolError(
+ "NX_TEST", "Actionable failure", details={"mutation_outcome": "rolled_back"}
+ ).as_dict(),
+ )
+ with pytest.raises(ValidationError):
+ validate(name, {"status": "error", "message": "missing code and retry guidance"})
+
+
+def test_recovery_states_and_unknown_are_typed_without_inventing_failure():
+ unknown = {"operation_id": "unknown_123", "state": "unknown", "mutation_outcome": "unknown"}
+ validate("nx_operation_status", unknown)
+ for key, value in [("state", "complete"), ("mutation_outcome", "probably_ok")]:
+ with pytest.raises(ValidationError):
+ validate("nx_operation_status", {**unknown, key: value})
+ validate(
+ "nx_operation_status",
+ {
+ **unknown,
+ "state": "committed",
+ "mutation_outcome": "committed",
+ "result": {"object": {}},
+ },
+ )
+
+
+def test_geometry_cardinality_units_and_closest_points():
+ ref = {"id": "body123", "kind": "body", "name": "", "part_id": "part123"}
+ volume = {
+ "bodies": [{"body": ref, "volume_mm3": 12}],
+ "body_count": 1,
+ "volume_mm3": 12,
+ "volume_units": "mm^3",
+ "semantics": "sum_of_included_bodies",
+ "scope": "part",
+ }
+ validate("nx_measure_volume", volume)
+ with pytest.raises(ValidationError):
+ validate("nx_measure_volume", {**volume, "volume_units": "in^3"})
+ distance = {
+ "distance": 5,
+ "closest_points": [[0, 0, 0], [5, 0, 0]],
+ "references": ["a", "b"],
+ "resolved_tags": [1, 2],
+ "coordinate_frame": "work_part",
+ "pair_count": 1,
+ "method": "NX",
+ "accuracy": None,
+ }
+ validate("nx_measure_distance", distance)
+ bad = copy.deepcopy(distance)
+ bad["closest_points"][0].pop()
+ with pytest.raises(ValidationError):
+ validate("nx_measure_distance", bad)
+
+
+@pytest.mark.asyncio
+async def test_fresh_mcp_client_accepts_artifacts_and_structured_errors(tmp_path):
+ (tmp_path / "test.txt").write_text("hello")
+ server = create_server(AsyncMock(), Workspace(tmp_path), enable_experimental=True)
+ async with create_connected_server_and_client_session(server) as client:
+ for args in [{"delivery": "metadata"}, {"length": 2}, {"offset": 5}]:
+ result = await client.call_tool("nx_download_file", {"path": "test.txt", **args})
+ assert not result.isError
+ validate("nx_download_file", result.structuredContent)
+ error = await client.call_tool("nx_download_file", {"path": "absent.txt"})
+ assert error.isError and error.structuredContent["code"] == "NX_FILE_NOT_FOUND"
+ validate("nx_download_file", error.structuredContent)
+ tools = {t.name: t for t in (await client.list_tools()).tools}
+ assert "sketch_name" in tools["nx_revolve"].inputSchema["required"]
+ assert "Saves the part" in tools["nx_export_step"].description
+ assert "checkpoints can expire" in tools["nx_export_step"].description
+ bad = await client.call_tool("nx_revolve", {})
+ assert bad.isError and bad.structuredContent["code"] == "NX_INVALID_ARGUMENT"
+
+
+@pytest.mark.asyncio
+async def test_new_inspection_tools_publish_actionable_docstrings(tmp_path):
+ server = create_server(AsyncMock(), Workspace(tmp_path), enable_experimental=True)
+ tools = {t.name: t for t in await server.list_tools()}
+ expected = {
+ "nx_flat_pattern_orientation_edges": "x_axis_edge",
+ "nx_create_reference_set": "solid body IDs",
+ "nx_set_component_reference_set": "direct children",
+ "nx_list_reference_sets": "direct members",
+ "nx_list_datums": "coordinate systems",
+ "nx_set_datum_visibility": "restore_id",
+ "nx_list_open_parts": "total_count",
+ "nx_list_components": "include_transforms=False",
+ }
+ for name, guidance in expected.items():
+ assert guidance in tools[name].description
+ assert (
+ "semantics and installed API support have not been validated"
+ not in tools[name].description
+ )
diff --git a/tests/test_recovery_state.py b/tests/test_recovery_state.py
new file mode 100644
index 0000000..88857d6
--- /dev/null
+++ b/tests/test_recovery_state.py
@@ -0,0 +1,248 @@
+"""Recovery invariants across native failures, save boundaries and part lifetimes."""
+
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Part
+
+pytestmark = pytest.mark.fake_nx
+
+
+def mutation(rig):
+ def add(fail=False):
+ rig.part.Bodies.append(Body())
+ rig.part.IsModified = True
+ if fail:
+ raise RuntimeError("after mutation")
+ return {}
+
+ rig.e._handlers["nx_test_add"] = add
+ return lambda **p: rig.e.execute("nx_test_add", p)
+
+
+def test_checkpoint_rollback_invalidates_objects_and_preserves_receipt(rig):
+ add = mutation(rig)
+ cp = rig.e.execute("nx_checkpoint", {})
+ committed = add(operation_id="test-operation-001")
+ ref = committed["changes"]["created"][0]["id"]
+ assert rig.e._checkpoint_state()["undo_depth"] == 1
+ rig.e.execute("nx_status", {})
+ assert rig.e._checkpoint_state()["undo_depth"] == 1
+ rig.e.execute("nx_rollback", {"checkpoint_id": cp["checkpoint_id"]})
+ assert not rig.part.Bodies and not rig.part.IsModified
+ with pytest.raises(NXToolError):
+ rig.e.objects.resolve(ref)
+ replay = add(operation_id="test-operation-001")
+ assert replay["replayed"] and "reverted" in replay["warnings"][0]
+ assert not rig.part.Bodies
+ with pytest.raises(NXToolError, match="not in this session"):
+ rig.e._rollback_checkpoint(cp["checkpoint_id"])
+
+
+def test_undo_reverts_only_latest_operation_and_save_expires_marks(rig):
+ add = mutation(rig)
+ add(operation_id="first-operation")
+ add(operation_id="second-operation")
+ assert len(rig.part.Bodies) == 2
+ assert rig.e.execute("nx_undo", {})["undone_operation_id"] == "second-operation"
+ assert len(rig.part.Bodies) == 1
+ cp = rig.e._checkpoint()
+ saved = rig.e._save_part()
+ assert saved["recovery"]["undo_depth"] == 0
+ with pytest.raises(NXToolError, match="expired"):
+ rig.e._rollback_checkpoint(cp["checkpoint_id"])
+ with pytest.raises(NXToolError, match="No native undo"):
+ rig.e._undo()
+
+
+def test_cross_part_undo_and_checkpoint_refuse_unrelated_changes(rig, tmp_path):
+ add = mutation(rig)
+ cp = rig.e._checkpoint()
+ add()
+ other = Part(rig.session, tmp_path / "other.prt")
+ with pytest.raises(NXToolError) as error:
+ rig.e._undo()
+ assert error.value.code == "NX_CROSS_PART_ROLLBACK"
+ with pytest.raises(NXToolError) as error:
+ rig.e._rollback_checkpoint(cp["checkpoint_id"])
+ assert error.value.code == "NX_CROSS_PART_ROLLBACK"
+ assert len(rig.part.Bodies) == 1 and not other.Bodies
+
+
+def test_failed_update_rolls_back_and_rejects_retry(rig):
+ add = mutation(rig)
+ with pytest.raises(NXToolError) as error:
+ add(fail=True, operation_id="failed-operation")
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ assert not rig.part.Bodies and not rig.part.IsModified
+ with pytest.raises(NXToolError) as replay:
+ add(fail=True, operation_id="failed-operation")
+ assert replay.value.code == "NX_OPERATION_FAILED"
+ assert rig.e._current_operation is None
+
+
+def test_rollback_failure_is_partial_and_never_replayed_as_success(rig):
+ add = mutation(rig)
+ rig.session.UndoToMark = Mock(side_effect=RuntimeError("lost mark"))
+ with pytest.raises(NXToolError) as error:
+ add(fail=True, operation_id="partial-operation")
+ assert error.value.code == "NX_ROLLBACK_FAILED"
+ assert error.value.details["mutation_outcome"] == "partial"
+ assert rig.e.store.get("partial-operation")["mutation_outcome"] == "partial"
+ assert len(rig.part.Bodies) == 1
+
+
+def test_receipt_write_failure_does_not_undo_committed_geometry(rig, monkeypatch):
+ add = mutation(rig)
+ put = rig.e.store.put
+
+ def persist(record):
+ if record["state"] == "committed":
+ raise OSError("disk full")
+ put(record)
+
+ monkeypatch.setattr(rig.e.store, "put", persist)
+ with pytest.raises(OSError):
+ add(operation_id="durable-operation")
+ assert len(rig.part.Bodies) == 1
+ assert rig.e.store.get("durable-operation")["state"] == "running"
+ rig.e.store.recover("next-session")
+ assert rig.e.store.get("durable-operation")["state"] == "unknown"
+ with pytest.raises(NXToolError) as error:
+ add(operation_id="durable-operation")
+ assert error.value.code == "NX_OPERATION_UNKNOWN"
+
+
+def test_prior_session_receipt_warns_and_cannot_duplicate(rig):
+ add = mutation(rig)
+ add(operation_id="previous-session")
+ r = rig.e.store.get("previous-session")
+ r["session_id"] = "old"
+ rig.e.store.put(r)
+ result = add(operation_id="previous-session")
+ assert any("earlier NX session" in w for w in result["warnings"])
+ assert len(rig.part.Bodies) == 1
+
+
+def test_session_lifecycle_and_generation_reject_closed_references(rig, tmp_path):
+ part = rig.part
+ body = Body()
+ part.Bodies.append(body)
+ old = rig.ref(body)
+ p = rig.e._reference(part, "part", part, "Part")["id"]
+ opened = rig.e._open_part(part.FullPath)
+ assert opened["already_loaded"]
+ assert opened["message"] == "Reused loaded part"
+ assert rig.e._activate_part(part.Name, False, False)["part"]["id"] == p
+ cp = rig.e._checkpoint()
+ rig.e._close_part(save=True, part=p)
+ assert not rig.session.Parts and not rig.e._checkpoints
+ with pytest.raises(NXToolError):
+ rig.e.objects.resolve(old)
+ rig.session.Parts.append(part)
+ rig.session.Parts.Work = rig.session.Parts.Display = part
+ assert rig.ref(body) != old
+ path = tmp_path / "imported.prt"
+ path.write_text("fixture")
+ new_part = rig.e._open_part(str(path), work=False, display=False)
+ assert not new_part["already_loaded"]
+ assert new_part["message"] == "Opened part"
+ assert len(rig.e._list_open_parts()["parts"]) == 2
+ with pytest.raises(NXToolError):
+ rig.e._open_part(str(tmp_path / "missing.prt"))
+ with pytest.raises(NXToolError):
+ rig.e._activate_part("missing")
+ assert cp["checkpoint_id"] not in rig.e._checkpoints
+
+
+def test_snapshot_invalidation_preserves_topology_for_display_only(rig):
+ b = Body()
+ rig.part.Bodies.append(b)
+ before = rig.e._snapshot(rig.part)
+ face = rig.ref(b.faces[0], "face")
+ rig.e._invalidate_deleted(before, before, False)
+ assert rig.e.objects.resolve(face) is b.faces[0]
+ rig.e._invalidate_deleted(before, {})
+ for ref in [face, next(iter(before.values()))["id"]]:
+ with pytest.raises(NXToolError):
+ rig.e.objects.resolve(ref)
+
+
+def test_lookup_ambiguity_ownership_and_kind_guards(rig, tmp_path):
+ first = Body("A")
+ second = Body("a")
+ rig.part.Bodies.extend([first, second])
+ ref = rig.ref(first)
+ assert rig.e._resolve(first.JournalIdentifier, {"body"}) is first
+ with pytest.raises(NXToolError) as error:
+ rig.e._resolve("A")
+ assert error.value.code == "NX_AMBIGUOUS_REFERENCE"
+ with pytest.raises(NXToolError):
+ rig.e._resolve(ref, {"feature"})
+ with pytest.raises(NXToolError):
+ rig.e._resolve("obj_missing")
+ with pytest.raises(NXToolError):
+ rig.e._resolve("missing")
+ Part(rig.session, tmp_path / "other.prt")
+ with pytest.raises(NXToolError):
+ rig.e._resolve(ref)
+
+
+def test_guard_failures_never_create_undo_marks(rig):
+ with pytest.raises(NXToolError):
+ rig.e.execute("nx_checkpoint", {"unexpected": 1})
+ with pytest.raises(NXToolError):
+ rig.e.execute("nx_no_such_tool", {})
+ rig.e.enable_experimental = False
+ with pytest.raises(NXToolError):
+ rig.e.execute("nx_no_such_tool", {})
+ assert not rig.session.marks
+
+
+def test_open_reuses_loaded_part_with_equivalent_path_spelling(rig, tmp_path):
+ path = tmp_path / "project" / "base.prt"
+ path.parent.mkdir()
+ rig.part.FullPath = str(path.parent / "sub" / ".." / path.name)
+ opened = rig.e._open_part(str(path))
+ assert opened["already_loaded"]
+ assert len(rig.session.Parts) == 1
+
+
+def test_close_invalidates_automatically_unloaded_prototypes(rig, tmp_path):
+ child = Part(rig.session, tmp_path / "child.prt")
+ child_ref = rig.e._reference(child, "part", child, "Child")["id"]
+ target = rig.e._reference(rig.part, "part", rig.part, "Parent")["id"]
+ rig.part.Close = Mock(side_effect=lambda *args: rig.session.Parts.clear())
+ result = rig.e._close_part(save=False, part=target)
+ assert result["closed_count"] == 2 and result["remaining_count"] == 0
+ with pytest.raises(NXToolError) as error:
+ rig.e.objects.resolve(child_ref)
+ assert error.value.code == "NX_OBJECT_STALE"
+
+
+def test_operation_status_preserves_target_receipt_metadata(rig):
+ committed = mutation(rig)(operation_id="receipt-query-target")
+ status = rig.e.execute("nx_operation_status", {"operation_id": "receipt-query-target"})
+ for key in ["operation_id", "session_id", "mutation_outcome"]:
+ assert status[key] == committed[key]
+ assert status["query_operation_id"] != status["operation_id"]
+ missing = rig.e.execute("nx_operation_status", {"operation_id": "receipt-not-recorded"})
+ assert missing["operation_id"] == "receipt-not-recorded"
+ assert missing["mutation_outcome"] == "unknown" and missing["session_id"] is None
+
+
+def test_capability_filters_and_unit_conventions(rig):
+ from types import SimpleNamespace
+
+ rig.session.DexManager = SimpleNamespace()
+ rig.session.Measurement = SimpleNamespace()
+ one = rig.e._capabilities(tool="nx_close_part")
+ assert list(one["tools"]) == ["nx_close_part"] and one["units"] is None
+ group = rig.e._capabilities(prefix="nx_sheet")
+ assert all(name.startswith("nx_sheet") for name in group["tools"])
+ assert group["tool_count"] < group["total_tool_count"]
+ for params in [{"tool": "nx_missing"}, {"tool": "nx_close_part", "prefix": "nx_"}]:
+ with pytest.raises(NXToolError):
+ rig.e._capabilities(**params)
diff --git a/tests/test_release_acceptance.py b/tests/test_release_acceptance.py
new file mode 100644
index 0000000..80f2dec
--- /dev/null
+++ b/tests/test_release_acceptance.py
@@ -0,0 +1,231 @@
+"""Acceptance verifies installed bytes and never replays uncertain native work."""
+
+import io
+import json
+import runpy
+import zipfile
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+RUNNER = runpy.run_path(str(Path(__file__).resolve().parents[1] / "scripts/accept_release.py"))
+
+
+def package(tmp_path):
+ install = tmp_path / "install"
+ runtime = install / "venv/nx_mcp"
+ runtime.mkdir(parents=True)
+ (runtime / "__init__.py").write_bytes(b"version = 'test'\n")
+ release = {"version": "1.0", "commit": "a" * 40}
+ (install / "release.json").write_text(json.dumps(release))
+ (install / "source").mkdir()
+ (install / "source/file.py").write_bytes(b"source\n")
+ wheel = io.BytesIO()
+ with zipfile.ZipFile(wheel, "w") as z:
+ z.writestr("nx_mcp/__init__.py", (runtime / "__init__.py").read_bytes())
+ files = {
+ "release.json": json.dumps(release).encode(),
+ "source/file.py": b"source\n",
+ "wheels/nx_mcp-1.0-py3-none-any.whl": wheel.getvalue(),
+ }
+ files["manifest.json"] = json.dumps({k: RUNNER["digest"](v) for k, v in files.items()}).encode()
+ archive = tmp_path / "release.zip"
+ with zipfile.ZipFile(archive, "w") as z:
+ for name, data in files.items():
+ z.writestr(name, data)
+ return archive, RUNNER["digest"](archive.read_bytes()), release["commit"], install, runtime
+
+
+def test_release_checks_source_wheel_commit_and_archive(tmp_path):
+ args = package(tmp_path)
+ result = RUNNER["verify_package"](*args)
+ assert result["source_files_verified"] == result["runtime_files_verified"] == 1
+ assert result["loaded_process_bytes_attested"] is False
+ with pytest.raises(RuntimeError, match="SHA-256"):
+ RUNNER["verify_package"](args[0], "0" * 64, *args[2:])
+ with pytest.raises(RuntimeError, match="commit"):
+ RUNNER["verify_package"](*args[:2], "b" * 40, *args[3:])
+ (args[3] / "source/file.py").write_text("changed")
+ with pytest.raises(RuntimeError, match="Installed source mismatch"):
+ RUNNER["verify_package"](*args)
+
+
+def test_release_rejects_shadow_runtime_and_changed_wheel(tmp_path):
+ args = package(tmp_path)
+ (args[4] / "unexpected.py").write_text("shadow")
+ with pytest.raises(RuntimeError, match="Unpackaged"):
+ RUNNER["verify_package"](*args)
+ (args[4] / "unexpected.py").unlink()
+ (args[4] / "__init__.py").write_text("changed")
+ with pytest.raises(RuntimeError, match="differs from wheel"):
+ RUNNER["verify_package"](*args)
+
+
+@pytest.mark.parametrize("state", ["running", "failed"])
+def test_resume_does_not_repeat_uncertain_native_phase(tmp_path, state):
+ path = tmp_path / "acceptance.json"
+ path.write_text(
+ json.dumps({"identity": {"commit": "a"}, "phases": {"native": {"state": state}}})
+ )
+ with pytest.raises(RuntimeError, match="no automatic replay"):
+ RUNNER["prepare_report"](path, {"commit": "a"}, True)
+
+
+def test_resume_requires_same_inputs_and_complete_preservation(tmp_path):
+ path = tmp_path / "acceptance.json"
+ report = {"identity": {"commit": "a"}, "state": "verified", "phases": {}}
+ path.write_text(json.dumps(report))
+ assert RUNNER["prepare_report"](path, report["identity"], True) == report
+ with pytest.raises(RuntimeError, match="inputs differ"):
+ RUNNER["prepare_report"](path, {"commit": "b"}, True)
+ report["phases"]["native"] = {"state": "passed"}
+ path.write_text(json.dumps(report))
+ with pytest.raises(RuntimeError, match="reconcile session"):
+ RUNNER["prepare_report"](path, report["identity"], True)
+
+
+def test_phase_persists_running_before_work_and_failure_after(tmp_path):
+ path = tmp_path / "acceptance.json"
+ report = {"phases": {}}
+
+ def fail():
+ assert json.loads(path.read_text())["phases"]["native"]["state"] == "running"
+ raise RuntimeError("uncertain outcome")
+
+ with pytest.raises(RuntimeError, match="uncertain"):
+ RUNNER["phase"](report, path, "native", fail)
+ assert json.loads(path.read_text())["state"] == "failed"
+
+
+def test_dependency_pins_checked_not_only_pip_compatibility(tmp_path):
+ lock = tmp_path / "requirements.lock"
+ lock.write_text("mcp==1.0 \\\n --hash=sha256:abc\n")
+ assert RUNNER["verify_dependencies"](lock, lambda name: "1.0") == {"mcp": "1.0"}
+ with pytest.raises(RuntimeError, match="dependency mismatch"):
+ RUNNER["verify_dependencies"](lock, lambda name: "2.0")
+
+
+@pytest.mark.parametrize("path", ["../file", "/file", "C:/file", "folder\\file"])
+def test_package_paths_cannot_escape(path):
+ with pytest.raises(RuntimeError, match="Unsafe"):
+ RUNNER["safe_name"](path)
+
+
+def test_native_exit_zero_still_requires_preservation_evidence(tmp_path, monkeypatch):
+ (tmp_path / "native").mkdir()
+ receipt = tmp_path / "native/release-validation.json"
+ receipt.write_text(json.dumps({"passed": True, "session_restored": False}))
+ monkeypatch.setattr(
+ RUNNER["run_native"].__globals__["subprocess"],
+ "run",
+ lambda *args, **kwargs: SimpleNamespace(returncode=0),
+ )
+ with pytest.raises(RuntimeError, match="session_restored evidence"):
+ RUNNER["run_native"](tmp_path, tmp_path, 179)
+ receipt.write_text(json.dumps({"passed": True, "session_restored": True, "suites": []}))
+ result = RUNNER["run_native"](tmp_path, tmp_path, 179)
+ assert result["sha256"] == RUNNER["digest"](receipt.read_bytes())
+
+
+def test_native_failure_still_checks_original_session(tmp_path, monkeypatch):
+ execute = RUNNER["execute"]
+ namespace = execute.__globals__
+ snapshot = {"nx_version": "v2606", "tool_count": 179}
+ verified = []
+
+ async def read_snapshot():
+ return snapshot
+
+ monkeypatch.setattr(
+ namespace["importlib"].util,
+ "find_spec",
+ lambda name: SimpleNamespace(origin=str(tmp_path / "venv/nx_mcp/__init__.py")),
+ )
+ monkeypatch.setitem(namespace, "verify_package", lambda *args: {})
+ monkeypatch.setitem(namespace, "verify_dependencies", lambda *args: {})
+ monkeypatch.setattr(
+ namespace["subprocess"],
+ "run",
+ lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="ok"),
+ )
+ monkeypatch.setattr(
+ namespace["runpy"],
+ "run_path",
+ lambda *args: {
+ "snapshot": read_snapshot,
+ "verify_session": lambda a, b: verified.append((a, b)),
+ },
+ )
+
+ def fail_native(*args):
+ raise RuntimeError("native test failed")
+
+ monkeypatch.setitem(namespace, "run_native", fail_native)
+ args = SimpleNamespace(
+ install_root=tmp_path,
+ release_zip=tmp_path / "r.zip",
+ sha256="a",
+ expected_commit="b",
+ expected_tool_count=179,
+ expected_nx_version="v2606",
+ verify_only=False,
+ output=tmp_path,
+ )
+ report = {"phases": {}}
+ with pytest.raises(RuntimeError, match="native test failed"):
+ execute(args, tmp_path / "acceptance.json", report)
+ assert verified == [(snapshot, snapshot)]
+ assert report["state"] == "failed"
+ assert report["phases"]["session_preservation"]["state"] == "passed"
+
+
+def test_cli_accepts_new_shared_drive_output_when_canonical_resolution_fails(tmp_path, monkeypatch):
+ """Reproduce WinError 1005 without requiring a Windows VirtIO mount."""
+ main = RUNNER["main"]
+ namespace = main.__globals__
+ install = tmp_path / "install"
+ install.mkdir()
+ fixture = tmp_path / "authorized.step"
+ fixture.write_text("fixture")
+ archive = tmp_path / "release.zip"
+ archive.write_text("fixture")
+ output = tmp_path / "new-parent/acceptance"
+ error = OSError("The volume does not contain a recognized file system")
+ error.winerror = 1005
+
+ def unsupported_resolve(*args, **kwargs):
+ raise error
+
+ monkeypatch.setattr(Path, "resolve", unsupported_resolve)
+ monkeypatch.setattr(namespace["sys"], "platform", "win32")
+ monkeypatch.setattr(namespace["sys"], "version_info", (3, 12, 0))
+ monkeypatch.setattr(
+ namespace["sys"],
+ "argv",
+ [
+ "accept_release.py",
+ "--release-zip",
+ str(archive),
+ "--sha256",
+ "a" * 64,
+ "--expected-commit",
+ "b" * 40,
+ "--install-root",
+ str(install),
+ "--output",
+ str(output),
+ "--verify-only",
+ ],
+ )
+ monkeypatch.setenv("NX_MCP_URL", "http://127.0.0.1:8765/mcp")
+ monkeypatch.setenv("NX_VENDOR_STEP", str(fixture))
+ calls = []
+ monkeypatch.setitem(namespace, "execute", lambda args, receipt, report: calls.append(report))
+ main()
+ assert len(calls) == 1
+ assert (output / "acceptance.json").is_file()
+ assert calls[0]["identity"]["vendor_step"] == str(fixture)
+ assert calls[0]["identity"]["install_root"] == str(install)
+ assert calls[0]["identity"]["release_zip"] == str(archive)
+ assert not (install / "acceptance.lock").exists()
diff --git a/tests/test_release_engineering.py b/tests/test_release_engineering.py
new file mode 100644
index 0000000..c9ea3cb
--- /dev/null
+++ b/tests/test_release_engineering.py
@@ -0,0 +1,310 @@
+"""Preflight, ownership and verification failures for native release engineering."""
+
+from types import SimpleNamespace as NS
+from unittest.mock import MagicMock
+
+import pytest
+
+from nx_mcp.release_engineering import sheet_point
+from nx_mcp.runtime import NXToolError
+from tests.test_freeform_manufacturing import ff as _fixture
+
+
+@pytest.fixture
+def ff(rig, monkeypatch):
+ value = _fixture.__wrapped__(rig, monkeypatch)
+ value.part.Annotations = MagicMock()
+ value.part.DraftingViews = MagicMock()
+ value.part.Dimensions = []
+ return value
+
+
+@pytest.mark.parametrize("value", [None, [], [1], [1, 2, 3], [float("nan"), 2], ["x", 2]])
+def test_invalid_sheet_point(value):
+ with pytest.raises((NXToolError, ValueError)):
+ sheet_point(value)
+
+
+def test_view_edit_preflight_and_native_readback(ff):
+ obj = NS(Tag=2)
+ ff.e._drawing_object = lambda *_: obj
+ sheet = MagicMock()
+ ff.e._view_sheet = lambda _: sheet
+ ff.uf.Draw = MagicMock()
+ with pytest.raises(NXToolError):
+ ff.e._edit_drawing_view("view")
+ with pytest.raises(NXToolError):
+ ff.e._edit_drawing_view("view", scale=-1)
+ ff.uf.Draw.SetViewScale.assert_not_called()
+ ff.e._drawing_view_info = lambda _: {"scale": 2, "position": [5, 6], "object": {"id": "view"}}
+ assert ff.e._edit_drawing_view("view", position=[5, 6], scale=2)["modified"] == [{"id": "view"}]
+ with pytest.raises(NXToolError, match="alignment or scale"):
+ ff.e._edit_drawing_view("view", position=[7, 8])
+
+
+def test_section_rejects_nonplanar_or_parallel_directions(ff):
+ for step, arrow in [([1, 0, 0], [1, 0, 0]), ([0, 0, 1], [1, 0, 0])]:
+ with pytest.raises(NXToolError, match="perpendicular"):
+ ff.e._add_section_drawing_view("view", "edge", [10, 20], step, arrow)
+ ff.uf.Draw.CreateSimpleSxview.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "anchor",
+ [{}, {"version": 2}, {"version": 1, "owner_part": "part", "kind": "component", "handle": "h"}],
+)
+def test_anchor_schema_rejects_foreign_kinds(ff, anchor):
+ with pytest.raises(NXToolError):
+ ff.e._resolve_geometry_anchor(anchor)
+ ff.uf.Tag.AskTagOfHandle.assert_not_called()
+
+
+def test_anchor_owner_and_missing_entity_fail_closed(ff):
+ part = ff.e._work_part()
+ anchor = {"version": 1, "owner_part": part.FullPath, "kind": "body", "handle": "native"}
+ with pytest.raises(NXToolError, match="another part"):
+ ff.e._resolve_geometry_anchor({**anchor, "owner_part": "wrong"})
+ ff.uf.Tag.AskTagOfHandle.return_value = 912345
+ with pytest.raises(NXToolError, match="no longer exists"):
+ ff.e._resolve_geometry_anchor(anchor)
+ body = list(part.Bodies)[0]
+ ff.uf.Tag.AskTagOfHandle.return_value = body.Tag
+ assert ff.e._resolve_geometry_anchor(anchor)["object"]["kind"] == "body"
+
+
+@pytest.mark.parametrize(
+ "overrides",
+ [
+ {"kind": "approval"},
+ {"rows": []},
+ {"rows": [["one"]]},
+ {"widths": [-1, 20]},
+ {"rows": [[1, "two"]]},
+ {"rows": [["x" * 1025, "two"]]},
+ {"row_height": 0},
+ ],
+)
+def test_table_preflight_prevents_partial_authoring(ff, overrides):
+ args = {
+ "drawing": "sheet",
+ "kind": "revision",
+ "rows": [["A", "Initial"]],
+ "widths": [10, 20],
+ "position": [0, 0],
+ }
+ with pytest.raises(NXToolError):
+ ff.e._drawing_table(**{**args, **overrides})
+ ff.part.Annotations.TableSections.CreateTableSectionBuilder.assert_not_called()
+
+
+@pytest.fixture
+def drawing(ff, monkeypatch):
+ import sys
+
+ from tests.fakes import Object, point
+
+ d = NS(
+ DrawingSheet=NS(Unit=NS(Millimeters=2, Inches=1)),
+ DetailViewBuilder=NS(Types=NS(Circular=1)),
+ ViewScaleBuilder=NS(Type=NS(Ratio=1)),
+ )
+ monkeypatch.setitem(sys.modules, "NXOpen.Drawings", d)
+ ff.nx.Drawings = d
+ ff.nx.NXObject = NS(AttributeType=NS(String=1))
+ ff.uf.Tag.AskHandleFromTag.side_effect = lambda tag: str(tag)
+ sheet = Object("Sheet")
+ sheet.Length, sheet.Height, sheet.Units = 297, 210, 2
+ sheet.Open = MagicMock()
+ view = Object("Top")
+ view.OwningPart = ff.part
+ view.GetDrawingReferencePoint = lambda: point(100, 90, 0)
+ view.Matrix = NS(Xx=1, Xy=0, Xz=0)
+ view.HasUserAttribute = MagicMock(return_value=False)
+ view.SetAttribute = MagicMock()
+ view.UpdateAutomaticViewBound = MagicMock()
+ import contextlib
+
+ ff.e._drawing_save_context = lambda *a, **k: contextlib.nullcontext()
+ sheet.GetDraftingViews = lambda: [view]
+ ff.part.DrawingSheets = [sheet]
+ ff.e._drawing_object = lambda ref, kind: sheet if kind == "drawing_sheet" else view
+ ff.uf.Draw.AskViewBorders.return_value = [60, 70, 140, 110]
+ ff.uf.Draw.AskViewScale.return_value = (0, 1.0)
+ ff.uf.View.MapModelToDrawing.side_effect = lambda _, p: [p[0] + 100, p[1] + 90]
+ ff.part.SettingsManager = MagicMock()
+ style = ff.part.SettingsManager.CreateDrawingEditViewSettingsBuilder.return_value.ViewStyle
+ from nx_mcp.drawing_preferences import STYLE_PROPERTIES
+
+ for key, (group, prop) in STYLE_PROPERTIES.items():
+ setattr(getattr(style, group), prop, 1 if key.endswith(("font", "width")) else True)
+ ff.sheet, ff.view = sheet, view
+ return ff
+
+
+def test_sheet_units_and_physical_point_conversion(drawing):
+ f = drawing
+ assert f.e._drawing_view_info("v")["inside_sheet"]
+ f.sheet.Units = 1
+ assert f.e._sheet_units(f.sheet) == "in"
+ result = f.e._sheet_point3d(f.sheet, [1, 2])
+ assert (result.X, result.Y) == (25.4, 50.8)
+ assert f.e._drawing_coordinates(f.sheet, [1, 2])["position_mm"] == [25.4, 50.8]
+ f.part.PartUnits = "inch"
+ f.sheet.Units = 2
+ assert pytest.approx(1) == f.e._sheet_point3d(f.sheet, [25.4, 50.8]).X
+ f.sheet.Units = 912
+ with pytest.raises(NXToolError, match="Unknown native"):
+ f.e._sheet_units(f.sheet)
+
+
+def test_view_ownership_and_native_placement_verification(drawing):
+ f = drawing
+ f.e._place_drawing_view(f.view, f.sheet, [100, 90])
+ with pytest.raises(NXToolError, match="placement differs"):
+ f.e._place_drawing_view(f.view, f.sheet, [100, 91])
+ f.sheet.GetDraftingViews = lambda: []
+ with pytest.raises(NXToolError, match="one work-part sheet"):
+ f.e._view_sheet(f.view)
+
+
+def test_detail_maps_model_boundary_to_sheet_and_releases_builder(drawing):
+ f = drawing
+ b = MagicMock()
+ b.Commit.return_value = f.view
+ f.part.DraftingViews.CreateDetailViewBuilder.return_value = b
+ f.e._edit_drawing_view = lambda *a, **k: {"scale": k["scale"]}
+ assert f.e._add_detail_drawing_view("v", [0, 0, 0], 5, [100, 100], 3)["scale"] == 3
+ calls = f.part.Points.CreatePoint.call_args_list
+ assert calls[0].args[0].X == 100 and calls[1].args[0].X == 105
+ b.Destroy.assert_called_once()
+ b.Validate.return_value = False
+ with pytest.raises(NXToolError, match="did not validate"):
+ f.e._add_detail_drawing_view("v", [0, 0, 0], 5, [100, 100])
+ assert b.Destroy.call_count == 2
+
+
+def test_section_native_associativity_and_explicit_scale(drawing):
+ f = drawing
+ f.nx.UF.Drf = NS(Object=lambda: NS(), AssocType=NS(ARC_CENTER=2, END_POINT=1))
+ f.nx.TaggedObjectManager = NS(GetTaggedObject=lambda _: f.view)
+ f.e._edit_drawing_view = lambda *a, **k: {"scale": k["scale"]}
+ face_ref = f.ref(f.body.GetEdges()[0], "edge")
+ for assoc, expected, modifier in [("start", 1, 1), ("end", 1, 2), ("arc_center", 2, 0)]:
+ result = f.e._add_section_drawing_view(
+ "v", face_ref, [100, 100], [1, 0, 0], [0, 1, 0], 2, assoc
+ )
+ assert result["scale"] == 2
+ arg = f.uf.Draw.CreateSimpleSxview.call_args.args[5]
+ assert (arg.ObjectAssocType, arg.ObjectAssocModifier) == (expected, modifier)
+ with pytest.raises(NXToolError, match="cut_association"):
+ f.e._add_section_drawing_view("v", face_ref, [100, 100], [1, 0, 0], [0, 1, 0], 2, "bad")
+
+
+def test_detail_refresh_requires_surviving_parent(drawing):
+ import json
+
+ f = drawing
+ f.view.HasUserAttribute.return_value = True
+ f.view.GetStringAttribute = lambda _: json.dumps(
+ {"parent": "handle", "center": [0, 0, 0], "radius": 5}
+ )
+ f.uf.Tag.AskTagOfHandle.return_value = f.view.Tag
+ f.e._refresh_detail_boundaries()
+ f.part.DraftingViews.CreateDetailViewBuilder.return_value.Commit.assert_called_once()
+ f.uf.Tag.AskTagOfHandle.return_value = -100
+ with pytest.raises(NXToolError, match="parent no longer"):
+ f.e._refresh_detail_boundaries()
+
+
+def test_table_edits_preserve_native_identity_and_evaluated_cells(drawing):
+ from tests.fakes import Object
+
+ f = drawing
+ section = Object("Revision")
+ section.OwningPart, section.IsOccurrence = f.part, False
+ attrs = {}
+ section.SetAttribute = lambda k, v: attrs.__setitem__(k, v)
+ section.HasUserAttribute = lambda k, *_: k in attrs
+ section.GetStringAttribute = lambda k: attrs[k]
+ sheet_attrs = {}
+ sheet = f.e._drawing_object("s", "drawing_sheet")
+ sheet.SetAttribute = lambda k, v: sheet_attrs.__setitem__(k, v)
+ sheet.HasUserAttribute = lambda k, *_: k in sheet_attrs
+ sheet.GetStringAttribute = lambda k: sheet_attrs[k]
+ section.AnnotationOrigin = None
+ b = f.part.Annotations.TableSections.CreateTableSectionBuilder.return_value
+ b.Commit.return_value = section
+ f.uf.Tag.AskHandleFromTag.side_effect = lambda t: str(t)
+ tab = f.uf.Tabnot
+ data = [["", ""]]
+ tab.AskNmColumns.return_value = 2
+ tab.AskNmRows.side_effect = lambda _: len(data)
+ tab.AskNthRow.side_effect = lambda _, i: i
+ tab.AskNthColumn.side_effect = lambda _, i: i
+ tab.AskCellAtRowCol.side_effect = lambda r, c: (r, c)
+ tab.SetCellText.side_effect = lambda cell, v: data[cell[0]].__setitem__(cell[1], v)
+ tab.AskEvaluatedCellText.side_effect = lambda cell: data[cell[0]][cell[1]]
+ tab.AddRow.side_effect = lambda *_: data.append(["", ""])
+ tab.RemoveRow.side_effect = lambda i: data.pop(i)
+ args = {
+ "drawing": "s",
+ "kind": "revision",
+ "rows": [["REV", "TEXT"], ["A", "Initial"]],
+ "widths": [20, 70],
+ "position": [20, 250],
+ }
+ result = f.e._drawing_table(**args)
+ assert result["rows"] == args["rows"]
+ ref = result["table"]["id"]
+ args["rows"] = [["B", "Changed"]]
+ assert f.e._drawing_table(**args, table=ref)["table"]["id"] == ref
+ assert data == [["B", "Changed"]]
+ args["kind"] = "title_block"
+ with pytest.raises(NXToolError, match="same kind"):
+ f.e._drawing_table(**args, table=ref)
+ assert b.Commit.call_count == 1
+
+
+def test_assembly_refresh_unsatisfied_constraints_releases_network(drawing, monkeypatch):
+ import sys
+
+ f = drawing
+ monkeypatch.setitem(
+ sys.modules, "NXOpen.Positioning", NS(Constraint=NS(SolverStatus=NS(Solved=1)))
+ )
+ f.e._explosion_context = lambda: f.part
+ constraint = NS(GetConstraintStatus=lambda: 2)
+ f.e._assembly_constraints = lambda _: [constraint]
+ positioner = MagicMock()
+ f.part.ComponentAssembly.Positioner = positioner
+ with pytest.raises(NXToolError, match="did not solve"):
+ f.e._update_assembly_documentation()
+ positioner.ClearNetwork.assert_called_once()
+ positioner.EndAssemblyConstraints.assert_called_once()
+ f.part.Annotations.PartsLists = []
+ f.e._assembly_constraints = lambda _: []
+ f.e._explosions = lambda _: []
+ f.e._refresh_annotations = lambda: {"updated_count": 0}
+ result = f.e._update_assembly_documentation()
+ assert result["health"]["healthy"] and len(result["views"]) == 1
+
+
+def test_native_mass_unit_is_explicit(ff):
+ props = NS(Volume=8193.532, Dispose=MagicMock())
+ ff.part.MeasureManager.NewMassProperties = lambda *a: props
+ result = ff.e._measure_volume()
+ assert props.InformationUnit == ff.nx.MeasureBodies.AnalysisUnit.KilogramMillimeter
+ assert result["volume_mm3"] == 8193.532
+ props.Dispose.assert_called_once()
+
+
+def test_retained_dimensions_are_not_valid_measurements(drawing):
+ from tests.fakes import Object, point
+
+ d = Object("Retained dimension")
+ d.ComputedSize, d.IsRetained, d.AnnotationOrigin = 8, True, point(0, 0, 0)
+ drawing.part.Dimensions = [d]
+ result = drawing.e._list_dimensions()["dimensions"][0]
+ assert result["retained"] and not result["measurement_valid"]
+ d.IsRetained = False
+ assert drawing.e._list_dimensions()["dimensions"][0]["measurement_valid"]
diff --git a/tests/test_result_transport.py b/tests/test_result_transport.py
new file mode 100644
index 0000000..e48e332
--- /dev/null
+++ b/tests/test_result_transport.py
@@ -0,0 +1,49 @@
+"""Committed results remain retrievable when their payload exceeds bridge framing."""
+
+import pytest
+
+from nx_mcp.bridge import BridgeClient, BridgeServer
+from nx_mcp.result_transport import bound_result, read_result
+
+
+def test_large_result_pages_preserve_requested_cardinality(tmp_path):
+ full = {
+ "status": "success",
+ "operation_id": "large-operation",
+ "mutation_outcome": "committed",
+ "restore_id": "display_test",
+ "objects": [{"id": str(i), "data": "x" * 2000} for i in range(2032)],
+ }
+ bounded = bound_result(full, tmp_path)
+ assert bounded["mutation_outcome"] == "committed" and bounded["restore_id"] == "display_test"
+ key = bounded["full_result"]["id"]
+ page = read_result(tmp_path, key, "/objects", offset=100, limit=20)
+ assert len(page["value"]) == 20 and page["next_offset"] == 120 and page["total_count"] == 2032
+ assert page["value"][0]["id"] == "100"
+
+
+@pytest.mark.asyncio
+async def test_bridge_bounds_large_native_commit(tmp_path):
+ calls = []
+
+ def execute(method, params):
+ calls.append(method)
+ return {
+ "status": "success",
+ "mutation_outcome": "committed",
+ "operation_id": "large-operation",
+ "objects": ["x" * 2048] * 2032,
+ }
+
+ server = BridgeServer(execute, token="test", result_directory=tmp_path)
+ server.start()
+ try:
+ client = BridgeClient("127.0.0.1", server.port, token="test")
+ result = await client.call("mutate", {})
+ assert (
+ result["mutation_outcome"] == "committed"
+ and result["full_result"]["tool"] == "nx_read_result"
+ )
+ assert calls == ["mutate"]
+ finally:
+ server.stop()
diff --git a/tests/test_save_audit.py b/tests/test_save_audit.py
new file mode 100644
index 0000000..b83acae
--- /dev/null
+++ b/tests/test_save_audit.py
@@ -0,0 +1,80 @@
+"""Disk and native-state regressions for explicitly scoped saves."""
+
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from nx_mcp.save_audit import snapshot
+from tests.fakes import Part
+
+
+def child(rig, tmp_path):
+ original = rig.part
+ p = Part(rig.session, tmp_path / "child.prt")
+ p.Save()
+ p.IsModified = True
+ rig.session.Parts.Work = rig.session.Parts.Display = original
+ return p
+
+
+def test_receipt_identifies_saved_file_and_preserved_child(rig, tmp_path):
+ p = child(rig, tmp_path)
+ before = snapshot(rig.session)[p.Tag]
+ result = rig.e._save_part()
+ assert result["saved_files"] == [rig.part.FullPath]
+ assert result["unrelated_modified_parts"] == [
+ {"path": p.FullPath, "modified_before": True, "modified_after": True, "unchanged": True}
+ ]
+ assert snapshot(rig.session)[p.Tag] == before
+ assert result["target_file"]["after"]["sha256"]
+
+
+@pytest.mark.parametrize("change", ["flag", "disk"])
+def test_unexpected_child_change_is_partial_with_evidence(rig, tmp_path, change):
+ from pathlib import Path
+
+ p = child(rig, tmp_path)
+ save = rig.part.Save
+
+ def faulty_save(*args):
+ if change == "flag":
+ p.IsModified = False
+ else:
+ Path(p.FullPath).write_bytes(b"unintended write")
+ return save(*args)
+
+ rig.part.Save = faulty_save
+ with pytest.raises(NXToolError) as error:
+ rig.e._save_part()
+ assert error.value.details["mutation_outcome"] == "partial"
+ assert error.value.details["saved_files"] == [rig.part.FullPath]
+ assert error.value.details["unexpected_changes"][0]["path"] == p.FullPath
+
+
+def test_native_unsaved_status_is_not_reported_as_success(rig):
+ status = NS(
+ NumberUnsavedParts=1,
+ NumberUnsavedObjects=0,
+ GetPart=lambda i: rig.part,
+ GetStatus=lambda i: 123,
+ Dispose=Mock(),
+ )
+ rig.part.Save = Mock(return_value=status)
+ with pytest.raises(NXToolError) as error:
+ rig.e._save_part()
+ assert error.value.details["native_save_errors"] == [
+ {"path": rig.part.FullPath, "nx_code": 123}
+ ]
+ assert not error.value.details["saved_files"]
+ status.Dispose.assert_called_once()
+
+
+def test_preflight_read_failure_prevents_save(rig, monkeypatch):
+ monkeypatch.setattr("nx_mcp.save_audit.snapshot", Mock(side_effect=PermissionError("denied")))
+ rig.part.Save = Mock()
+ with pytest.raises(NXToolError) as error:
+ rig.e._save_part()
+ assert error.value.details["mutation_outcome"] == "not_started"
+ rig.part.Save.assert_not_called()
diff --git a/tests/test_sheet_metal.py b/tests/test_sheet_metal.py
new file mode 100644
index 0000000..25ae641
--- /dev/null
+++ b/tests/test_sheet_metal.py
@@ -0,0 +1,770 @@
+"""Sheet-metal contracts and failure recovery; these are not NX kernel tests."""
+
+import inspect
+import json
+import sys
+from pathlib import Path
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp import sheet_metal_server
+from nx_mcp.runtime import NXToolError
+from nx_mcp.sheet_metal import APPLICATION, CATALOG, field_schema
+from tests.fakes import Body, Edge, Face, Feature, Object, Sketch
+
+pytestmark = pytest.mark.fake_nx
+
+
+@pytest.fixture
+def sm(rig, monkeypatch):
+ r = rig
+ r.session.ApplicationName = APPLICATION
+ r.session.ApplicationSwitchImmediate = Mock(
+ side_effect=lambda name: setattr(r.session, "ApplicationName", name)
+ )
+ module = NS(
+ ApplicationContext=NS(NxSheetMetal=1),
+ SheetmetalBendState=NS(Bent=1, Flat=2),
+ Tab=Feature,
+ MultiFlange=Feature,
+ FlatPattern=Feature,
+ )
+ r.nx.Features.SheetMetal = module
+ monkeypatch.setitem(sys.modules, "NXOpen.Features", r.nx.Features)
+ monkeypatch.setitem(sys.modules, "NXOpen.Features.SheetMetal", module)
+ r.sm = module
+ r.nx.ObjectList = NS(DeleteOption=NS(Delete="delete"))
+ r.part.Features.SheetmetalManager = NS()
+ r.owned = []
+
+ def own(obj, kind):
+ obj.IsOccurrence = False
+ if isinstance(obj, Sketch):
+ obj.Feature = Feature()
+ obj.OwningPart = r.part
+ r.owned.append(obj)
+ return r.ref(obj, kind)
+
+ r.own = own
+ return r
+
+
+def test_public_signatures_match_executor_and_schema(sm):
+ for name, fn in vars(sheet_metal_server).items():
+ if name.startswith("nx_") and inspect.isfunction(fn):
+ native = sm.e._handlers[name]
+ assert set(inspect.signature(fn).parameters) == set(
+ inspect.signature(native).parameters
+ )
+ schemas = sm.e._sheet_metal_schema()
+ assert len(schemas["operations"]) == len(CATALOG)
+ for operation, spec in CATALOG.items():
+ result = sm.e._sheet_metal_schema(operation)
+ schema = result["parameters_schema"]
+ assert set(schema["required"]) <= set(schema["properties"])
+ assert not schema["additionalProperties"]
+ assert result["validation_status"] == spec["native_status"]
+ with pytest.raises(NXToolError, match="Unknown"):
+ sm.e._sheet_metal_schema("__dict__")
+
+
+def test_context_is_explicit_and_rejects_active_sketch(sm):
+ sm.session.ApplicationName = "UG_APP_MODELING"
+ with pytest.raises(NXToolError, match="context"):
+ sm.e._sm_require_context()
+ sm.session.ApplicationSwitchImmediate.assert_not_called()
+ result = sm.e.execute("nx_sheet_metal_context", {})
+ assert result["application"] == APPLICATION
+ assert not sm.session.marks
+ sm.session.ActiveSketch = object()
+ with pytest.raises(NXToolError, match="Finish"):
+ sm.e._sm_prepare()
+ with pytest.raises(NXToolError, match="Finish"):
+ sm.e._sm_require_context()
+ sm.session.ActiveSketch = None
+ sm.session.Parts.Display = None
+ with pytest.raises(NXToolError, match="work and display"):
+ sm.e._sm_prepare()
+
+
+def test_context_failure_and_batch(sm):
+ sm.session.ApplicationName = "UG_APP_MODELING"
+ sm.session.ApplicationSwitchImmediate.side_effect = None
+ with pytest.raises(NXToolError, match="did not enter"):
+ sm.e._sm_prepare()
+ sm.session.IsBatch = True
+ assert sm.e._sheet_metal_context()["application"] == "batch"
+ sm.e._sm_require_context()
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"section": "missing", "thickness": 2, "ignored": 1},
+ {"section": "missing", "thickness": float("nan")},
+ {"thickness": -1},
+ {"thickness": 0},
+ {"thickness": True},
+ ],
+)
+def test_bad_inputs_do_not_construct_native_builder(sm, params):
+ factory = Mock()
+ sm.part.Features.SheetmetalManager.CreateTabFeatureBuilder = factory
+ with pytest.raises(NXToolError):
+ sm.e.execute("nx_sheet_metal_feature", {"operation": "tab", "parameters": params})
+ factory.assert_not_called()
+ assert not list(sm.part.Bodies)
+
+
+@pytest.mark.parametrize(
+ "kind,value",
+ [
+ ("integer", True),
+ ("boolean", 1),
+ ("enum", "ValueOf"),
+ ("string", "bad\nname"),
+ ("point3d", [1, 2]),
+ ("direction", [0, 0, 0]),
+ ("flange_list", []),
+ ("face_pairs", [["same", "same"]]),
+ ("collector", []),
+ ("csys", {"origin": [0, 0, 0], "x_axis": [1, 0, 0], "y_axis": [1, 0, 0]}),
+ ("plane", {"origin": [0, 0, 0]}),
+ ],
+)
+def test_strict_nested_validation(sm, kind, value):
+ field = {"kind": kind, "values": ["Valid"], "fields": {}, "objects": "edge"}
+ with pytest.raises(NXToolError):
+ sm.e._sm_validate({"input": field}, {"input": value})
+
+
+def test_owned_typed_references_and_normalized_basis(sm):
+ face = Face()
+ ref = sm.own(face, "face")
+ assert sm.e._sm_reference(ref, "face_or_edge") is face
+ face.IsOccurrence = True
+ with pytest.raises(NXToolError, match="work part"):
+ sm.e._sm_reference(ref, "face")
+ with pytest.raises(NXToolError, match="typed"):
+ sm.e._sm_reference(12, "face")
+ edge = sm.own(Edge(), "edge")
+ with pytest.raises(NXToolError, match="unique"):
+ sm.e._sm_validate(
+ {"edges": {"kind": "collector", "objects": "edge"}}, {"edges": [edge, edge]}
+ )
+ sk = Sketch(sm.session)
+ sid = sm.own(sk, "sketch")
+ sm.session.ActiveSketch = sk
+ with pytest.raises(NXToolError, match="Finish"):
+ sm.e._sm_reference(sid, "sketch")
+ values = sm.e._sm_validate(
+ {"plane": {"kind": "plane"}}, {"plane": {"origin": [1, 2, 3], "normal": [0, 0, 2]}}
+ )
+ assert values["plane"]["normal"] == [0, 0, 1]
+
+
+def test_face_pair_edit_replaces_old_pairs(sm):
+ old = [(Face(), Face()), (Face(), Face())]
+ pairs = list(old)
+ builder = NS(
+ GetNumberOfFacePairs=lambda: len(pairs),
+ GetFacePair=lambda i: pairs[i],
+ RemoveFacePair=lambda a, b: pairs.remove((a, b)),
+ AddFacePair=lambda a, b: pairs.append((a, b)),
+ )
+ new = (Face(), Face())
+ sm.e._sm_apply(builder, {"pairs": {"kind": "face_pairs"}}, {"pairs": [new]})
+ assert pairs == [new]
+
+
+def test_flange_list_uses_dedicated_factory_and_owns_failed_entry(sm):
+ entries = []
+ sequence = NS(Clear=Mock(side_effect=lambda _: entries.clear()), Append=entries.append)
+ item = NS(Length=NS(RightHandSide="1"))
+ factory = Mock(return_value=item)
+ b = NS(
+ FlangePropertiesList=NS(
+ FeatureBendPropertiesList=sequence, CreateFlangeBendProperties=factory
+ )
+ )
+ fields = {
+ "flanges": {
+ "kind": "flange_list",
+ "fields": {"length": {"kind": "expression", "path": "Length"}},
+ }
+ }
+ sm.e._sm_apply(b, fields, {"flanges": [{"length": 20}]})
+ assert entries == [item] and item.Length.RightHandSide == "20"
+ sequence.Clear.assert_called_once_with("delete")
+ fields["flanges"]["fields"]["bad"] = {"kind": "expression", "path": "Missing"}
+ with pytest.raises(AttributeError):
+ sm.e._sm_apply(b, fields, {"flanges": [{"bad": 1}]})
+ assert entries == [item] # Destroy of the parent builder can now clean it up.
+
+
+def make_tab_builder(sm, fail=False):
+ b = NS(
+ Thickness=NS(RightHandSide="1"),
+ Section=None,
+ SetApplicationContext=Mock(),
+ Validate=Mock(return_value=True),
+ Destroy=Mock(),
+ )
+
+ def commit():
+ body = Body()
+ sm.part.Bodies.append(body)
+ if fail:
+ raise RuntimeError("native partial failure")
+ f = Feature(bodies=[body])
+ f.FeatureType = "Base Tab"
+ f.expressions = []
+ sm.part.Features.append(f)
+ return f
+
+ b.CommitFeature = Mock(side_effect=commit)
+ sm.part.Features.SheetmetalManager.CreateTabFeatureBuilder = Mock(return_value=b)
+ sm.e._engineering_section = Mock(return_value=object())
+ sm.e._update_model = Mock()
+ return b
+
+
+def test_native_failure_rolls_back_partial_body_and_disposes_builder(sm):
+ b = make_tab_builder(sm, fail=True)
+ sid = sm.own(Sketch(sm.session), "sketch")
+ with pytest.raises(NXToolError, match="native partial failure"):
+ sm.e.execute(
+ "nx_sheet_metal_feature",
+ {
+ "operation": "tab",
+ "parameters": {"section": sid, "thickness": 2},
+ "operation_id": "sm-failure",
+ },
+ )
+ assert len(sm.part.Bodies) == 0
+ b.Destroy.assert_called_once()
+ receipt = sm.e.store.get("sm-failure")
+ assert receipt["mutation_outcome"] == "rolled_back"
+
+
+def test_native_creation_and_idempotent_retry(sm):
+ b = make_tab_builder(sm)
+ sid = sm.own(Sketch(sm.session), "sketch")
+ params = {
+ "operation": "tab",
+ "parameters": {"section": sid, "thickness": 2},
+ "operation_id": "sm-once-01",
+ }
+ result = sm.e.execute("nx_sheet_metal_feature", params)
+ assert result["body_count"] == 1 and result["native_feature_type"] == "Base Tab"
+ assert result["requested_parameters"]["thickness"] == 2
+ assert sm.e.execute("nx_sheet_metal_feature", params)["replayed"]
+ b.CommitFeature.assert_called_once()
+
+
+@pytest.mark.parametrize("failure", ["commit", "destroy", "invalid", "empty"])
+def test_export_failure_removes_staging_and_preserves_existing(sm, tmp_path, failure):
+ obj = Feature()
+ obj.FeatureType = "FLAT_PATTERN"
+ feature = sm.own(obj, "feature")
+ b = NS(FlatPattern=NS(Value=None))
+
+ def commit():
+ Path(b.OutputFile).write_bytes(
+ b"" if failure == "empty" else b"invalid" if failure == "invalid" else b"0\nSECTION\n"
+ )
+ if failure == "commit":
+ raise RuntimeError("export failed")
+
+ def destroy():
+ if failure == "destroy":
+ raise RuntimeError("destroy failed")
+
+ b.Commit, b.Destroy = commit, Mock(side_effect=destroy)
+ sm.part.Features.SheetmetalManager.CreateExportFlatPatternBuilder = lambda: b
+ sm.sm.ExportFlatPatternBuilder = NS(
+ DxfRevisionType=NS(R2018=2018),
+ FileType=NS(Dxf=1, TrumpfGeo=2),
+ ExportLocationOptions=NS(Native=1),
+ )
+ with pytest.raises((NXToolError, RuntimeError)):
+ sm.e._export_flat_pattern(feature, "new.dxf")
+ assert not (tmp_path / "new.dxf").exists()
+ assert not list(tmp_path.glob(".nx-export-*"))
+ (tmp_path / "existing.dxf").write_text("preserve")
+ with pytest.raises(NXToolError):
+ sm.e._export_flat_pattern(feature, "existing.dxf")
+ assert (tmp_path / "existing.dxf").read_text() == "preserve"
+
+
+def test_schema_primitives_and_required_lists():
+ for kind in [
+ "expression",
+ "number",
+ "boolean",
+ "integer",
+ "string",
+ "point",
+ "point3d",
+ "direction",
+ "plane",
+ "csys",
+ "face_pairs",
+ "collector",
+ "reference_list",
+ "select_faces",
+ "select_edges",
+ "select_bodies",
+ "face",
+ "section",
+ ]:
+ schema = field_schema({"kind": kind})
+ assert "type" in schema or "oneOf" in schema
+ assert field_schema(CATALOG["flange"]["fields"]["flanges"])["items"]["required"] == [
+ "edges",
+ "length",
+ "angle",
+ ]
+ assert field_schema(CATALOG["joggle"]["fields"]["inputs"])["items"]["required"] == [
+ "faces",
+ "depth",
+ ]
+
+
+def test_standard_validation_rejects_before_commit(sm):
+ b = make_tab_builder(sm)
+ b.Validate.return_value = False
+ sid = sm.own(Sketch(sm.session), "sketch")
+ with pytest.raises(NXToolError, match="validation failed"):
+ sm.e.execute(
+ "nx_sheet_metal_feature",
+ {"operation": "tab", "parameters": {"section": sid, "thickness": 2}},
+ )
+ b.CommitFeature.assert_not_called()
+ b.Destroy.assert_called_once()
+
+
+def test_legacy_validator_is_not_used_for_valid_tab(sm):
+ b = make_tab_builder(sm)
+ b.ValidateBuilderData = Mock(side_effect=RuntimeError("Unreliable NX 2606 method"))
+ sid = sm.own(Sketch(sm.session), "sketch")
+ result = sm.e.execute(
+ "nx_sheet_metal_feature",
+ {"operation": "tab", "parameters": {"section": sid, "thickness": 2}},
+ )
+ assert result["body_count"] == 1
+ b.ValidateBuilderData.assert_not_called()
+ assert not hasattr(b, "Sketch") # External sketch sections are not consumed internally.
+
+
+def test_edit_preserves_application_context(sm):
+ b = make_tab_builder(sm)
+ b.GetApplicationContext = Mock(return_value=1)
+ f = Feature()
+ f.FeatureType = "Base Tab"
+ fid = sm.own(f, "feature")
+ sm.e.execute(
+ "nx_sheet_metal_feature",
+ {"operation": "tab", "feature": fid, "parameters": {"thickness": 3}},
+ )
+ b.SetApplicationContext.assert_not_called()
+ b.GetApplicationContext.assert_called_once()
+
+
+@pytest.fixture
+def preferences(sm, monkeypatch):
+ r = sm
+ modes = NS(Value=0, MaterialTable=1, ToolIdTable=2)
+ methods = NS(
+ NeutralFactorValue=0,
+ BendTable=1,
+ BendAllowanceFormula=2,
+ MaterialTable=3,
+ ToolTable=4,
+ BendAllowanceTable=5,
+ BendDeductionTable=6,
+ BendDeductionFormula=7,
+ Din6935Formula=8,
+ )
+ pref = NS(
+ SheetMetalPreferencesBuilder=NS(
+ ParameterEntryTypes=modes, BendDefinitionMethodOptions=methods
+ )
+ )
+ r.nx.Preferences = pref
+ monkeypatch.setitem(sys.modules, "NXOpen.Preferences", pref)
+ values = {
+ key: NS(RightHandSide="2")
+ for key in [
+ "MaterialThickness",
+ "BendRadius",
+ "NeutralFactor",
+ "BendReliefWidth",
+ "BendReliefDepth",
+ ]
+ }
+ values["NeutralFactor"].RightHandSide = ".33"
+ b = NS(
+ **values,
+ ParameterEntryType=0,
+ BendAllowanceFormula="",
+ BendDeductionFormula="",
+ Commit=Mock(),
+ Destroy=Mock(),
+ )
+ state = {"material": "", "tool": "", "method": 0, "table": ""}
+ b.SetMaterial = lambda x: state.update(material=x)
+ b.SetToolName = lambda x: state.update(tool=x)
+ b.SetBendDefinitionMethod = lambda x: state.update(method=x)
+ b.SetBendTable = lambda x: state.update(table=x)
+ manager = NS(
+ **{"Get" + key: (lambda value=exp: value) for key, exp in values.items()},
+ GetParameterEntryType=lambda: b.ParameterEntryType,
+ GetBendDefinitionMethod=lambda: state["method"],
+ GetMaterialName=lambda: state["material"],
+ GetToolName=lambda: state["tool"],
+ GetBendTable=lambda: state["table"],
+ GetBendAllowanceFormula=lambda: b.BendAllowanceFormula,
+ GetBendDeductionFormula=lambda: b.BendDeductionFormula,
+ GetMaterialNames=Mock(side_effect=RuntimeError("unsafe native catalog")),
+ CreateSheetMetalPreferencesBuilder=Mock(return_value=b),
+ )
+ r.part.Preferences = NS(SheetMetalPreferences=manager)
+ r.e._expression_record = lambda exp: {
+ "value": float(exp.RightHandSide),
+ "formula": exp.RightHandSide,
+ }
+ r.e._update_model = Mock()
+ r.pref_builder, r.pref_manager = b, manager
+ return r
+
+
+def test_defaults_are_read_back_without_unsafe_catalog_enumeration(preferences):
+ r = preferences
+ result = r.e._set_sheet_metal_defaults(
+ thickness=1.5,
+ bend_radius=3,
+ neutral_factor=0.4,
+ parameter_entry="Value",
+ bend_definition="NeutralFactorValue",
+ )
+ assert result["parameters"]["thickness"]["value"] == 1.5
+ assert result["parameters"]["bend_radius"]["value"] == 3
+ assert result["parameters"]["neutral_factor"]["value"] == 0.4
+ assert result["parameter_entry"] == "Value"
+ assert result["material_catalog_status"] == "unavailable"
+ r.pref_manager.GetMaterialNames.assert_not_called()
+ r.pref_builder.Destroy.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "params",
+ [
+ {},
+ {"thickness": 0},
+ {"neutral_factor": 2},
+ {"bend_radius": -1},
+ {"parameter_entry": "invalid"},
+ {"bend_definition": "ValueOf"},
+ {"material": ""},
+ {"tool": "bad\nname"},
+ {"bend_allowance_formula": ""},
+ {"bend_deduction_formula": "x\ny"},
+ {"bend_table": "missing.tbl"},
+ ],
+)
+def test_default_preflight_constructs_no_builder(preferences, params):
+ r = preferences
+ with pytest.raises(NXToolError):
+ r.e._set_sheet_metal_defaults(**params)
+ r.pref_manager.CreateSheetMetalPreferencesBuilder.assert_not_called()
+
+
+def test_silently_ignored_default_is_a_verification_failure(preferences):
+ r = preferences
+ r.pref_builder.Commit.side_effect = lambda: setattr(
+ r.pref_builder.MaterialThickness, "RightHandSide", "2"
+ )
+ with pytest.raises(NXToolError, match="did not retain"):
+ r.e._set_sheet_metal_defaults(thickness=4)
+ r.pref_builder.Destroy.assert_called_once()
+
+
+def test_material_and_bend_table_settings_are_verified(preferences, tmp_path):
+ r = preferences
+ table = tmp_path / "bend.tbl"
+ table.write_text("fixture")
+ result = r.e._set_sheet_metal_defaults(
+ parameter_entry="MaterialTable",
+ material="Aluminum",
+ tool="ToolA",
+ bend_definition="BendTable",
+ bend_table=str(table),
+ bend_allowance_formula="1",
+ bend_deduction_formula="2",
+ )
+ assert result["material"] == "Aluminum" and result["tool"] == "ToolA"
+ assert result["bend_table"] == str(table)
+ r.pref_builder.SetMaterial = lambda _: None
+ with pytest.raises(NXToolError, match="did not retain requested material"):
+ r.e._set_sheet_metal_defaults(material="MissingMaterial")
+
+
+def test_assignable_collector_is_created_before_binding_rules(sm):
+ collected = NS(ReplaceRules=Mock())
+ sm.part.ScCollectors = NS(CreateCollector=Mock(return_value=collected))
+ sm.part.ScRuleFactory = NS(CreateRuleFaceDumb=Mock(return_value="rule"))
+ builder = NS(FaceCollector=None)
+ faces = [Face()]
+ sm.e._sm_apply(
+ builder,
+ {
+ "faces": {
+ "kind": "collector",
+ "path": "FaceCollector",
+ "objects": "face",
+ "assign": True,
+ }
+ },
+ {"faces": faces},
+ )
+ assert builder.FaceCollector is collected
+ collected.ReplaceRules.assert_called_once_with(["rule"], False)
+ sm.part.ScRuleFactory.CreateRuleFaceDumb.assert_called_once_with(faces)
+
+
+def test_legacy_sheet_metal_builder_without_context_methods(sm):
+ b = make_tab_builder(sm)
+ del b.SetApplicationContext
+ sid = sm.own(Sketch(sm.session), "sketch")
+ result = sm.e.execute(
+ "nx_sheet_metal_feature",
+ {"operation": "tab", "parameters": {"section": sid, "thickness": 2}},
+ )
+ assert result["body_count"] == 1
+
+
+def test_assignable_section_is_not_read_before_initialization(sm, monkeypatch):
+ class NativeBuilder:
+ @property
+ def Section(self):
+ raise RuntimeError("Native getter fails before initialization")
+
+ @Section.setter
+ def Section(self, section):
+ self.assigned = section
+
+ builder = NativeBuilder()
+ sketch = Sketch(sm.session)
+ section = object()
+ monkeypatch.setattr(sm.e, "_engineering_section", lambda value: section)
+ sm.e._sm_apply(
+ builder,
+ {"section": {"kind": "section", "path": "Section", "assign": True}},
+ {"section": sketch},
+ )
+ assert builder.assigned is section
+
+
+def test_nested_catalog_required_fields_exist():
+ def check(spec):
+ assert set(spec.get("required", [])) <= set(spec.get("fields", {}))
+ for field in spec.get("fields", {}).values():
+ check(field)
+
+ for spec in CATALOG.values():
+ check(spec)
+
+
+@pytest.fixture
+def annotation_rig(sm, monkeypatch):
+ r = sm
+ module = NS(SheetMetalPMIBuilder=NS(Types=NS(Body="body", Bend="bend")))
+ r.nx.Annotations = module
+ monkeypatch.setitem(sys.modules, "NXOpen.Annotations", module)
+ body = Body()
+ r.body_id = r.own(body, "body")
+ r.face = Face()
+ r.face.GetBody = lambda: body
+ r.face_id = r.own(r.face, "face")
+ r.note = Object("PMI")
+ r.note.OwningPart = r.part
+ r.note.GetText = lambda: list(r.lines)
+ r.lines = []
+ r.builder = NS(
+ SelectedBody=NS(Value=None),
+ SelectedFace=NS(Clear=Mock(), Add=Mock()),
+ AssociatedObjects=NS(Nxobjects=NS(Clear=Mock(), Add=Mock())),
+ Text=NS(TextBlock=NS(SetText=lambda lines: setattr(r, "lines", list(lines)))),
+ Origin=NS(SetInferRelativeToGeometry=Mock(), Origin=NS(SetValue=Mock())),
+ Validate=Mock(return_value=True),
+ Commit=Mock(side_effect=lambda: r.part.Notes.append(r.note) or r.note),
+ GetCommittedObjects=Mock(return_value=[r.note]),
+ Destroy=Mock(),
+ )
+ manager = r.part.Features.SheetmetalManager
+ manager.CreateSheetMetalPmiBuilder = Mock(return_value=r.builder)
+ manager.IsSheetmetalBody = lambda b: b is body
+ manager.GetBodyThickness = lambda b: 2.0
+ manager.GetBendParameters = lambda f: NS(InnerRadius=3.0, BendAngle=90.0, NeutralFactor=0.33)
+ return r
+
+
+def test_annotation_has_measured_text_and_geometry_association(annotation_rig):
+ r = annotation_rig
+ result = r.e.execute(
+ "nx_sheet_metal_annotation",
+ {
+ "kind": "bend",
+ "body": r.body_id,
+ "faces": [r.face_id],
+ "position": [10, 20, 30],
+ },
+ )
+ assert "90.000 deg" in result["text"][0][1]
+ assert result["measured_parameters"]["bends"][0]["inner_radius"] == 3
+ assert "snapshot" in result["text_semantics"].lower()
+ r.builder.AssociatedObjects.Nxobjects.Add.assert_called_once_with([r.face])
+ assert result["annotation_count"] == 1
+ r.builder.Destroy.assert_called_once()
+
+
+def test_partial_annotation_commit_rolls_back_and_destroys_builder(annotation_rig):
+ r = annotation_rig
+
+ def fail():
+ r.part.Notes.append(r.note)
+ raise RuntimeError("native annotation failure")
+
+ r.builder.Commit.side_effect = fail
+ with pytest.raises(NXToolError) as error:
+ r.e.execute(
+ "nx_sheet_metal_annotation",
+ {
+ "kind": "body",
+ "body": r.body_id,
+ "position": [0, 0, 0],
+ },
+ )
+ assert error.value.details["mutation_outcome"] == "rolled_back"
+ assert not r.part.Notes
+ r.builder.Destroy.assert_called_once()
+
+
+def test_explicit_edge_section_validates_ownership_before_builder(sm):
+ edge = Edge()
+ edge_id = sm.own(edge, "edge")
+ values = sm.e._sm_validate(
+ {"section": {"kind": "section"}},
+ {
+ "section": {"edges": [edge_id], "help_point": [1, 2, 3]},
+ },
+ )
+ assert values["section"]["edges"] == [edge]
+ for invalid in [
+ {"edges": [edge_id]},
+ {"edges": [edge_id], "curves": [edge_id], "help_point": [0, 0, 0]},
+ {"curves": [edge_id], "help_point": [0, 0, 0]},
+ ]:
+ with pytest.raises(NXToolError):
+ sm.e._sm_validate({"section": {"kind": "section"}}, {"section": invalid})
+
+
+def test_path_sketch_uses_arc_length_and_returns_actual_frame(sm, monkeypatch):
+ g = NS(OnPathDimensionBuilder=NS(UpdateReason=NS(Path="path")))
+ sm.nx.GeometricUtilities = g
+ monkeypatch.setitem(sys.modules, "NXOpen.GeometricUtilities", g)
+ sm.nx.SketchAlongPathBuilder = NS(
+ PlaneOrientationType=NS(NormalToPath="normal"),
+ SketchOrientationType=NS(Automatic="auto", RelativeToFace="face"),
+ )
+ edge = sm.own(Edge(), "edge")
+ sk = Sketch(sm.session)
+ sk.OwningPart = sm.part
+ sk.Activate = Mock()
+ builder = NS(
+ Section=object(),
+ PlaneLocation=NS(Expression=NS(RightHandSide="0"), Update=Mock()),
+ Validate=Mock(return_value=True),
+ Commit=Mock(return_value=sk),
+ Destroy=Mock(),
+ )
+ sm.part.Sketches.CreateSketchAlongPathBuilder = Mock(return_value=builder)
+ monkeypatch.setattr(sm.e, "_sm_section", Mock())
+ result = sm.e.execute(
+ "nx_create_path_sketch", {"edges": [edge], "help_point": [0, 0, 0], "percent": 25}
+ )
+ assert builder.PlaneLocation.IsParameterUsed is False
+ assert builder.PlaneLocation.IsPercentUsed is True
+ assert float(builder.PlaneLocation.Expression.RightHandSide) == 25
+ assert result["frame"]["origin"] == [0, 0, 0]
+ assert result["position_convention"] == "arc_length_percent"
+ builder.Destroy.assert_called_once()
+ for invalid in [-1, 101, True, float("nan")]:
+ with pytest.raises(NXToolError):
+ sm.e._create_path_sketch([edge], [0, 0, 0], percent=invalid)
+ assert sm.part.Sketches.CreateSketchAlongPathBuilder.call_count == 1
+
+
+def test_secondary_tab_rejects_inconsistent_thickness_before_builder(sm):
+ b = make_tab_builder(sm)
+ body = Body()
+ body_id = sm.own(body, "body")
+ sketch_id = sm.own(Sketch(sm.session), "sketch")
+ sm.part.Features.SheetmetalManager.GetBodyThickness = lambda target: 2
+ with pytest.raises(NXToolError, match="must match"):
+ sm.e.execute(
+ "nx_sheet_metal_feature",
+ {
+ "operation": "tab",
+ "parameters": {
+ "section": sketch_id,
+ "target_body": body_id,
+ "is_secondary": True,
+ "thickness": 3,
+ },
+ },
+ )
+ b.CommitFeature.assert_not_called()
+ sm.part.Features.SheetmetalManager.CreateTabFeatureBuilder.assert_not_called()
+
+
+@pytest.mark.parametrize("operation", ["flange", "advanced_flange", "unbend", "rebend"])
+def test_guided_examples_match_creation_contract_and_native_evidence(sm, operation):
+ from jsonschema import Draft202012Validator
+
+ result = sm.e._sheet_metal_schema(operation)
+ Draft202012Validator(result["parameters_schema"]).validate(result["example_parameters"])
+ source = result["example_evidence"]["source"]
+ path, _, pointer = source.partition("#")
+ evidence_path = Path(__file__).parents[1] / path
+ assert evidence_path.is_file()
+ if pointer:
+ evidence = json.loads(evidence_path.read_text())
+ for key in pointer.strip("/").split("/"):
+ evidence = evidence[key]
+ assert evidence["source_fixture"]
+ if "parameters" in evidence:
+ assert result["example_parameters"] == evidence["parameters"]
+ assert len(result["prerequisites"]) > 3
+
+
+def test_unbend_guidance_distinguishes_stationary_web_from_bend_strip(sm):
+ result = sm.e._sheet_metal_schema("unbend")
+ for key in ["parameters_schema", "edit_parameters_schema"]:
+ fields = result[key]["properties"]
+ assert "items[].bends[].face.id" in fields["face_collector"]["description"]
+ assert "not the flattened bend strip" in fields["reference_entity"]["description"]
+ assert (
+ "Edge-based stationary references were not exercised" in result["example_evidence"]["scope"]
+ )
+
+
+def test_advanced_flange_does_not_claim_reference_modes_are_verified(sm):
+ result = sm.e._sheet_metal_schema("advanced_flange")
+ assert "unverified" in result["parameters_schema"]["properties"]["type"]["description"]
+ assert result["parameters_schema"]["required"] == ["edges"]
+ assert "type" not in result["example_parameters"]
diff --git a/tests/test_tools/test_file_ops.py b/tests/test_tools/test_file_ops.py
index a3a25ca..2f475b6 100644
--- a/tests/test_tools/test_file_ops.py
+++ b/tests/test_tools/test_file_ops.py
@@ -40,6 +40,7 @@ def _make_mock_nxopen():
base_part.Units.Inches = "Inches"
base_part.SaveComponents = MagicMock()
base_part.SaveComponents.TrueValue = "TrueValue"
+ base_part.SaveComponents.FalseValue = "FalseValue"
base_part.CloseAfterSave = MagicMock()
base_part.CloseAfterSave.FalseValue = "FalseValue"
base_part.CloseModified = MagicMock()
diff --git a/tests/test_tools/test_measure.py b/tests/test_tools/test_measure.py
index 0a8484c..c584649 100644
--- a/tests/test_tools/test_measure.py
+++ b/tests/test_tools/test_measure.py
@@ -45,6 +45,7 @@ def _make_named(name):
# --- Features ---
mock_work_part.Features = MagicMock()
mock_work_part.Features.ToArray = MagicMock(return_value=mock_features)
+ mock_work_part.Features.__iter__.side_effect = lambda: iter(mock_features)
# --- Bodies ---
mock_work_part.Bodies = MagicMock()
diff --git a/tests/test_tools/test_modeling.py b/tests/test_tools/test_modeling.py
index e5a7cd0..8dee706 100644
--- a/tests/test_tools/test_modeling.py
+++ b/tests/test_tools/test_modeling.py
@@ -87,6 +87,7 @@ def _make_named(name):
mock_sketches = [_make_named("Circle1"), _make_named("Path1")]
mock_work_part.Sketches = MagicMock()
mock_work_part.Sketches.ToArray = MagicMock(return_value=mock_sketches)
+ mock_work_part.Sketches.__iter__.side_effect = lambda: iter(mock_sketches)
# --- Curves ---
mock_work_part.Curves = MagicMock()
diff --git a/tests/test_ui_recovery.py b/tests/test_ui_recovery.py
new file mode 100644
index 0000000..f19e389
--- /dev/null
+++ b/tests/test_ui_recovery.py
@@ -0,0 +1,283 @@
+"""UI handoff and scheduler failures; Win32 calls are explicit seams."""
+
+import ctypes
+import os
+import threading
+import time
+from types import SimpleNamespace as NS
+from unittest.mock import Mock
+
+import pytest
+
+from nx_mcp import interactive
+from nx_mcp.bridge import BridgeDescriptor
+from nx_mcp.interactive import ControlPanel, InteractiveHost
+from nx_mcp.runtime import NXToolError
+from tests.fakes import Body, Collection
+
+pytestmark = pytest.mark.fake_nx
+
+
+@pytest.fixture
+def host(rig, tmp_path, monkeypatch):
+ lock = {"value": 0}
+ enabled = {"value": True}
+ ui = NS(
+ AskLockStatus=lambda: lock["value"],
+ CanOpenPart=lambda: not lock["value"],
+ LockAccess=lambda: lock.update(value=1),
+ UnlockAccess=lambda: lock.update(value=0),
+ )
+ rig.nx.UI = NS(Status=NS(Lock=1))
+ h = InteractiveHost.__new__(InteractiveHost)
+ h.__dict__.update(
+ thread=threading.get_ident(),
+ mode="manual",
+ owns_lock=False,
+ window_disabled=False,
+ ui=ui,
+ nx=rig.nx,
+ session=rig.session,
+ executor=rig.e,
+ main_hwnd=1,
+ native_thread=123,
+ ticks=0,
+ completed=0,
+ running_method=None,
+ operation_started=None,
+ last_duration_seconds=None,
+ last_method=None,
+ last_error=None,
+ started=time.time(),
+ auto_start=False,
+ busy=False,
+ stopped=False,
+ stop_requested=False,
+ requested_mode=None,
+ stop_file=tmp_path / "stop",
+ state_dir=tmp_path,
+ panel=NS(update=Mock(), close=Mock()),
+ dispatcher=NS(drain=Mock(), stop=Mock()),
+ server=NS(stop=Mock()),
+ timer=7,
+ )
+ h.user = NS(
+ EnableWindow=lambda _, v: enabled.update(value=v),
+ IsWindowEnabled=lambda _: enabled["value"],
+ KillTimer=Mock(),
+ )
+ monkeypatch.setattr(
+ ctypes, "windll", NS(kernel32=NS(GetCurrentThreadId=lambda: 123)), raising=False
+ )
+ h.descriptor = BridgeDescriptor.create(12345, "test")
+ h.descriptor_path = tmp_path / "bridge.json"
+ h.descriptor.write(h.descriptor_path)
+ return h
+
+
+def test_manual_handoff_invalidates_references_and_checkpoints(host, rig):
+ body = Body()
+ rig.part.Bodies.append(body)
+ ref = rig.ref(body)
+ rig.e._checkpoint()
+ assert host.control("agent")["actual_ui_lock"]
+ host.control("manual")
+ assert not host.owns_lock and host.status()["nx_window_input_enabled"]
+ assert not rig.e._checkpoints
+ with pytest.raises(NXToolError):
+ rig.e.objects.resolve(ref)
+ with pytest.raises(NXToolError):
+ host.control("bad")
+ host.ui.CanOpenPart = lambda: False
+ with pytest.raises(NXToolError, match="dialog"):
+ host.control("agent")
+ host.thread = -1
+ with pytest.raises(RuntimeError):
+ host.control("manual")
+ with pytest.raises(RuntimeError):
+ host.execute("nx_status", {})
+
+
+def test_operation_failure_relocks_ui_and_refresh_failure_is_warning(host, rig):
+ with pytest.raises(NXToolError, match="paused"):
+ host.execute("nx_list_bodies", {})
+ host.execute("nx_ui_control", {"mode": "agent"})
+ assert host.execute("nx_status", {})["ui"]["mode"] == "agent"
+ rig.e._handlers["nx_test_fail"] = Mock(side_effect=RuntimeError("native error"))
+ with pytest.raises(NXToolError):
+ host.execute("nx_test_fail", {})
+ assert host.ui.AskLockStatus() == 1
+ rig.part.ModelingViews.WorkView.UpdateDisplay.side_effect = RuntimeError("refresh")
+ result = host.execute("nx_list_bodies", {})
+ assert not any("View refresh" in w for w in result["warnings"])
+ rig.e._handlers["nx_test_edit"] = lambda: {}
+ edited = host.execute("nx_test_edit", {})
+ assert any("View refresh" in w for w in edited["warnings"])
+ rig.part.DrawingSheets = Collection()
+ rig.part.DrawingSheets.CurrentDrawingSheet = object()
+ drawing = host.execute("nx_test_edit", {})
+ assert not any("View refresh" in w for w in drawing["warnings"])
+ host.ui.LockAccess = Mock(side_effect=RuntimeError("lock failure"))
+ host.execute("nx_list_bodies", {})
+ assert host.mode == "manual" and "Cannot restore" in host.last_error
+
+
+def test_tick_modes_status_persistence_and_exception_handoff(host):
+ host.auto_start = True
+ host.tick()
+ assert host.mode == "agent" and not host.auto_start
+ host.requested_mode = "manual"
+ host.ticks = 9
+ host.tick()
+ assert host.mode == "manual" and (host.state_dir / "ui-state.json").is_file()
+ host.requested_mode = "bad"
+ host.tick()
+ assert "mode must" in host.last_error
+ host.auto_start = True
+ host.ui.CanOpenPart = lambda: False
+ host.tick()
+ assert host.auto_start
+ host.dispatcher.drain.side_effect = RuntimeError("dispatcher")
+ host.tick()
+ assert not host.busy and host.last_error == "dispatcher"
+ host.busy = True
+ n = host.ticks
+ host.tick()
+ assert host.ticks == n
+ host.busy = False
+ host.stop_requested = True
+ host.tick()
+ assert host.stopped and not host.descriptor_path.exists()
+ host.user.KillTimer.assert_called_once()
+ host.server.stop.assert_called_once()
+
+
+def test_stop_does_not_delete_another_session_descriptor(host):
+ replacement = BridgeDescriptor.create(12346, "other")
+ replacement.write(host.descriptor_path)
+ host.stop()
+ assert host.descriptor_path.exists()
+ host.descriptor_path.write_text("malformed")
+ host.stop()
+
+
+def test_panel_commands_and_cleanup(host):
+ panel = ControlPanel.__new__(ControlPanel)
+ panel.host = host
+ panel.user = NS(
+ DefWindowProcW=Mock(return_value=9),
+ SetWindowTextW=Mock(),
+ UpdateWindow=Mock(),
+ DestroyWindow=Mock(),
+ UnregisterClassW=Mock(),
+ )
+ panel.label = 2
+ panel.hwnd = 3
+ panel._class = NS(lpszClassName="class")
+ for code, mode in [(101, "manual"), (102, "agent")]:
+ assert panel._message(1, 0x111, code, 0) == 0 and host.requested_mode == mode
+ panel._message(1, 0x111, 103, 0)
+ assert host.stop_requested
+ host.stop_requested = False
+ panel._message(1, 0x10, 0, 0)
+ assert host.stop_requested
+ assert panel._message(1, 999, 0, 0) == 9
+ assert panel._message(1, 0x111, None, 0) == 9 and host.last_error
+ panel.update("hello")
+ panel.close()
+ panel.user.DestroyWindow.assert_called_once_with(3)
+
+
+def test_main_window_selection_requires_own_visible_window(monkeypatch):
+ user = NS(
+ EnumWindows=Mock(),
+ GetWindowThreadProcessId=Mock(),
+ GetWindowRect=Mock(),
+ IsWindowVisible=Mock(return_value=True),
+ )
+ user.EnumWindows.side_effect = lambda fn, _: [fn(h, 0) for h in (1, 2, 3)]
+ user.GetWindowThreadProcessId.side_effect = lambda h, p: setattr(
+ p._obj, "value", os.getpid() if h != 3 else os.getpid() + 1
+ )
+
+ def rect(h, p):
+ p._obj.right = h * 100
+ p._obj.bottom = 100
+
+ user.GetWindowRect.side_effect = rect
+ monkeypatch.setattr(ctypes, "WinDLL", lambda *a, **k: user, raising=False)
+ monkeypatch.setattr(ctypes, "WINFUNCTYPE", lambda *a: lambda fn: fn, raising=False)
+ assert interactive.nx_main_window() == 2
+ user.IsWindowVisible.return_value = False
+ with pytest.raises(RuntimeError, match="visible"):
+ interactive.nx_main_window()
+
+
+def test_start_reuses_live_host_and_cleans_partial_initialization(monkeypatch, host):
+ monkeypatch.setattr(interactive, "_host", host)
+ monkeypatch.setattr(interactive, "_retired", [])
+ assert interactive.start("unused", "unused")["pid"] == os.getpid()
+ host.stopped = True
+ resources = NS(user=NS(KillTimer=Mock()), server=NS(stop=Mock()), panel=NS(close=Mock()))
+
+ def fail(self, *_):
+ self.timer = 2
+ self.user = resources.user
+ self.server = resources.server
+ self.panel = resources.panel
+ raise RuntimeError("initialization failed")
+
+ monkeypatch.setattr(InteractiveHost, "__init__", fail)
+ with pytest.raises(RuntimeError):
+ interactive.start("unused", "unused")
+ resources.server.stop.assert_called_once()
+ resources.panel.close.assert_called_once()
+ assert len(interactive._retired) == 2
+
+
+def test_running_snapshot_is_readable_without_ui_queue_or_nx_calls(host, rig):
+ host.control("agent")
+ observed = []
+
+ def operation():
+ def reader():
+ observed.append(host.dispatch("nx_ui_control", {"mode": "status"}))
+
+ thread = threading.Thread(target=reader)
+ thread.start()
+ thread.join(1)
+ assert not thread.is_alive()
+ state = __import__("json").loads((host.state_dir / "ui-state.json").read_text())
+ assert state["running_method"] == "nx_test_busy"
+ assert "Running nx_test_busy" in host.panel.update.call_args.args[0]
+ return {}
+
+ rig.e._handlers["nx_test_busy"] = operation
+ host.execute("nx_test_busy", {})
+ assert observed[0]["activity"] == "running"
+ assert observed[0]["snapshot_only"] is True
+ assert observed[0]["operation_elapsed_seconds"] >= 0
+ host.dispatcher.drain.assert_not_called()
+ assert host.status()["activity"] == "reserved_idle"
+ assert host.status()["last_duration_seconds"] >= 0
+ assert "intentionally reserved" in host.panel_text()
+ assert not host.status()["nx_window_input_enabled"]
+
+
+def test_diagnostic_write_failure_does_not_fail_committed_operation(host, rig):
+ host.control("agent")
+ host.publish = Mock(side_effect=OSError("disk full"))
+ result = host.execute("nx_list_bodies", {})
+ assert result["status"] == "success"
+ assert host.running_method is None and host.operation_started is None
+ assert host.owns_lock
+
+
+def test_panel_unchanged_text_does_not_repaint(host):
+ panel = ControlPanel.__new__(ControlPanel)
+ panel.user = NS(SetWindowTextW=Mock(), UpdateWindow=Mock())
+ panel.label, panel.hwnd = 2, 3
+ panel.update("Idle")
+ panel.update("Idle")
+ panel.user.SetWindowTextW.assert_called_once()
+ assert panel.user.UpdateWindow.call_count == 2
diff --git a/tests/test_visual_tools.py b/tests/test_visual_tools.py
new file mode 100644
index 0000000..c979fa2
--- /dev/null
+++ b/tests/test_visual_tools.py
@@ -0,0 +1,72 @@
+from types import SimpleNamespace
+
+import pytest
+
+from nx_mcp.runtime import NXToolError
+from nx_mcp.visual_tools import VisualToolsMixin, unit_normal
+
+
+def test_section_normal_rejects_degenerate_or_nonfinite_planes():
+ for value in ([0, 0, 0], [float("nan"), 0, 1], [0, 1]):
+ with pytest.raises(NXToolError):
+ unit_normal(value)
+ assert unit_normal([0, 0, 5]) == [0, 0, 1]
+
+
+def test_restore_preflights_all_references_before_any_mutation():
+ host = VisualToolsMixin()
+ host._visual_part = lambda: None
+ host._work_part = lambda: None
+ host._part_id = lambda p: "part-1"
+ host._display_snapshots = {
+ "snapshot": {
+ "part_id": "part-1",
+ "records": [
+ {"object": {"id": "live"}, "blanked": True},
+ {"object": {"id": "stale"}, "blanked": False},
+ ],
+ }
+ }
+ changes = []
+
+ def resolve(ref):
+ if ref == "stale":
+ raise NXToolError("NX_OBJECT_STALE", "closed part")
+ return SimpleNamespace(Blank=lambda: changes.append("blank"))
+
+ host._resolve = resolve
+ with pytest.raises(NXToolError, match="closed part"):
+ host._restore_display("snapshot")
+ assert changes == [] and "snapshot" in host._display_snapshots
+
+
+def test_restore_rejects_out_of_order_changes():
+ host = VisualToolsMixin()
+ host._visual_part = lambda: None
+ host._work_part = lambda: None
+ host._part_id = lambda p: "part-1"
+ host._display_snapshots = {
+ key: {"part_id": "part-1", "records": []} for key in ("first", "second")
+ }
+ with pytest.raises(NXToolError, match="reverse order"):
+ host._restore_display("first")
+
+
+@pytest.mark.asyncio
+async def test_visual_tools_publish_enums_and_native_capture_description(tmp_path):
+ from nx_mcp.server import create_server
+ from nx_mcp.workspace import Workspace
+
+ server = create_server(SimpleNamespace(), Workspace(tmp_path), enable_experimental=True)
+ tools = {t.name: t for t in await server.list_tools()}
+ assert len(tools) == 189
+ assert tools["nx_set_visibility"].inputSchema["properties"]["mode"]["enum"] == [
+ "show",
+ "hide",
+ "isolate",
+ ]
+ assert "native" in tools["nx_section_view"].description.lower()
+ assert "desktop to PNG" not in tools["nx_screenshot"].description
+ assert "inline" in tools["nx_screenshot"].description
+ assert tools["nx_sketch_diagnostics"].annotations.readOnlyHint
+ assert not tools["nx_set_display"].annotations.readOnlyHint
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
index f8b9c39..d53c541 100644
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -1,4 +1,3 @@
-import sys
from pathlib import Path
import pytest
@@ -14,8 +13,6 @@ def test_workspace_accepts_relative_paths_inside_root(tmp_path: Path):
@pytest.mark.parametrize("path", ["../outside.prt", "C:/outside.prt"])
def test_workspace_rejects_paths_outside_root(tmp_path: Path, path: str):
- if sys.platform != "win32" and path.startswith("C:/"):
- pytest.skip("drive-letter paths are only absolute on Windows")
workspace = Workspace(tmp_path)
with pytest.raises(WorkspaceViolation, match="workspace"):
@@ -34,3 +31,25 @@ def test_workspace_rejects_absolute_path_outside_root(tmp_path: Path):
with pytest.raises(WorkspaceViolation, match="workspace"):
workspace.ensure_inside(tmp_path.parent / "outside.prt")
+
+
+def test_resolve_accepts_absolute_inside_root(tmp_path):
+ path = tmp_path / "projects" / "controller" / "part.prt"
+ assert Workspace(tmp_path).resolve(str(path)) == path
+
+
+@pytest.mark.parametrize("path", ["C:relative.prt", r"\relative.prt", ".NX-MCP/state.json"])
+def test_rejects_ambiguous_or_reserved_paths(tmp_path, path):
+ with pytest.raises(WorkspaceViolation):
+ Workspace(tmp_path).resolve(path)
+
+
+def test_symlink_cannot_escape_workspace(tmp_path):
+ root = tmp_path / "root"
+ root.mkdir()
+ try:
+ (root / "escape").symlink_to(tmp_path, target_is_directory=True)
+ except OSError:
+ pytest.skip("Symlinks require host privileges")
+ with pytest.raises(WorkspaceViolation):
+ Workspace(root).resolve("escape/outside.prt")