diff --git a/CHANGELOG.md b/CHANGELOG.md index c47cb0c3..357c8dbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Tools the U-joint needed** (#209), each checked on SolidWorks 2026: + - `add_mate` takes a face as a point on it (`face_a`, `face_b`) in that + part's own coordinates, so a pin mates concentric in its hole; + - `set_component_state` (new) fixes or floats a component and makes a + sub-assembly rigid or flexible, keeping its other settings; + - `create_reference_plane` makes planes at an angle about an `axis`, and + from a face (`face_point`), sloped or not; `create_axis` now names the + axis it made; + - `add_chamfer` takes an `angle` and `flip`; + - `create_extrusion` takes `thin_side` for thin walls, and an open + outline makes a bent plate. +- **Every tool accepts a plain dict** (#209). A script calling a tool + function with a dict gets the same validation as an MCP client; the + export file tools no longer need `format_type`. + - **Support levels for every tool** (#79). Each tool is `stable` (checked live, no known fault), `beta` (a known fault or partial COM coverage), `experimental` (not yet checked live) or `planned` (not implemented), @@ -79,6 +94,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`create_extrusion`'s reply says what was built** (#209): the end + condition SolidWorks used (`MidPlane` for `both_directions`), and the + thin wall. It used to echo `direction: blind`, so an agent concluded + `both_directions` was ignored. + - **One failed call could pause a tool for good** (#208). The server's router fell back to the VBA adapter, which called back into the router, so one failed `create_extrusion` counted as many failures and opened the circuit diff --git a/README.md b/README.md index c05e0575..54c4f4d3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![SolidWorks](https://img.shields.io/badge/tested%20on-SolidWorks%202026-red)](https://www.solidworks.com/) [![Coverage](https://codecov.io/gh/andrewbartels1/SolidworksMCP-python/branch/main/graph/badge.svg)](https://codecov.io/gh/andrewbartels1/SolidworksMCP-python) -Python MCP server for SolidWorks automation with 155 tools, plus an optional agent/prompt-testing layer for AI-assisted workflows. +Python MCP server for SolidWorks automation with 156 tools, plus an optional agent/prompt-testing layer for AI-assisted workflows. [![155 MCP tools by support level, and how much of the core SolidWorks COM API they reach](docs/assets/progress/progress.svg)](https://andrewbartels1.github.io/SolidworksMCP-python/progress/) @@ -33,7 +33,7 @@ Python MCP server for SolidWorks automation with 155 tools, plus an optional age await adapter.create_extrusion(ExtrusionParameters(depth=10)) ``` -- **An MCP tool server on top of it.** The same calls, exposed as 155 MCP tools, so an agent such as Claude Code can build parts, assemblies and drawings from a description. Each tool carries a support level (stable, beta, experimental or planned), so the agent knows how far to trust it. +- **An MCP tool server on top of it.** The same calls, exposed as 156 MCP tools, so an agent such as Claude Code can build parts, assemblies and drawings from a description. Each tool carries a support level (stable, beta, experimental or planned), so the agent knows how far to trust it. ## See it work @@ -186,6 +186,8 @@ Each GIF is a live test driving SolidWorks 2026 through the MCP tools, with the Custom properties
Custom properties +A pin seated in its hole by two face mates
A pin seated in its hole by two face mates +A bent plate from an open outline
A bent plate from an open outline @@ -300,7 +302,7 @@ Add this to `%APPDATA%\Code\User\mcp.json`: } ``` -Run **MCP: List Servers** from the Command Palette and start `solidworks`. Its output should end with `Discovered 155 tools` (the count grows as tools are added). +Run **MCP: List Servers** from the Command Palette and start `solidworks`. Its output should end with `Discovered 156 tools` (the count grows as tools are added). diff --git a/docs/assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif b/docs/assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif new file mode 100644 index 00000000..28a9cebe Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif b/docs/assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif new file mode 100644 index 00000000..ab564260 Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif b/docs/assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif new file mode 100644 index 00000000..b1b9c553 Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif b/docs/assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif new file mode 100644 index 00000000..a90e9a31 Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif b/docs/assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif new file mode 100644 index 00000000..9e106b6b Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif b/docs/assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif new file mode 100644 index 00000000..fa6aa6f5 Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif b/docs/assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif new file mode 100644 index 00000000..69852953 Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif differ diff --git a/docs/assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif b/docs/assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif new file mode 100644 index 00000000..152188fd Binary files /dev/null and b/docs/assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif differ diff --git a/docs/assets/progress/progress.json b/docs/assets/progress/progress.json index 4ee5efa7..ce1d5a7d 100644 --- a/docs/assets/progress/progress.json +++ b/docs/assets/progress/progress.json @@ -1,9 +1,9 @@ { "tools": { - "total": 155, + "total": 156, "by_level": { - "stable": 81, - "beta": 24, + "stable": 83, + "beta": 23, "experimental": 10, "planned": 40 }, @@ -41,7 +41,7 @@ "family": "modeling", "level": "stable", "reason": "", - "summary": "Bevel edges of the active part with an equal-distance chamfer at 45 degrees." + "summary": "Bevel edges of the active part with a chamfer: `distance` along one face at `angle` (45 by default)." }, { "name": "add_circle", @@ -102,8 +102,8 @@ { "name": "add_mate", "family": "modeling", - "level": "beta", - "reason": "mates only reference planes and axes, by name, not faces.", + "level": "stable", + "reason": "", "summary": "Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle." }, { @@ -412,7 +412,7 @@ "family": "modeling", "level": "stable", "reason": "", - "summary": "Add a reference plane parallel to a plane or flat face, offset by a distance in mm." + "summary": "Add a reference plane offset from, at an angle to, or on a plane or flat face." }, { "name": "create_reference_point", @@ -502,7 +502,7 @@ "name": "export_dwg", "family": "export", "level": "beta", - "reason": "the file extension sets the format; `format_type` and `options` are ignored.", + "reason": "`options` is ignored; the file extension sets the format.", "summary": "Export the active document to DWG, like File > Save As with the DWG type." }, { @@ -516,7 +516,7 @@ "name": "export_iges", "family": "export", "level": "beta", - "reason": "the file extension sets the format; `format_type` and `options` are ignored.", + "reason": "`options` is ignored; the file extension sets the format.", "summary": "Export the active document to IGES, like File > Save As with the IGES type." }, { @@ -530,21 +530,21 @@ "name": "export_pdf", "family": "export", "level": "beta", - "reason": "the file extension sets the format; `format_type` and `options` are ignored.", + "reason": "`options` is ignored; the file extension sets the format.", "summary": "Export the active document to PDF, like File > Save As with the PDF type." }, { "name": "export_step", "family": "export", "level": "beta", - "reason": "the file extension sets the format; `format_type` and `options` are ignored.", + "reason": "`options` is ignored; the file extension sets the format.", "summary": "Export the active document to STEP, like File > Save As with the STEP type." }, { "name": "export_stl", "family": "export", "level": "beta", - "reason": "the file extension sets the format; `format_type` and `options` are ignored.", + "reason": "`options` is ignored; the file extension sets the format.", "summary": "Export the active document to STL, like File > Save As with the STL type." }, { @@ -960,6 +960,13 @@ "reason": "", "summary": "Select a feature, sketch, plane or component by name in the active document." }, + { + "name": "set_component_state", + "family": "modeling", + "level": "stable", + "reason": "", + "summary": "Fix or float a component of the active assembly, or make a sub-assembly rigid or flexible." + }, { "name": "set_custom_property", "family": "engineering_data", @@ -1098,11 +1105,11 @@ "com": { "type_library": "83A33D31-27C5-11CE-BFD4-00400513BB57x0x34x0", "core": { - "covered": 208, + "covered": 214, "total": 2838 }, "api": { - "covered": 232, + "covered": 238, "total": 5985 }, "interfaces": [ @@ -1256,11 +1263,14 @@ "AddComponent4", "AddComponent5", "AddMate5", + "CompConfigProperties5", "FeatureByName", + "FixComponent", "GetBox", "GetComponentByName", "GetComponentCount", - "GetComponents" + "GetComponents", + "UnfixComponent" ], "total": 198 }, @@ -1345,7 +1355,10 @@ "GetID", "GetModelDoc2", "GetPathName", + "GetSuppression2", "GetType", + "IsEnvelope", + "IsFixed", "IsSuppressed", "Select", "Select2", diff --git a/docs/assets/progress/progress.svg b/docs/assets/progress/progress.svg index 1cd0a7c4..73d5a656 100644 --- a/docs/assets/progress/progress.svg +++ b/docs/assets/progress/progress.svg @@ -21,33 +21,33 @@ -MCP tools: 155 +MCP tools: 156 68% work on SolidWorks today - - - - - + + + + + -stable 81 +stable 83 -beta 24 +beta 23 experimental 10 planned 40 Core SolidWorks COM API (12 interfaces) -208 of 2,838 methods · 7.3% +214 of 2,838 methods · 7.5% - - - + + + -Whole API: 232 of 5,985 method names (3.9%), counted by a text scan of the adapters. +Whole API: 238 of 5,985 method names (4.0%), counted by a text scan of the adapters. diff --git a/docs/assets/progress/tables.html b/docs/assets/progress/tables.html index 35b8fa6e..259c7a49 100644 --- a/docs/assets/progress/tables.html +++ b/docs/assets/progress/tables.html @@ -6,7 +6,7 @@ add_annotationdrawingplanned
no adapter implements it yet.Add an annotation (a note, balloon or surface-finish symbol) to the active drawing. add_arcsketchingstableDraw a circular arc in the open sketch from its centre, start point and end point, in mm. add_centerlinesketchingstableDraw a centreline (construction line) in the open sketch, in mm. -add_chamfermodelingstableBevel edges of the active part with an equal-distance chamfer at 45 degrees. +add_chamfermodelingstableBevel edges of the active part with a chamfer: `distance` along one face at `angle` (45 by default). add_circlesketchingstableDraw a circle in the open sketch from its centre and radius, in mm. add_datum_tagdrawing_automationstableAdd a datum feature symbol (A, B, C, ...) on an edge of the active drawing. add_dimensiondrawingplanned
no adapter implements it yet.Add one dimension between drawing entities at a position on the sheet. @@ -15,7 +15,7 @@ add_filletmodelingstableRound edges of the active part with a constant-radius fillet. add_gtoldrawing_automationstableAdd a GD&T feature control frame to the active drawing, e.g. position Ø0.2 (M) to A|B|C. add_linesketchingstableDraw a straight line between two points in the open sketch, in mm. -add_matemodelingbeta
mates only reference planes and axes, by name, not faces.Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle. +add_matemodelingstableMate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle. add_notedrawingstablePlace a text note on the active drawing sheet at a position in millimetres. add_polygonsketchingstableDraw a regular polygon (e.g. a hexagon) in the open sketch from its centre, radius and number of sides. add_rectanglesketchingstableDraw a closed, axis-aligned rectangle in the open sketch from two opposite corners, in mm. @@ -59,7 +59,7 @@ create_loftmodelingstableLoft a solid through two or more closed profile sketches, in order (Insert > Boss/Base > Loft). create_macro_librarymacro_recordingplanned
no adapter implements it yet.Set up a shared folder of categorised, documented macros. create_partmodelingbeta
`material` is ignored; use `set_material`.Create a new, empty part document and make it active, ready for `create_sketch`. -create_reference_planemodelingstableAdd a reference plane parallel to a plane or flat face, offset by a distance in mm. +create_reference_planemodelingstableAdd a reference plane offset from, at an angle to, or on a plane or flat face. create_reference_pointmodelingstableAdd a reference point on an edge (at a distance or percentage along it) or at the centre of a face. create_revolvemodelingbeta
`sketch_name` and `axis_entity` are ignored; it revolves the sketch closed last around its centreline.Revolve the sketch you just closed around the centreline drawn in it, adding a solid to the active part. create_section_viewdrawingplanned
no adapter implements it yet.Cut a section view along a line across a drawing view. @@ -72,13 +72,13 @@ execute_macromacro_recordingbeta
runs the macro once whatever `repeat_count` says, and only binary .swp files.Run a binary SolidWorks macro (.swp, or legacy .swb) in the running SolidWorks, calling its `main` procedure once. execute_workflowautomationplanned
no adapter implements it yet.Run a named sequence of tool steps, in order or in parallel, with an error policy. exit_sketchsketchingstableClose the open sketch so a feature can use it. -export_dwgexportbeta
the file extension sets the format; `format_type` and `options` are ignored.Export the active document to DWG, like File > Save As with the DWG type. +export_dwgexportbeta
`options` is ignored; the file extension sets the format.Export the active document to DWG, like File > Save As with the DWG type. export_flat_patternengineering_datastableExport the active sheet-metal part's flat pattern to DXF or DWG, for laser or waterjet cutting. -export_igesexportbeta
the file extension sets the format; `format_type` and `options` are ignored.Export the active document to IGES, like File > Save As with the IGES type. +export_igesexportbeta
`options` is ignored; the file extension sets the format.Export the active document to IGES, like File > Save As with the IGES type. export_imageexportstableSave a picture of the active model in a standard view, as PNG or JPG, at a chosen size. -export_pdfexportbeta
the file extension sets the format; `format_type` and `options` are ignored.Export the active document to PDF, like File > Save As with the PDF type. -export_stepexportbeta
the file extension sets the format; `format_type` and `options` are ignored.Export the active document to STEP, like File > Save As with the STEP type. -export_stlexportbeta
the file extension sets the format; `format_type` and `options` are ignored.Export the active document to STL, like File > Save As with the STL type. +export_pdfexportbeta
`options` is ignored; the file extension sets the format.Export the active document to PDF, like File > Save As with the PDF type. +export_stepexportbeta
`options` is ignored; the file extension sets the format.Export the active document to STEP, like File > Save As with the STEP type. +export_stlexportbeta
`options` is ignored; the file extension sets the format.Export the active document to STL, like File > Save As with the STL type. extract_templatetemplate_managementplanned
no adapter implements it yet.Capture a model's settings (custom properties, dimensions, units) as a reusable template file. find_related_api_membersdocs_discoverystableFind SolidWorks API members related to an interface or member name. generate_drawing_packagedrawing_automationstableCreate a complete A3 drawing of a part or assembly in one call, and save it with a PDF. @@ -138,6 +138,7 @@ search_solidworks_api_helpdocs_discoverystableSearch SolidWorks API interface and member names by keyword. select_face_edgesmodelingstableSelect every edge of one face of the active part, ready for `add_fillet` or `add_chamfer` with no edge names. select_featuremodelingstableSelect a feature, sketch, plane or component by name in the active document. +set_component_statemodelingstableFix or float a component of the active assembly, or make a sub-assembly rigid or flexible. set_custom_propertyengineering_datastableCreate or overwrite one text custom property on the active document, e.g. PartNo or DrawnBy. set_dimensionmodelingstableChange one length dimension of the active model, in mm, and rebuild. set_materialengineering_datastableAssign a library material to the active part, e.g. "AISI 1020" or "6061-T6 (SS)". @@ -169,12 +170,12 @@ IModelDoc266758
8.7%AddDiameterDimension2, AddDimension, AddDimension2, AddRadialDimension2, AddRelation, ClearSelection2, CreateArc, CreateCenterLine, CreateCircleByRadius, CreateCircularSketchStepAndRepeat, CreateEllipse, CreateLine, CreateLinearSketchStepAndRepeat, EditDelete, EditRebuild3, EditSuppress2, EditUndo, EditUndo2, EditUnsuppress2, FeatureByPositionReverse, FeatureChamferType, FeatureCut3, FeatureCut4, FeatureFillet3, FeatureRevolve2, FirstFeature, ForceRebuild3, GetActiveConfiguration, GetActiveSketch2, GetConfigurationNames, GetFeatureCount, GetLineCount, GetMassProperties, GetNext, GetPathName, GetSaveFlag, GetTitle, GetType, GetUserPreferenceStringValue, GetUserPreferenceToggle, GraphicsRedraw2, InsertAxis2, InsertDatumTag2, InsertGtol, InsertNote, InsertProtrusionBlend2, InsertProtrusionSwept4, InsertSketch, Lock, Parameter, Save, Save3, SaveAs, SaveAs2, SaveAs3, SaveBMP, Select, SetSaveFlag, SetUserPreferenceIntegerValue, SetUserPreferenceToggle, ShowNamedView2, SketchAddConstraints, SketchMirror, SketchOffset2, ViewZoomtofit, ViewZoomtofit2
IModelDocExtension20337
5.9%AddDimension, CreateMassProperty, CreateMeasure, CustomPropertyManager, DeleteSelection2, GetAnnotations, GetMassProperties, GetPackAndGo, GetUserPreferenceInteger, GetUserPreferenceToggle, GetWhatsWrong, GetWhatsWrongCount, InsertBomTable4, SaveAs, SaveAs2, SaveAs3, SavePackAndGo, SelectByID2, SetUserPreferenceInteger, SetUserPreferenceToggle
IPartDoc12167
7.2%ExportToDWG2, FeatureByName, FeatureExtrusion2, FeatureExtrusion3, FeatureExtrusionThin2, FeatureRevolve2, FirstFeature, GetBodies2, GetMaterialPropertyName2, GetPartBox, InsertMirrorFeature, SetMaterialPropertyName2
-IAssemblyDoc8198
4.0%AddComponent4, AddComponent5, AddMate5, FeatureByName, GetBox, GetComponentByName, GetComponentCount, GetComponents
+IAssemblyDoc11198
5.6%AddComponent4, AddComponent5, AddMate5, CompConfigProperties5, FeatureByName, FixComponent, GetBox, GetComponentByName, GetComponentCount, GetComponents, UnfixComponent
IDrawingDoc13272
4.8%ActivateView, AutoDimension, Create1stAngleViews2, Create3rdAngleViews2, CreateDrawViewFromModelView3, EditSheet, EditTemplate, FeatureByName, GetCurrentSheet, GetFirstView, GetViews, InsertModelAnnotations3, SetupSheet5
IFeatureManager16268
6.0%CreateSaveBodyFeature, FeatureCircularPattern5, FeatureCut3, FeatureCut4, FeatureExtrusion2, FeatureExtrusion3, FeatureExtrusionThin2, FeatureFillet3, FeatureRevolve2, GetFeatureCount, InsertFeatureChamfer, InsertMirrorFeature, InsertProtrusionBlend2, InsertProtrusionSwept4, InsertRefPlane, InsertReferencePoint
ISketchManager1283
14.5%CreateArc, CreateCenterLine, CreateCircleByRadius, CreateCircularSketchStepAndRepeat, CreateCornerRectangle, CreateEllipse, CreateLine, CreateLinearSketchStepAndRepeat, CreatePolygon, CreateSpline2, InsertSketch, SketchOffset2
ISelectionMgr461
6.6%CreateSelectData, GetSelectedObject6, GetSelectedObjectCount2, GetSelectedObjectType3
-IComponent214147
9.5%CustomPropertyManager, FeatureByName, FirstFeature, GetBodies2, GetBox, GetConfigurationNames, GetID, GetModelDoc2, GetPathName, GetType, IsSuppressed, Select, Select2, Select4
+IComponent217147
11.6%CustomPropertyManager, FeatureByName, FirstFeature, GetBodies2, GetBox, GetConfigurationNames, GetID, GetModelDoc2, GetPathName, GetSuppression2, GetType, IsEnvelope, IsFixed, IsSuppressed, Select, Select2, Select4
IFeature18115
15.7%GetBox, GetErrorCode2, GetFaces, GetFirstDisplayDimension, GetFirstSubFeature, GetID, GetNameForSelection, GetNextDisplayDimension, GetNextFeature, GetNextSubFeature, GetParents, GetSpecificFeature2, GetTypeName2, IsSuppressed, IsSuppressed2, Parameter, Select, Select2
ISketch3102
2.9%AutoDimension, GetLineCount, GetSketchSegments
diff --git a/docs/demos.md b/docs/demos.md index 52d7fc0e..7352b474 100644 --- a/docs/demos.md +++ b/docs/demos.md @@ -559,6 +559,145 @@ and reports the assembly's size. 1. `verify_model({"component_count": 2, "mate_count": 2, "no_interference": true})` +## The tools the U-joint was missing {#tool-gaps} + +Building the [U-joint](#ujoint) from a prompt, both agent sessions hit the +same walls: no face mates, no way to let a part move, no angled planes or +chamfers, and an extrusion reply that hid what it built (#209). Each demo +below is a live test of the fix on SolidWorks 2026, calling the MCP tools +with plain dicts, as a script would. + +Source: [`tests/live/test_live_sw_ujoint_tool_gaps.py`](https://github.com/andrewbartels1/SolidworksMCP-python/blob/main/tests/live/test_live_sw_ujoint_tool_gaps.py). + +### A bent plate from an open outline {#thin-plate} + +![create_extrusion turning an open L-shaped sketch into a 6.35 mm bent plate, 38.1 mm wide, mid-plane](assets/demos/tool-gaps/test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate-motion.gif) + +> *Example prompt:* Make a bent plate: an 80 mm wall and a 40 mm foot, +> 6.35 mm thick, 38.1 mm wide, centred on the Right plane. + +**What it shows.** Two lines, not a closed outline, extruded as a 6.35 mm +thin wall on both sides of the line and 19.05 mm each way. One body of +29,032.2 mm³, exactly (80 + 40) x 6.35 x 38.1, and the reply says +`MidPlane` and the wall it built. + +**Tool calls** + +1. `create_extrusion({"sketch_name": "Sketch1", "depth": 38.1, "both_directions": true, "thin_feature": true, "thin_thickness": 6.35, "thin_side": "mid_plane"})` + +### Both directions, and the reply says so {#mid-plane} + +![create_extrusion 20 mm both ways from the Front Plane, spanning z -10 to 10 and reporting MidPlane](assets/demos/tool-gaps/test_both_directions_is_symmetric_and_says_mid_plane-motion.gif) + +> *Example prompt:* Extrude this rectangle 20 mm, centred on its sketch plane. + +**What it shows.** The body spans -10 to 10 mm about the sketch plane, and +the reply's `end_condition` is `MidPlane`. It used to echo `direction: +blind`, so an agent thought `both_directions` was ignored. + +**Tool calls** + +1. `create_extrusion({"sketch_name": "Sketch1", "depth": 20, "both_directions": true})` + +### A 60° chamfer, and flipping it {#angled-chamfer} + +![add_chamfer cutting a 2 mm chamfer at 60 degrees, then the same chamfer flipped on the opposite edge](assets/demos/tool-gaps/test_a_60_degree_chamfer_and_flip_swaps_its_legs-motion.gif) + +> *Example prompt:* Chamfer the top front edge 2 mm at 60°, and the back +> edge the other way round. + +**What it shows.** Each chamfer removes 138.56 mm³ (½ x 2 x 2·tan 60° x +40 mm). Unflipped, the chamfer face slopes 60° from the top; flipped, 30°: +`flip` swaps which face the 2 mm is measured on. + +**Tool calls** + +1. `add_chamfer({"distance": 2, "angle": 60, "edge_names": ["0,0.01,0.01"]})` +2. `add_chamfer({"distance": 2, "angle": 60, "flip": true, "edge_names": ["0,0.01,-0.01"]})` + +### A plane at 40° about an axis {#angled-plane} + +![create_axis making Axis1, then create_reference_plane making a plane at 40 degrees to the Top Plane about it](assets/demos/tool-gaps/test_a_plane_at_40_degrees_about_an_axis-motion.gif) + +> *Example prompt:* Add a plane at 40° to the Top Plane, tilted about the x +> axis. + +**What it shows.** `create_axis` names the axis it makes, and the new plane +is exactly 40° from the Top Plane about it. + +**Tool calls** + +1. `create_axis({"reference": "x"})` +2. `create_reference_plane({"reference": "Top Plane", "angle": 40, "axis": "Axis1"})` + +### A plane off a sloped face {#face-plane} + +![create_reference_plane making a plane 5 mm off a wedge's sloped face, from a point on that face](assets/demos/tool-gaps/test_a_plane_offset_from_a_sloped_face-motion.gif) + +> *Example prompt:* Put a plane 5 mm above the sloped face of this wedge. + +**What it shows.** A point on the 3-4-5 slope picks the face. The plane's +normal is (0.6, 0.8, 0), parallel to the slope, and it sits 5.000 mm off it. + +**Tool calls** + +1. `create_reference_plane({"face_point": [20, 15, 5], "offset": 5})` + +### A pin in its hole, by two face mates {#face-mate} + +![add_mate seating a pin in a plate's hole with two face mates, each face picked by a point in its own part](assets/demos/tool-gaps/test_a_pin_mates_concentric_in_its_hole_by_two_faces-motion.gif) + +> *Example prompt:* Put the pin through the plate's hole, flush with the +> plate's bottom. + +**What it shows.** Each face is a point in its own part's coordinates, as +the part was modelled. A concentric mate between the hole's wall (25, 5, 0) +and the pin's round face (4.75, 15, 0) puts the pin's axis exactly on the +hole's, 20 mm from the plate's origin. A coincident mate between the two +bottom faces stops it sliding along that axis: the pin runs from 0 to 30 mm +above the plate's bottom, through the hole. + +**Tool calls** + +1. `add_mate({"component_a": "mate_plate-1", "component_b": "mate_pin-1", "face_a": [25, 5, 0], "face_b": [4.75, 15, 0], "mate_type": "concentric"})` +2. `add_mate({"component_a": "mate_plate-1", "component_b": "mate_pin-1", "face_a": [-20, 0, 0], "face_b": [0, 0, 0], "mate_type": "coincident", "alignment": "aligned"})` + +### Fixed, floating and flexible {#component-state} + +![set_component_state floating and fixing a part, then making a sub-assembly flexible with its other settings unchanged](assets/demos/tool-gaps/test_components_fix_float_and_a_sub_assembly_turns_flexible-motion.gif) + +> *Example prompt:* Let the crank sub-assembly turn inside the main +> assembly. + +**What it shows.** The first part in, which SolidWorks fixes, floats and is +fixed again. A sub-assembly turns flexible, so its own mates can move in the +main assembly, and its suppression, visibility, configuration and BOM +setting are unchanged. + +**Tool calls** + +1. `set_component_state({"component": "state_plate-1", "fixed": false})` +2. `set_component_state({"component": "state_plate-1", "fixed": true})` +3. `set_component_state({"component": "gap_pin_plate-1", "flexible": true})` + +### Interference, before and after {#fresh-interference} + +![check_interference finding the pin through the plate, then none once the pin is mated into the hole](assets/demos/tool-gaps/test_interference_is_fresh_after_a_mate-motion.gif) + +> *Example prompt:* Does anything overlap? Now put the pin in the hole and +> check again. + +**What it shows.** The pin, inserted through solid plate, overlaps it +once. Seated in the Ø10 hole by the two face mates above, the Ø9.5 pin +overlaps nothing: it passes through the plate with 0.25 mm clearance. The +second check is fresh, not the first one's cached answer. + +**Tool calls** + +1. `check_interference({"components": ["clash_plate-1", "clash_pin-1"]})` +2. `add_mate` concentric and coincident, as in the demo above +3. `check_interference({"components": ["clash_plate-1", "clash_pin-1"]})` + ## Drawing packages {#drawing-package} The drawing-package tools make a complete drawing from a model, and set the diff --git a/docs/index.md b/docs/index.md index 2f03a60a..5d1df192 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,7 +111,7 @@ cd SolidworksMCP-python Open SolidWorks, restart your client, and ask *"What model is open in SolidWorks?"*. Healthy server logs include: - `SolidWorks COM interface is available` -- `Registered 155 SolidWorks tools` (the count grows as tools are added) +- `Registered 156 SolidWorks tools` (the count grows as tools are added) - `Connected to SolidWorks` ### Linux / WSL only diff --git a/docs/user-guide/tools-overview.md b/docs/user-guide/tools-overview.md index f8332277..ec8f964a 100644 --- a/docs/user-guide/tools-overview.md +++ b/docs/user-guide/tools-overview.md @@ -1,6 +1,6 @@ # Tools Overview -The SolidWorks MCP Server provides 155 specialized tools for CAD automation. This page is a navigation map, not a tutorial. +The SolidWorks MCP Server provides 156 specialized tools for CAD automation. This page is a navigation map, not a tutorial. ## Read this first @@ -21,7 +21,7 @@ The SolidWorks MCP Server provides 155 specialized tools for CAD automation. Thi Core 3D modeling operations including part creation, features, assemblies, and configurations. - [**30 tools available** :octicons-arrow-right-24:](#modeling-tools) + [**31 tools available** :octicons-arrow-right-24:](#modeling-tools) - :material-pencil:{ .lg .middle } **Sketching Tools** @@ -133,7 +133,7 @@ The SolidWorks MCP Server provides 155 specialized tools for CAD automation. Thi | Category | Tool Count | Complexity Level | Primary Use Case | |----------|------------|------------------|------------------| -| **Modeling** | 30 | High | Part/assembly creation and modification | +| **Modeling** | 31 | High | Part/assembly creation and modification | | **Sketching** | 19 | Medium | 2D geometry and constraint management | | **Drawing** | 14 | Medium | Technical documentation creation | | **Drawing Analysis** | 8 | Medium | Quality assurance and compliance | @@ -148,7 +148,7 @@ The SolidWorks MCP Server provides 155 specialized tools for CAD automation. Thi | **Macro Recording** | 7 | High | Workflow capture and optimization | | **Docs Discovery** | 2 | Medium | API and COM discovery workflows | -**Total: 155 Tools** +**Total: 156 Tools** ## Tool Design Principles diff --git a/openspec/changes/tool-support-levels-and-progress/.openspec.yaml b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/.openspec.yaml similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/.openspec.yaml rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/.openspec.yaml diff --git a/openspec/changes/tool-support-levels-and-progress/design.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/design.md similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/design.md rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/design.md diff --git a/openspec/changes/tool-support-levels-and-progress/proposal.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/proposal.md similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/proposal.md rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/proposal.md diff --git a/openspec/changes/tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/docs/coverage-progress/spec.md diff --git a/openspec/changes/tool-support-levels-and-progress/specs/tools/support-levels/spec.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/support-levels/spec.md similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/specs/tools/support-levels/spec.md rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/support-levels/spec.md diff --git a/openspec/changes/tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/specs/tools/tool-descriptions/spec.md diff --git a/openspec/changes/tool-support-levels-and-progress/tasks.md b/openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/tasks.md similarity index 100% rename from openspec/changes/tool-support-levels-and-progress/tasks.md rename to openspec/changes/archive/2026-11-06-tool-support-levels-and-progress/tasks.md diff --git a/openspec/changes/ujoint-tool-gaps/.openspec.yaml b/openspec/changes/ujoint-tool-gaps/.openspec.yaml new file mode 100644 index 00000000..8a94d769 --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/ujoint-tool-gaps/design.md b/openspec/changes/ujoint-tool-gaps/design.md new file mode 100644 index 00000000..b4502062 --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/design.md @@ -0,0 +1,142 @@ +## Context + +Gaps from #209, checked against the code and the two U-joint transcripts on +2026-11-06: + +- `add_mate` selects each entity with `IComponent2::FeatureByName` + + `IFeature::Select2`, so only named tree features (planes, axes) can be + mated. +- Nothing calls `IAssemblyDoc::FixComponent` / `UnfixComponent` or sets a + component's solving (rigid / flexible). +- `create_reference_plane` refuses `angle`: measured on SW 2025, an angle + constraint with one reference adds nothing. A face can only be named, and + faces have no names. +- `add_chamfer` calls `InsertFeatureChamfer(1, 1, width, pi/4, ...)`. In + `swChamferType_e` 1 is **angle-distance**, not equal distance (16): it has + always made an angle-distance chamfer at a fixed 45°. Options = 1 is + `swFeatureChamferFlipDirection`. +- `create_extrusion` already maps `both_directions` to `swEndCondMidPlane` + (#191) and builds thin walls with `FeatureExtrusionThin2`. In the + transcripts the bodies came out mid-plane (symmetric bounding boxes), but + the reply echoed the input `direction: "blind"`, so the agent believed the + flag was ignored. The thin call's `RevThinDir` is fixed at 0 (one side). +- Over MCP, arguments arrive validated as the tool's model; a script calling + the function with a dict hits `'dict' object has no attribute ...` on most + tools. The export tools fixed this one by one (6ae900f). + +COM members, checked with the project's API lookup (type library 34.0) and +`swconst.tlb`: + +| Member | Signature / values | +| --- | --- | +| `IAssemblyDoc::AddMate5` | (type, align, flip, distance, upper, lower, gear num, gear den, angle, upper, lower, positioning only, lock rotation, width option, out error) | +| `IAssemblyDoc::FixComponent`, `UnfixComponent` | act on the selected component | +| `IAssemblyDoc::CompConfigProperties6` | (suppression, solving, visibility, use named config, config name, exclude from BOM, is envelope, save-as-part option): sets all of them for the selected component | +| `IComponent2` | `IsFixed()`, `Solving` (read), `GetSuppression2`, `Visible`, `ReferencedConfiguration`, `ExcludeFromBOM`, `IsEnvelope`, `GetBodies2(type)`, `Transform2`, `Select4(append, data, popup)` | +| `IFeatureManager::InsertRefPlane` | (first constraint, value, second, value, third, value) | +| `IFeatureManager::InsertFeatureChamfer` | (options, type, width, angle, other distance, vertex 1, 2, 3) | +| `IFeatureManager::FeatureExtrusionThin2` | ... `Thk1, Thk2, EndThk, RevThinDir, CapEnds` ... | +| `swRefPlaneReferenceConstraints_e` | Parallel 1, Coincident 4, Distance 8, Angle 16, OptionFlip 256 | +| `swChamferType_e` | AngleDistance 1, DistanceDistance 2, EqualDistance 16 | +| `swComponentSolvingOption_e` | Rigid 0, Flexible 1 | +| `swThinWallType_e` | OneDirection 0, OppDirection 1, MidPlane 2, TwoDirection 3 | +| `swMateType_e` / `swMateAlign_e` | as in `_MATE_TYPES` / `_MATE_ALIGNMENTS` | + +## Goals / Non-Goals + +**Goals:** every gap in #209 closed and checked live on SolidWorks 2026; the +U-joint's pin-in-hole and spinning knob possible with the tools; replies that +say what SolidWorks built. + +**Non-Goals:** the queue's other `create_extrusion` faults (`sketch_name`, +`direction`), gear/cam/limit mates, rebuilding the U-joint demo, edge mates. + +## Decisions + +### A face is a point on it, in the part's own coordinates + +`face_a` / `face_b` are `[x, y, z]` mm in the component's part coordinates, +which is what an agent knows from building the part. The adapter maps the +point into the assembly with the component's `Transform2` (an `IMathPoint` +from `IMathUtility::CreatePoint`, `MultiplyTransform`). It searches only that +component's faces (`GetBodies2`, the face closest to the point within +0.01 mm, as `select_face_edges` does) and selects the face with +`IEntity::Select4`, the first under mark 1 and the second appended. Whether +component faces report assembly or part coordinates is measured live first +(a read-only probe); the transform step is kept only if needed. + +**Measured 2026-11-06 (SW 2026, read-only probe):** a 100 x 50 x 10 block +inserted 50 mm along x had `Transform2` translation (0.05, -0.005, 0) m, yet +every face from `IComponent2::GetBodies2(0)` reported its box in **part** +coordinates (x from -50 to 50 mm), and `IEntity::GetComponent` on such a face +returned the component. So the point is matched against the component's faces +in part coordinates directly, and no transform is needed. + +Alternative, `SelectByID2("", "FACE", x, y, z)` at an assembly point: +rejected. A pin's surface and its hole's surface are at the same points, so +the pick could land on the wrong component. + +Each side takes exactly one of `entity_*` or `face_*`. The default stays +`"Front Plane"` only when neither is given, so existing calls are unchanged. + +### `set_component_state` is its own tool + +Input: `component`, and any of `fixed` (true fixes, false floats) and +`flexible` (true flexible, false rigid). Fix and float select the component +and call `FixComponent` / `UnfixComponent`. Solving goes through +`CompConfigProperties5`, which also sets suppression, visibility, +configuration, BOM and envelope: the adapter reads each one from the +component first and passes it back, so only solving changes. (Version 6 +adds a save-as-part option the component doesn't report, so it would set +something unread.) The reply is +the state read back (`fixed`, `flexible`). Alternative, more fields on +`add_mate`: rejected, a component's state isn't a mate. + +### Reference planes: angle about an axis; a face by a point + +- `angle` needs `axis`: a named axis (`"Axis1"`) or a temporary axis name, + selected under mark 1, with constraints `Angle | flip` on the plane (mark 0) + and `Coincident` on the axis. This is what the SW 2025 measurement said was + missing. +- `face_point` (`[x, y, z]` mm) replaces `reference` with the planar face at + that point, found as `select_face_edges` finds it, and selected under + mark 0. With `offset` it makes a parallel plane; with `offset` 0 a plane on + the face. + +### Chamfer: angle and flip on the call it already makes + +`angle` (degrees, 0 < angle < 90, default 45) and `flip` (measure the +distance on the other face). The call stays `InsertFeatureChamfer`, type 1 +(angle-distance), `Options = 1 if flip else 0`. The comment that called +type 1 "equal distance" is corrected. Equal-distance (16) is not used: at 45° +angle-distance gives the same chamfer and has been verified live. + +### Extrusion reply + +`extrusion` reports `end_condition` (as SolidWorks applied it), `both_directions`, +and, for thin walls, `thin_thickness` and `thin_side`; `direction` is no longer +echoed. `thin_side` (`one_side`, `other_side`, `mid_plane`) maps to +`RevThinDir` 0, 1, 2. + +### Plain dicts at the boundary + +At registration, `tool_boundary` finds the parameters annotated with a +pydantic model (alone or `| None`). At call time a dict in one of them is +validated with `normalize_input`, so a bad dict is an `invalid_input` error +like any other. The export tools' `_file_input` keeps its default +`format_type` and stays. + +## Risks / Trade-offs + +- [Component face coordinates differ by build] → measured live before the + code depends on it; the live test mates a pin in a hole away from the + origin, so a wrong space fails. +- [CompConfigProperties6 resets something we didn't read] → the live test + reads suppression, visibility and configuration before and after. +- [Boundary conversion changes a tool that took a raw dict on purpose] → only + parameters annotated with a model are converted; the full suite runs. + +## Migration Plan + +Additive: new optional fields and one new tool. `add_chamfer` without +`angle` behaves as before. diff --git a/openspec/changes/ujoint-tool-gaps/proposal.md b/openspec/changes/ujoint-tool-gaps/proposal.md new file mode 100644 index 00000000..b049219f --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/proposal.md @@ -0,0 +1,61 @@ +## Why + +Building SolidWorks' U-Joint sample from a prompt (the README's top demo), +both agent sessions hit the same walls (#209). The pin can't be mated +concentric to its hole, because `add_mate` only takes named planes and axes. +The knob can't spin, because nothing fixes, floats or flexes a component. +There are no planes at an angle or on a sloped face. Every chamfer is 45°. +The bent bracket had to be drawn as a closed outline, because the agent +didn't know thin extrudes work. And the agent concluded `both_directions` was +ignored, because the reply echoed `direction: blind`. Scripts that call a +tool function with a plain dict still fail on most tools. + +## What Changes + +- **Face mates.** `add_mate` takes `face_a` / `face_b` in place of + `entity_a` / `entity_b`: a point on the face in the component's own part + coordinates (mm). The face is found among that component's faces only, so + a pin and the hole it sits in can't be confused. Concentric, coincident, + parallel, distance and angle mates all work on faces. +- **`set_component_state`, a new tool.** Fix or float a component, and make + a sub-assembly rigid or flexible (so its own mates move in the parent). + The reply reads the state back. +- **Angled and face-based reference planes.** `create_reference_plane` takes + `angle` with an `axis` to rotate about (a named axis or a straight edge's + point), and `face_point` to use a planar face, sloped or not, as the + reference. +- **Angled chamfers.** `add_chamfer` takes `angle` (default 45°) and `flip`, + using SolidWorks' angle-distance chamfer, which it already called with a + fixed 45°. +- **Extrusion replies say what was built.** `create_extrusion` reports the + end condition SolidWorks used (`MidPlane` for `both_directions`) and the + thin wall, instead of echoing `direction`. Thin extrudes from an open + profile are checked live and documented, with `thin_side` (one side, the + other side, or mid-plane). +- **Plain-dict input for every tool.** The tool boundary validates a dict + argument as the tool's input model, so a script can call any tool function + with a dict. The export tools' own fix (6ae900f) is folded into it. +- The stale `check_interference` result in #209 was the router's cache, + removed by #213; a live check confirms it. + +## Capabilities + +### New Capabilities +- `tools/assembly-mates`: face mates and component state (fixed, floating, + rigid, flexible). +- `tools/reference-plane`: planes offset from, angled to, or on a face. +- `tools/extrusion`: what an extrusion reply reports; thin extrudes. + +### Modified Capabilities +- `tools/edge-treatment`: chamfers at an angle. +- `tools/error-boundary`: a plain dict is validated as the tool's input. + +## Impact + +- Adapter: `add_mate`, `create_reference_plane` and `add_chamfer` gain + parameters, and `set_component_state` is new. Each goes through base, mock, + COM, circuit breaker and connection pool. +- Tools: `modeling.py` inputs and descriptions; `tool_boundary.py`; the + support levels and hints for the new tool; the progress files regenerate. +- Live tests for every changed tool, recorded as GIFs on the Demos page. +- Closes #209. diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md new file mode 100644 index 00000000..d7c4afad --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/specs/tools/assembly-mates/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Mate a face by a point on it + +`add_mate` SHALL accept, for each side, either a named entity or a point on a face in that component's own part coordinates (mm), and SHALL pick the face among that component's faces only. + +#### Scenario: A pin concentric in a hole +- **WHEN** an agent mates a pin's cylindrical face (a point on it) concentric with a bracket's hole face (a point on it) +- **THEN** the mate is added, the pin's axis lies on the hole's axis, and the reply names the faces used + +#### Scenario: No face at the point +- **WHEN** the point is not on any face of the named component +- **THEN** the call fails with `not_found`, names the component and the point, and adds no mate + +#### Scenario: Both kinds for one side +- **WHEN** a side gives both a named entity and a face point +- **THEN** the call fails with `invalid_input` + +### Requirement: Fix, float, rigid and flexible components + +A `set_component_state` tool SHALL fix or float a component of the active assembly, and make a sub-assembly rigid or flexible, changing nothing else about it. + +#### Scenario: Float a component +- **WHEN** a fixed component is set `fixed: false` +- **THEN** it is floating, and the reply reads back `fixed: false` + +#### Scenario: Flexible sub-assembly +- **WHEN** a sub-assembly is set `flexible: true` +- **THEN** its solving is flexible, and its suppression, visibility and referenced configuration are as before diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md new file mode 100644 index 00000000..3d90c974 --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/specs/tools/edge-treatment/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Chamfers at an angle + +`add_chamfer` SHALL take an `angle` in degrees (default 45) and `flip`, making an angle-distance chamfer with `distance` along one face. + +#### Scenario: A 60 degree chamfer +- **WHEN** an agent chamfers a block's edge 2 mm at 60° +- **THEN** the volume removed is 0.5 x 2 x 2·tan(60°) x the edge length, within 1 %, or with the legs swapped when `flip` is set + +#### Scenario: Default unchanged +- **WHEN** no angle is given +- **THEN** the chamfer is the same 45° chamfer as before diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md new file mode 100644 index 00000000..5bdb0898 --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/specs/tools/error-boundary/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: A plain dict is accepted as a tool's input + +The tool boundary SHALL validate a dict passed where a tool expects its input model, so every tool can be called from a script with a dict. + +#### Scenario: A script passes a dict +- **WHEN** a script calls `check_interference` with `{"components": ["a-1", "b-1"]}` +- **THEN** the tool runs as it would over MCP + +#### Scenario: A bad dict +- **WHEN** the dict fails validation +- **THEN** the tool returns `error_type: invalid_input`, naming the field diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md new file mode 100644 index 00000000..2d1a2ab0 --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/specs/tools/extrusion/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: The reply says what was extruded + +`create_extrusion` SHALL report the end condition SolidWorks applied and, for a thin wall, its thickness and side, and SHALL NOT echo inputs it didn't apply. + +#### Scenario: Both directions +- **WHEN** an agent extrudes with `both_directions: true` +- **THEN** the body is symmetric about the sketch plane and the reply's `end_condition` is `MidPlane` + +### Requirement: Thin extrudes from an open profile + +`create_extrusion` with `thin_feature` SHALL extrude an open sketch into a wall of `thin_thickness`, on the side `thin_side` names. + +#### Scenario: A bent plate +- **WHEN** an agent extrudes an open L-shaped chain 38.1 mm with a 6.35 mm thin wall +- **THEN** one solid body is made whose volume is the chain's length times 6.35 times 38.1 mm, within 1 % diff --git a/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md b/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md new file mode 100644 index 00000000..d03283df --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/specs/tools/reference-plane/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: A plane at an angle about an axis + +`create_reference_plane` SHALL make a plane at `angle` degrees to a reference plane or face, rotated about a named `axis`. + +#### Scenario: 40 degrees about an axis +- **WHEN** an agent asks for a plane at 40° to the Top Plane about an axis lying in it +- **THEN** a new plane is added at 40° to the Top Plane, and its name comes back + +#### Scenario: An angle with no axis +- **WHEN** `angle` is given without `axis` +- **THEN** the call fails with `invalid_input` and says an axis is needed + +### Requirement: A plane from a face + +`create_reference_plane` SHALL accept `face_point`, a point on a planar face in mm, as the reference in place of a named plane. + +#### Scenario: Parallel to a sloped face +- **WHEN** an agent gives a point on a sloped face and an offset +- **THEN** a plane parallel to that face, offset by that distance, is added diff --git a/openspec/changes/ujoint-tool-gaps/tasks.md b/openspec/changes/ujoint-tool-gaps/tasks.md new file mode 100644 index 00000000..109f1b5d --- /dev/null +++ b/openspec/changes/ujoint-tool-gaps/tasks.md @@ -0,0 +1,26 @@ +## 1. Plain dicts at the boundary + +- [x] 1.1 `tool_boundary.py`: validate a dict argument as its annotated model; tests beside it, and one calling every tool with a dict on the mock + +## 2. Part features + +- [x] 2.1 `create_extrusion`: reply reports the applied end condition, `both_directions`, thin thickness and `thin_side`; `thin_side` input mapped to `RevThinDir`; tests +- [x] 2.2 `add_chamfer`: `angle` and `flip` through every adapter layer; correct the chamfer-type comment; tests +- [x] 2.3 `create_reference_plane`: `axis` with `angle`, and `face_point`; every adapter layer; tests + +## 3. Assemblies + +- [x] 3.1 Live read-only probe: in which space a component's faces report coordinates; record it in design.md +- [x] 3.2 `add_mate`: `face_a` / `face_b` through every adapter layer; tests +- [x] 3.3 `set_component_state` tool and adapter method (base, mock, COM, breaker, pool), hints, support level; tests + +## 4. Docs and levels + +- [x] 4.1 Tool descriptions; skill's errors-and-limits (remove the `add_mate` and fixed-45° limits); support levels (`add_mate` stable once live); regenerate progress +- [x] 4.2 CHANGELOG; queue + +## 5. Live + +- [x] 5.1 `tests/live/test_live_sw_ujoint_tool_gaps.py`: one test per tool (thin bent plate, mid-plane extrusion, 60° chamfer, angled plane, face plane, pin-in-hole face mate, fix/float/flexible, a dict call, a repeated interference check) +- [x] 5.2 Run them live (paced), record GIFs, add them to the Demos page +- [x] 5.3 Full mock suite, ruff, 100 % coverage, `mkdocs build --strict` diff --git a/openspec/queue.md b/openspec/queue.md index 59fb34af..94e0ff2c 100644 --- a/openspec/queue.md +++ b/openspec/queue.md @@ -13,8 +13,8 @@ Last updated 2026-11-06. | 1 | Tool descriptions agents can act on: one description standard, MCP hints and family tags, and a check that keeps them | #82 (docstring half) | `tool-descriptions-for-agents` (archived) | Done 2026-11-05 (#202, #203, #204). Left: after the next release, ask Glama for a rescan and record the score in the internal assessment | | 2 | Guidance through MCP and an installable skill: the server's `instructions`, the skill served as `skill://` resources with FastMCP 3.4.9's `SkillProvider`, and the same skill installable into an agent | #175 | `agent-skill-and-instructions` (archived) | Done 2026-11-06 (#205, #211; #147 closed as superseded) | | 3 | The circuit breaker locks a tool out after a success and never reopens; a reset tool | #208 | `circuit-breaker-recovery` (archived) | Done 2026-11-06 (#213) | -| 4 | Support levels for every tool, and COM coverage progress bars at the top of the README with an interactive docs page | #79 (coverage half) | `tool-support-levels-and-progress` | Implemented 2026-11-06, in review. #79's coverage half is done; its licence-tier half is item 8 | -| 5 | Tool gaps found building the U-joint: face mates, fixed parts and flexible sub-assemblies, angled planes, angled chamfers, `both_directions`, a stale interference result, thin extrudes | #209 | not yet written | | +| 4 | Support levels for every tool, and COM coverage progress bars at the top of the README with an interactive docs page | #79 (coverage half) | `tool-support-levels-and-progress` (archived) | Done 2026-11-06 (#214). #79's licence-tier half is item 8 | +| 5 | Tool gaps found building the U-joint: face mates, fixed parts and flexible sub-assemblies, angled planes, angled chamfers, `both_directions`, a stale interference result, thin extrudes | #209 | `ujoint-tool-gaps` | Implemented 2026-11-06 on `feat/ujoint-tool-gaps`; live-checked; awaiting push approval | | 6 | `--selftest` command and a compatibility-report issue template | #179 | not yet written | | | 7 | Skills that improve the agent's results, measured on the U-joint prompt (Opus 5.5 at medium effort: the first skill changed how the agent worked, not what it built) | #207 | not yet written | | | 8 | README: licence-tier tags | #79 (other half) | not yet written | | @@ -88,8 +88,8 @@ their reason), then run `python -m solidworks_mcp.progress.generate`. - `sketch_tutorial_simple_hole` suggests a negative-depth extrusion for the hole instead of `create_cut_extrude`. - `save_as` ignores `overwrite` (an existing file is replaced) and - `save_file` ignores `force_save`; the `export_*` tools require a - `format_type` they ignore (the extension decides) and ignore `options`; + `save_file` ignores `force_save`; the `export_*` file tools ignore + `options` (`format_type` is optional since #209: the extension decides); `ExportSTEPInput`, `ExportIGESInput` and `ExportSTLInput` are unused. - `check_interference` passes `include_hidden` where the adapter reads `ignore_hidden`, so it has no effect. diff --git a/openspec/specs/docs/coverage-progress/spec.md b/openspec/specs/docs/coverage-progress/spec.md new file mode 100644 index 00000000..b4f69e9b --- /dev/null +++ b/openspec/specs/docs/coverage-progress/spec.md @@ -0,0 +1,41 @@ +# docs/coverage-progress Specification + +## Purpose +TBD - created by archiving change tool-support-levels-and-progress. Update Purpose after archive. +## Requirements +### Requirement: COM coverage is counted from a committed snapshot + +The project SHALL keep a snapshot of the SolidWorks type library's interface and method names, and count coverage by scanning the adapter source for those method names, without needing SolidWorks. + +#### Scenario: Counting coverage +- **WHEN** the generator runs on any platform +- **THEN** it reports, for each core interface, the methods the adapters call out of its total, and the same for the whole API, from the snapshot and the adapter source only + +#### Scenario: Refreshing the snapshot +- **WHEN** the snapshot script runs on Windows with SolidWorks installed +- **THEN** it rewrites the snapshot from the installed type library and records the SolidWorks version it came from + +### Requirement: Progress bars at the top of the README + +The README SHALL open with a generated progress image: the tools by support level and the core COM coverage, with the whole-API figure, linking to the interactive page. + +#### Scenario: The image matches the code +- **WHEN** a tool, a level or the adapter's COM calls change and the image and data are not regenerated +- **THEN** the progress check fails and says which command regenerates them + +#### Scenario: Readable in both themes +- **WHEN** the README is viewed in GitHub's light or dark theme +- **THEN** the image draws its own dark panel, so the bars and their text look the same + +### Requirement: An interactive progress page + +The docs site SHALL have a page with the same bars and every tool and core interface, filterable by level and family. + +#### Scenario: Filtering +- **WHEN** a reader picks a level or a family +- **THEN** only the matching tools are shown, each coloured by its level, with its description on hover + +#### Scenario: Without JavaScript +- **WHEN** the page is read without scripts +- **THEN** the bars and a plain table of every tool and its level still show + diff --git a/openspec/specs/tools/support-levels/spec.md b/openspec/specs/tools/support-levels/spec.md new file mode 100644 index 00000000..e1287ebb --- /dev/null +++ b/openspec/specs/tools/support-levels/spec.md @@ -0,0 +1,45 @@ +# tools/support-levels Specification + +## Purpose +TBD - created by archiving change tool-support-levels-and-progress. Update Purpose after archive. +## Requirements +### Requirement: One support level per tool + +Every registered tool SHALL have exactly one support level, `stable`, `beta`, `experimental` or `planned`, recorded in one table, with a one-line reason for every level below `stable`. + +#### Scenario: A new tool without a level +- **WHEN** a tool is registered that the table does not list +- **THEN** the support-level check fails and names the tool + +#### Scenario: A listed tool that no longer exists +- **WHEN** the table lists a name that no registered tool has +- **THEN** the check fails and names it + +### Requirement: Levels are consistent with the code + +The check SHALL fail when a level contradicts what the code shows. + +#### Scenario: Planned means not implemented +- **WHEN** a tool is `planned` +- **THEN** its description says "Not available yet", and no tool outside `planned` says so + +#### Scenario: Stable means checked live +- **WHEN** a tool is `stable` +- **THEN** at least one test under `tests/live/` calls it by name + +### Requirement: The level is visible to clients + +A tool's level SHALL be shown in its tags and, below `stable`, in its results. + +#### Scenario: Tags +- **WHEN** a client lists the tools +- **THEN** each tool's tags include `level:` beside its family tag + +#### Scenario: A beta tool succeeds +- **WHEN** a `beta` or `experimental` tool returns `status: success` +- **THEN** the result also carries `warning`, a sentence giving the level and its reason, and every other key is unchanged + +#### Scenario: A stable tool succeeds +- **WHEN** a `stable` tool returns `status: success` +- **THEN** the result has no `warning` key + diff --git a/openspec/specs/tools/tool-descriptions/spec.md b/openspec/specs/tools/tool-descriptions/spec.md index d373c715..aea21986 100644 --- a/openspec/specs/tools/tool-descriptions/spec.md +++ b/openspec/specs/tools/tool-descriptions/spec.md @@ -71,3 +71,15 @@ The test suite SHALL fail when a registered tool: "Handle new thing." - **THEN** the description check fails and names the tool +### Requirement: A description states a level below stable + +A `beta` or `experimental` tool's description SHALL open with its level and the reason, in one short sentence, before what the tool does. + +#### Scenario: A beta tool +- **WHEN** an agent reads a `beta` tool's description +- **THEN** its first sentence starts "Beta:" and names the gap, e.g. "Beta: reads the material's name and library, not its properties." + +#### Scenario: The level changes +- **WHEN** a tool's level changes in the table +- **THEN** its description's opening sentence changes with it, with no edit to the tool, because the server adds that sentence from the table at registration; the check fails if a tool's description doesn't open with its level + diff --git a/src/solidworks_mcp/__init__.py b/src/solidworks_mcp/__init__.py index 5bfa4aea..b1bf0108 100644 --- a/src/solidworks_mcp/__init__.py +++ b/src/solidworks_mcp/__init__.py @@ -1,7 +1,7 @@ """SolidWorks MCP Server - Python Implementation with FastMCP and PydanticAI. This is a comprehensive Python implementation of the SolidWorks MCP server, providing -155 tools for SolidWorks automation with enhanced security, configurability, and modern +156 tools for SolidWorks automation with enhanced security, configurability, and modern Python architectures. Original TypeScript implementation rights and IP remain with the original author. This diff --git a/src/solidworks_mcp/adapters/base.py b/src/solidworks_mcp/adapters/base.py index 6002de1e..66b3ef32 100644 --- a/src/solidworks_mcp/adapters/base.py +++ b/src/solidworks_mcp/adapters/base.py @@ -214,6 +214,8 @@ class ExtrusionParameters(BaseModel): than silently falling back to some other sketch. thin_feature (bool): The thin feature value. thin_thickness (float | None): The thin thickness value. + thin_side (str): Which side of the sketch a thin wall grows on: + ``one_side``, ``other_side`` or ``mid_plane``. up_to_surface (str | None): The up to surface value. """ @@ -223,6 +225,7 @@ class ExtrusionParameters(BaseModel): both_directions: bool = False thin_feature: bool = False thin_thickness: float | None = None + thin_side: str = "one_side" auto_fillet_corners: bool = False fillet_corners_radius: float = 0.0 end_condition: str = "Blind" @@ -975,16 +978,21 @@ async def add_fillet( ) async def add_chamfer( - self, distance: float, edge_names: list[str] + self, + distance: float, + edge_names: list[str], + angle: float = 45.0, + flip: bool = False, ) -> AdapterResult[Any]: - """Add an equal-distance chamfer feature to selected edges. + """Add an angle-distance chamfer to the named or selected edges. - Chamfers the selected edges of the current solid body by the given - distance. + At the default 45 degrees both legs equal ``distance``. Args: - distance (float): Chamfer distance in millimeters. + distance (float): Chamfer distance in millimeters, along one face. edge_names (list[str]): List of edge names to chamfer. + angle (float): Chamfer angle in degrees. Defaults to 45. + flip (bool): Measure ``distance`` along the other face. Returns: AdapterResult: Feature result or error. @@ -1090,14 +1098,19 @@ async def create_reference_plane( offset: float = 0.0, angle: float = 0.0, flip: bool = False, + axis: str | None = None, + face_point: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: - """Create a reference plane offset from, or angled to, an existing plane. + """Create a reference plane offset from, angled to, or on a plane or face. Args: - reference (str): Name of the reference plane or planar face. + reference (str): Name of the reference plane. offset (float): Offset distance in millimetres. Defaults to 0.0. - angle (float): Angle in degrees. Defaults to 0.0. + angle (float): Angle in degrees about ``axis``. Defaults to 0.0. flip (bool): Reverse the offset or angle direction. Defaults to False. + axis (str | None): Named axis to rotate about; needed with ``angle``. + face_point (list[float] | None): ``[x, y, z]`` mm on a flat face to + use as the reference instead of ``reference``. Returns: AdapterResult[dict[str, Any]]: The new plane's name and parameters, or error. @@ -1266,6 +1279,27 @@ async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]: error="get_model_geometry_summary is not implemented by this adapter", ) + async def set_component_state( + self, + component: str, + fixed: bool | None = None, + flexible: bool | None = None, + ) -> AdapterResult[dict[str, Any]]: + """Fix or float a component, and make a sub-assembly rigid or flexible. + + Args: + component (str): Instance name, as ``list_components`` reports it. + fixed (bool | None): True fixes it, False floats it. + flexible (bool | None): True flexible, False rigid. + + Returns: + AdapterResult[dict[str, Any]]: The state before and after, or error. + """ + return AdapterResult( + status=AdapterResultStatus.ERROR, + error="set_component_state is not implemented by this adapter", + ) + async def add_mate( self, component_a: str, @@ -1276,6 +1310,8 @@ async def add_mate( alignment: str = "aligned", distance: float = 0.0, angle: float = 0.0, + face_a: list[float] | None = None, + face_b: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Mate two components together in the active assembly. @@ -1293,6 +1329,10 @@ async def add_mate( distance (float): Distance in millimetres, for a distance mate. Defaults to 0.0. angle (float): Angle in degrees, for an angle mate. Defaults to 0.0. + face_a (list[float] | None): A point on a face of the first + component, in its part coordinates (mm), used instead of + ``entity_a``. + face_b (list[float] | None): The same for the second component. Returns: AdapterResult[dict[str, Any]]: Mate details, or error. diff --git a/src/solidworks_mcp/adapters/circuit_breaker.py b/src/solidworks_mcp/adapters/circuit_breaker.py index 10a56baf..d4374f6d 100644 --- a/src/solidworks_mcp/adapters/circuit_breaker.py +++ b/src/solidworks_mcp/adapters/circuit_breaker.py @@ -687,13 +687,22 @@ async def add_fillet( ) async def add_chamfer( - self, distance: float, edge_names: list[str] + self, + distance: float, + edge_names: list[str], + angle: float = 45.0, + flip: bool = False, ) -> AdapterResult[SolidWorksFeature]: """Add chamfer through circuit breaker.""" return await self._execute_with_circuit_breaker( "add_chamfer", - lambda: self.adapter.add_chamfer(distance, edge_names), - input_dict={"distance": distance, "edge_names": edge_names}, + lambda: self.adapter.add_chamfer(distance, edge_names, angle, flip), + input_dict={ + "distance": distance, + "edge_names": edge_names, + "angle": angle, + "flip": flip, + }, ) async def get_selected_entities(self) -> AdapterResult[list[dict[str, Any]]]: @@ -756,16 +765,22 @@ async def create_reference_plane( offset: float = 0.0, angle: float = 0.0, flip: bool = False, + axis: str | None = None, + face_point: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Create reference plane through circuit breaker.""" return await self._execute_with_circuit_breaker( "create_reference_plane", - lambda: self.adapter.create_reference_plane(reference, offset, angle, flip), + lambda: self.adapter.create_reference_plane( + reference, offset, angle, flip, axis, face_point + ), input_dict={ "reference": reference, "offset": offset, "angle": angle, "flip": flip, + "axis": axis, + "face_point": face_point, }, ) @@ -1625,6 +1640,19 @@ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]: input_dict={"count": count}, ) + async def set_component_state( + self, + component: str, + fixed: bool | None = None, + flexible: bool | None = None, + ) -> AdapterResult[dict[str, Any]]: + """Set a component's state through circuit breaker.""" + return await self._execute_with_circuit_breaker( + "set_component_state", + lambda: self.adapter.set_component_state(component, fixed, flexible), + input_dict={"component": component, "fixed": fixed, "flexible": flexible}, + ) + async def add_mate( self, component_a: str, @@ -1635,6 +1663,8 @@ async def add_mate( alignment: str = "aligned", distance: float = 0.0, angle: float = 0.0, + face_a: list[float] | None = None, + face_b: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Add mate through circuit breaker. @@ -1649,6 +1679,9 @@ async def add_mate( alignment (str): Mate alignment. Defaults to "aligned". distance (float): Distance in millimetres. Defaults to 0.0. angle (float): Angle in degrees. Defaults to 0.0. + face_a (list[float] | None): A point on a face of the first + component, in its part coordinates (mm), instead of entity_a. + face_b (list[float] | None): The same for the second component. Returns: AdapterResult[dict[str, Any]]: The result produced by the operation. @@ -1664,6 +1697,8 @@ async def add_mate( alignment, distance, angle, + face_a, + face_b, ), input_dict={ "component_a": component_a, @@ -1674,6 +1709,8 @@ async def add_mate( "alignment": alignment, "distance": distance, "angle": angle, + "face_a": face_a, + "face_b": face_b, }, ) diff --git a/src/solidworks_mcp/adapters/connection_pool.py b/src/solidworks_mcp/adapters/connection_pool.py index f38e8687..5ad99d18 100644 --- a/src/solidworks_mcp/adapters/connection_pool.py +++ b/src/solidworks_mcp/adapters/connection_pool.py @@ -657,11 +657,16 @@ async def add_fillet( ) async def add_chamfer( - self, distance: float, edge_names: list[str] + self, + distance: float, + edge_names: list[str], + angle: float = 45.0, + flip: bool = False, ) -> AdapterResult[SolidWorksFeature]: """Add chamfer using pool.""" return await self._execute_with_pool( - "add_chamfer", lambda adapter: adapter.add_chamfer(distance, edge_names) + "add_chamfer", + lambda adapter: adapter.add_chamfer(distance, edge_names, angle, flip), ) async def get_selected_entities(self) -> AdapterResult[list[dict[str, Any]]]: @@ -709,12 +714,14 @@ async def create_reference_plane( offset: float = 0.0, angle: float = 0.0, flip: bool = False, + axis: str | None = None, + face_point: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Create reference plane using pool.""" return await self._execute_with_pool( "create_reference_plane", lambda adapter: adapter.create_reference_plane( - reference, offset, angle, flip + reference, offset, angle, flip, axis, face_point ), ) @@ -1516,6 +1523,18 @@ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]: "undo", lambda adapter: adapter.undo(count) ) + async def set_component_state( + self, + component: str, + fixed: bool | None = None, + flexible: bool | None = None, + ) -> AdapterResult[dict[str, Any]]: + """Set a component's state using pool.""" + return await self._execute_with_pool( + "set_component_state", + lambda adapter: adapter.set_component_state(component, fixed, flexible), + ) + async def add_mate( self, component_a: str, @@ -1526,6 +1545,8 @@ async def add_mate( alignment: str = "aligned", distance: float = 0.0, angle: float = 0.0, + face_a: list[float] | None = None, + face_b: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Add mate using pool. @@ -1540,6 +1561,9 @@ async def add_mate( alignment (str): Mate alignment. Defaults to "aligned". distance (float): Distance in millimetres. Defaults to 0.0. angle (float): Angle in degrees. Defaults to 0.0. + face_a (list[float] | None): A point on a face of the first + component, in its part coordinates (mm), instead of entity_a. + face_b (list[float] | None): The same for the second component. Returns: AdapterResult[dict[str, Any]]: The result produced by the operation. @@ -1555,6 +1579,8 @@ async def add_mate( alignment, distance, angle, + face_a, + face_b, ), ) diff --git a/src/solidworks_mcp/adapters/mock_adapter.py b/src/solidworks_mcp/adapters/mock_adapter.py index ec9ca4ef..cbd3d672 100644 --- a/src/solidworks_mcp/adapters/mock_adapter.py +++ b/src/solidworks_mcp/adapters/mock_adapter.py @@ -202,6 +202,7 @@ def __init__(self, config: object | None = None) -> None: # Mates added via add_mate, in order, so the geometry summary can # report a mate count without real geometry. self._mates: list[dict[str, Any]] = [] + self._component_states: dict[str, dict[str, bool]] = {} # Drawing views placed via create_drawing_view / add_drawing_view / # create_technical_drawing, in sheet order. See list_drawing_views. self._drawing_views: list[str] = [] @@ -856,10 +857,17 @@ async def add_fillet( return await self._mock_edge_feature("Fillet", radius, edge_names) async def add_chamfer( - self, distance: float, edge_names: list[str] + self, + distance: float, + edge_names: list[str], + angle: float = 45.0, + flip: bool = False, ) -> AdapterResult[SolidWorksFeature]: - """Mock equal-distance chamfer (see :meth:`_mock_edge_feature`).""" - return await self._mock_edge_feature("Chamfer", distance, edge_names) + """Mock angle-distance chamfer (see :meth:`_mock_edge_feature`).""" + result = await self._mock_edge_feature("Chamfer", distance, edge_names) + if result.is_success: + result.data.parameters.update({"angle": angle, "flip": flip}) + return result async def _mock_edge_feature( self, kind: str, size: float, edge_names: list[str] @@ -1160,8 +1168,12 @@ async def create_extrusion( "draft_angle": params.draft_angle, "reverse_direction": params.reverse_direction, "both_directions": params.both_directions, + "end_condition": "MidPlane" + if params.both_directions + else params.end_condition, "thin_feature": params.thin_feature, "thin_thickness": params.thin_thickness, + "thin_side": params.thin_side if params.thin_feature else None, }, properties={"created": datetime.now().isoformat(), "mock": True}, ) @@ -3036,6 +3048,42 @@ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]: execution_time=self._delays["model_operation"], ) + async def set_component_state( + self, + component: str, + fixed: bool | None = None, + flexible: bool | None = None, + ) -> AdapterResult[dict[str, Any]]: + """Mock fixing, floating and flexing a component. + + Tracks each component's state on ``self._component_states``. + + Args: + component (str): Instance name. + fixed (bool | None): True fixes it, False floats it. + flexible (bool | None): True flexible, False rigid. + + Returns: + AdapterResult[dict[str, Any]]: The state before and after. + """ + if fixed is None and flexible is None: + return AdapterResult( + status=AdapterResultStatus.ERROR, + error="Nothing to change: give fixed, flexible or both.", + ) + states = self._component_states + before = dict(states.get(component, {"fixed": False, "flexible": False})) + after = dict(before) + if fixed is not None: + after["fixed"] = fixed + if flexible is not None: + after["flexible"] = flexible + states[component] = after + return AdapterResult( + status=AdapterResultStatus.SUCCESS, + data={"component": component, "before": before, "after": after}, + ) + async def add_mate( self, component_a: str, @@ -3046,6 +3094,8 @@ async def add_mate( alignment: str = "aligned", distance: float = 0.0, angle: float = 0.0, + face_a: list[float] | None = None, + face_b: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Mock mating two components in the active assembly. @@ -3061,10 +3111,15 @@ async def add_mate( distance (float): Distance in millimetres, for a distance mate. Defaults to 0.0. angle (float): Angle in degrees, for an angle mate. Defaults to 0.0. + face_a (list[float] | None): A point on a face of the first + component (part coordinates, mm), used instead of entity_a. + face_b (list[float] | None): The same for the second component. Returns: AdapterResult[dict[str, Any]]: The result produced by the operation. """ + from .solidworks.face_selection import mate_entity_label + known_mate_types = { "coincident", "concentric", @@ -3095,7 +3150,10 @@ async def add_mate( "mate_type": mate_type, "alignment": alignment, "components": [component_a, component_b], - "entities": [entity_a, entity_b], + "entities": [ + mate_entity_label(entity_a, face_a), + mate_entity_label(entity_b, face_b), + ], "distance": distance or None, "angle": angle or None, # The real adapter fills these by comparing every component's @@ -3117,51 +3175,36 @@ async def create_reference_plane( offset: float = 0.0, angle: float = 0.0, flip: bool = False, + axis: str | None = None, + face_point: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: - """Mock creating a reference plane offset from an existing plane. - - Mirrors the live adapter's refusals rather than fabricating a - result it cannot know: an angled plane needs a second reference this - signature cannot supply, and an offset of zero would be coincident - with its reference. See ``_create_reference_plane_impl`` in - ``solidworks/features.py`` for the live contract this mirrors. + """Mock creating a reference plane offset from, angled to, or on a face. - On success, invents a sequential ``PlaneN`` name tracked on + Refuses exactly what the live adapter refuses (the shared + ``reference_plane_refusal`` in ``solidworks/features.py``). On + success, invents a sequential ``PlaneN`` name tracked on ``self._reference_planes`` and advances the shared mock feature-tree counter so ``features_before``/``features_after`` differ by exactly one, the same signal the live adapter derives from ``IFeatureManager::GetFeatureCount``. Args: - reference (str): Name of the reference plane or planar face. + reference (str): Name of the reference plane. offset (float): Offset distance in millimetres. Defaults to 0.0. - angle (float): Angle in degrees; unsupported (non-zero errors). - Defaults to 0.0. + angle (float): Angle in degrees about ``axis``. Defaults to 0.0. flip (bool): Reverse the offset direction. Defaults to False. + axis (str | None): Axis to rotate about, for an angle. + face_point (list[float] | None): Point on a flat face, in mm. Returns: AdapterResult[dict[str, Any]]: The new plane's name and parameters, or error. """ - if angle: - return AdapterResult( - status=AdapterResultStatus.ERROR, - error=( - "create_reference_plane does not support 'angle' yet: an " - "angled plane needs a second reference (an axis or edge " - "to rotate about) which this signature cannot take. Use " - "'offset' for parallel planes." - ), - ) + from .solidworks.features import reference_plane_refusal - if not offset: - return AdapterResult( - status=AdapterResultStatus.ERROR, - error=( - "create_reference_plane requires a non-zero offset - a " - "plane coincident with its reference is not useful" - ), - ) + refusal = reference_plane_refusal(reference, offset, angle, axis, face_point) + if refusal is not None: + return AdapterResult(status=AdapterResultStatus.ERROR, error=refusal) await asyncio.sleep(self._delays["feature_operation"]) self._operation_count += 1 @@ -3176,9 +3219,11 @@ async def create_reference_plane( status=AdapterResultStatus.SUCCESS, data={ "name": self._reference_planes[-1], - "reference": reference, - "offset": offset, - "angle": None, + "reference": reference if face_point is None else None, + "face_point": face_point, + "offset": offset or None, + "angle": angle or None, + "axis": axis if angle else None, "flip": flip, "features_before": before, "features_after": after, @@ -3223,10 +3268,8 @@ async def create_axis(self, reference: str) -> AdapterResult[dict[str, Any]]: # Invent a sequential "AxisN" name, tracked on self._axes, the same # way create_reference_plane invents "PlaneN" names on - # self._reference_planes. InsertAxis2 returns only a bool, so the - # live adapter cannot report a name either - but pattern_circular - # needs some name to validate the axis argument against, and this - # keeps the mock's bookkeeping consistent with the plane convention. + # self._reference_planes. The live adapter reads the new axis's name + # from the end of the feature tree (InsertAxis2 returns only a bool). self._axes.append(f"Axis{len(self._axes) + 1}") return AdapterResult( diff --git a/src/solidworks_mcp/adapters/solidworks/face_selection.py b/src/solidworks_mcp/adapters/solidworks/face_selection.py index bc784a2a..3085f31f 100644 --- a/src/solidworks_mcp/adapters/solidworks/face_selection.py +++ b/src/solidworks_mcp/adapters/solidworks/face_selection.py @@ -73,6 +73,23 @@ def side_vector(side: str) -> tuple[float, float, float]: ) from None +def mate_entity_label(entity: str, face: list[float] | None) -> str: + """How a mate's reply names one side: the entity, or the face point. + + Shared by the live and mock adapters. + + Args: + entity: The named entity (used when no face is given). + face: A point on a face, in part coordinates (mm). + + Returns: + str: e.g. ``"Front Plane"`` or ``"face at [0.0, 5.0, 0.0] mm"``. + """ + if face is None: + return entity + return f"face at {[float(c) for c in face]} mm" + + def validate_face_query( point: list[float] | None, side: str | None, @@ -265,6 +282,21 @@ def faces(self) -> list[FaceInfo]: part = self._model _flag(self._adapter, part, "GetBodies2") bodies = as_list(_attempt(self._adapter, lambda: part.GetBodies2(0, False))) + return self._body_faces(bodies) + + def component_faces(self, component: Any) -> list[FaceInfo]: + """Return every face of a component's solid bodies. + + Measured on SW 2026: these faces report their geometry in the + component's own part coordinates, wherever it sits in the assembly, + and ``IEntity::GetComponent`` on them returns the component. + """ + _flag(self._adapter, component, "GetBodies2") + bodies = as_list(_attempt(self._adapter, lambda: component.GetBodies2(0))) + return self._body_faces(bodies) + + def _body_faces(self, bodies: list[Any]) -> list[FaceInfo]: + """Describe every face of ``bodies``.""" out: list[FaceInfo] = [] for body in bodies: _flag(self._adapter, body, "GetFaces") diff --git a/src/solidworks_mcp/adapters/solidworks/features.py b/src/solidworks_mcp/adapters/solidworks/features.py index 8f497259..be553139 100644 --- a/src/solidworks_mcp/adapters/solidworks/features.py +++ b/src/solidworks_mcp/adapters/solidworks/features.py @@ -99,9 +99,13 @@ async def add_fillet( return result async def add_chamfer( - self, distance: float, edge_names: list[str] + self, + distance: float, + edge_names: list[str], + angle: float = 45.0, + flip: bool = False, ) -> AdapterResult[SolidWorksFeature]: - return _add_chamfer_impl(self, distance, edge_names) + return _add_chamfer_impl(self, distance, edge_names, angle, flip) async def delete_feature(self, name: str) -> AdapterResult[dict[str, Any]]: return _delete_feature_impl(self, name) @@ -135,8 +139,12 @@ async def create_reference_plane( offset: float = 0.0, angle: float = 0.0, flip: bool = False, + axis: str | None = None, + face_point: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: - return _create_reference_plane_impl(self, reference, offset, angle, flip) + return _create_reference_plane_impl( + self, reference, offset, angle, flip, axis, face_point + ) async def create_axis(self, reference: str) -> AdapterResult[dict[str, Any]]: return _create_axis_impl(self, reference) @@ -351,6 +359,10 @@ async def mirror_feature( } +#: ``swThinWallType_e`` for each ``thin_side``. +_THIN_SIDES: dict[str, int] = {"one_side": 0, "other_side": 1, "mid_plane": 2} + + def _extrusion_end_condition( adapter: Any, end_condition: str, both_directions: bool ) -> tuple[str, int]: @@ -465,7 +477,13 @@ def _extrusion_operation() -> SolidWorksFeature: both_directions=bool(getattr(params, "both_directions", False)), auto_fillet_corners=bool(getattr(params, "auto_fillet_corners", False)), fillet_corners_radius=float(getattr(params, "fillet_corners_radius", 0.0)), + thin_side=str(getattr(params, "thin_side", "one_side") or "one_side"), ) + if normalized.thin_side not in _THIN_SIDES: + raise ValueError( + f"Unsupported thin_side {normalized.thin_side!r}; use one of: " + + ", ".join(_THIN_SIDES) + ) feature_manager = adapter.currentModel.FeatureManager t0 = adapter.constants.get("swStartSketchPlane", 0) end_name, t1 = _extrusion_end_condition( @@ -497,9 +515,9 @@ def _extrusion_operation() -> SolidWorksFeature: normalized.merge_result, normalized.thin_thickness / 1000.0, normalized.thin_thickness / 1000.0, - 0.0, - 0, - 0, + 0.0, # EndThk + _THIN_SIDES[normalized.thin_side], # RevThinDir: swThinWallType_e + 0, # CapEnds normalized.auto_fillet_corners, normalized.fillet_corners_radius / 1000.0, False, @@ -595,6 +613,8 @@ def _extrusion_operation() -> SolidWorksFeature: "reverse_direction": normalized.reverse_direction, "thin_feature": normalized.thin_feature, "thin_thickness": normalized.thin_thickness, + "thin_side": normalized.thin_side if normalized.thin_feature else None, + "both_directions": normalized.both_directions, "end_condition": end_name, }, properties={"created": datetime.now().isoformat()}, @@ -2065,14 +2085,21 @@ def _fillet_operation() -> SolidWorksFeature: def _add_chamfer_impl( - adapter: Any, distance: float, edge_names: list[str] + adapter: Any, + distance: float, + edge_names: list[str], + angle: float = 45.0, + flip: bool = False, ) -> AdapterResult[SolidWorksFeature]: - """Create an equal-distance chamfer on one or more named edges. + """Create an angle-distance chamfer on one or more named edges. Each edge in ``edge_names`` is selected by name using ``Extension.SelectByID2`` with entity type ``"EDGE"``. After all edges are in the selection set, ``IFeatureManager::InsertFeatureChamfer`` is - called in equal-distance mode (type ``1``). A version-gated + called with ``swChamferAngleDistance`` (type ``1``; equal distance is + 16). At 45 degrees the two legs are equal, which is what this call has + always made. ``Options`` 1 is ``swFeatureChamferFlipDirection``, kept as + the default so existing chamfers are unchanged; ``flip`` clears it. A version-gated ``IModelDoc2::FeatureChamferType`` path was tried for SW 2025+ but live-verified to silently create an empty, zero-volume feature -- see the comment at its call site for how this was confirmed and why @@ -2081,8 +2108,10 @@ def _add_chamfer_impl( Args: adapter: A fully connected ``PyWin32Adapter`` with a non-``None`` ``currentModel``. - distance: Chamfer distance in **millimetres**. Converted to metres - internally. + distance: Chamfer distance in **millimetres**, along one face. + Converted to metres internally. + angle: Chamfer angle in degrees (0-90 exclusive). Defaults to 45. + flip: Measure ``distance`` along the other face. edge_names: List of SolidWorks edge entity names, e.g. ``["Edge<2>", "Edge<5>"]``, coordinate hints in metres (``"x,y,z"``), or ``"face:x,y,z"`` to select a whole face and @@ -2168,10 +2197,10 @@ def _chamfer_operation() -> SolidWorksFeature: feature_name = "Chamfer" feature, insert_err = adapter._attempt_with_error( lambda: fm.InsertFeatureChamfer( - 1, # Options - 1, # ChamferType = equal distance + 0 if flip else 1, # Options: swFeatureChamferFlipDirection + 1, # ChamferType: swChamferAngleDistance distance / 1000.0, # Width in metres - math.pi / 4, # 45 degree angle + math.radians(angle), 0.0, 0.0, 0.0, @@ -2189,7 +2218,12 @@ def _chamfer_operation() -> SolidWorksFeature: name=feature_name, type="Chamfer", id=adapter._get_feature_id(feature) if feature else "", - parameters={"distance": distance, "edges": edge_names}, + parameters={ + "distance": distance, + "angle": angle, + "flip": flip, + "edges": edge_names, + }, properties={"created": datetime.now().isoformat()}, ) @@ -2585,6 +2619,7 @@ def _undo_operation() -> dict[str, Any]: #: ``swRefPlaneReferenceConstraints_e`` members used by ``InsertRefPlane``. _REF_PLANE_DISTANCE = 8 _REF_PLANE_ANGLE = 16 +_REF_PLANE_COINCIDENT = 4 _REF_PLANE_OPTION_FLIP = 256 #: Plane pairs whose intersection defines each principal axis. ``InsertAxis2`` @@ -2707,36 +2742,121 @@ def _select_reference_entity( return False +def _select_face_at_point(adapter: Any, point_mm: list[float], mark: int) -> bool: + """Select the planar face of the active part at ``point_mm`` under ``mark``. + + The face is found as ``select_face_edges`` finds it (the face whose + closest point is within 0.01 mm), then selected with ``IEntity.Select4`` + and a ``SelectData`` carrying the mark ``InsertRefPlane`` reads. + + Args: + adapter: A connected adapter with a part open. + point_mm: ``[x, y, z]`` on the face, in millimetres. + mark: Selection mark for this reference. + + Returns: + bool: True when the face was selected. + + Raises: + LookupError: When no face lies at the point, or the face isn't flat. + """ + from .face_selection import FaceEdgeSelector + + selector = FaceEdgeSelector(adapter) + face = selector.face_at_point(selector.faces(), point_mm) + if not face.is_planar: + raise LookupError( + f"The face at {list(point_mm)} mm is not flat; a plane needs a flat face." + ) + sel_mgr = adapter.currentModel.SelectionManager + _flag(adapter, sel_mgr, "CreateSelectData") + select_data = _attempt(adapter, lambda: sel_mgr.CreateSelectData()) + select_data.Mark = mark + _flag(adapter, face.handle, "Select4") + return bool( + _attempt(adapter, lambda: face.handle.Select4(False, select_data), False) + ) + + +def reference_plane_refusal( + reference: str, + offset: float, + angle: float, + axis: str | None, + face_point: list[float] | None, +) -> str | None: + """Why a reference-plane request can't be made, or None when it can. + + Shared by the live and mock adapters so both refuse the same requests. + + Args: + reference: Named reference plane (unused with ``face_point``). + offset: Offset in mm. + angle: Angle in degrees. + axis: Axis to rotate about, for an angle. + face_point: Point on a flat face to use as the reference. + + Returns: + str | None: The refusal message, or None. + """ + if not reference and face_point is None: + return ( + "create_reference_plane requires a reference plane name or a " + "face_point on a flat face" + ) + if angle and not axis: + return ( + "An angled plane needs an axis to rotate about: pass 'axis' " + "(e.g. 'Axis1', made with create_axis)." + ) + if not angle and not offset and face_point is None: + return ( + "create_reference_plane requires a non-zero offset - a plane " + "coincident with a named plane is not useful" + ) + return None + + def _create_reference_plane_impl( adapter: Any, reference: str, offset: float, angle: float, flip: bool, + axis: str | None = None, + face_point: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: - """Create a reference plane offset from, or angled to, an existing plane. + """Create a reference plane offset from, angled to, or on a plane or face. Wraps ``IFeatureManager::InsertRefPlane(FirstConstraint, - FirstConstraintAngleOrDistance, Second, 0, Third, 0)``. The reference - entity must be selected under **mark 0** first, which is what the - SolidWorks documentation example does before the identical call. - - This is what lets a sketch be opened anywhere other than the six built-in - planes. ``create_sketch`` already accepts a plane by its tree name, so the - name returned here can be passed straight to it - verified live: a plane - 76.2 mm off the Front Plane came back as ``"Plane1"``, and a circle - sketched on it extruded to exactly the expected volume. + FirstConstraintAngleOrDistance, Second, value, Third, value)``. The + reference (a named plane, or the flat face at ``face_point``) is selected + under **mark 0**; for an angled plane, ``axis`` is selected under + **mark 1** with the Coincident constraint, which is the second reference + an angled plane needs (an angle constraint on one plane alone adds + nothing, measured on SW 2025). + + ``create_sketch`` accepts a plane by its tree name, so the name returned + here can be passed straight to it - verified live: a plane 76.2 mm off + the Front Plane came back as ``"Plane1"``, and a circle sketched on it + extruded to exactly the expected volume. Args: adapter: A connected ``PyWin32Adapter`` with a valid ``currentModel``. - reference: Name of the reference plane or planar face. + reference: Name of the reference plane; ignored when ``face_point`` + is given. offset: Offset distance in **millimetres**, converted to metres. - angle: Angle in **degrees**; used instead of ``offset`` when non-zero. + angle: Angle in **degrees** about ``axis``; used instead of ``offset`` + when non-zero. flip: Reverse the offset or angle direction. + axis: Named axis to rotate about (e.g. ``"Axis1"``); required with + ``angle``. + face_point: ``[x, y, z]`` mm on a flat face to use as the reference; + with a zero offset the plane lies on the face. Returns: AdapterResult[dict[str, Any]]: The new plane's name and the parameters - used. ``ERROR`` when there is no model, the reference cannot be + used. ``ERROR`` when there is no model, a reference cannot be selected, or no plane was added to the tree. Raises: @@ -2744,42 +2864,19 @@ def _create_reference_plane_impl( Example:: - plane = await adapter.create_reference_plane("Front Plane", offset=76.2) + plane = await adapter.create_reference_plane("Top Plane", angle=40, axis="Axis1") await adapter.create_sketch(plane.data["name"]) """ if not adapter.currentModel: return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model") - if not reference: - return AdapterResult( - status=AdapterResultStatus.ERROR, - error="create_reference_plane requires a reference plane/face name", - ) - - if angle: - # Measured on SW 2025: InsertRefPlane with the angle constraint and a - # single selected plane adds nothing to the tree and reports no error. - # An angled plane is under-defined by one reference - SolidWorks needs - # a second entity (an axis or edge) under mark 1 to rotate about. - # Refusing is better than returning a plane name that does not exist. - return AdapterResult( - status=AdapterResultStatus.ERROR, - error=( - "create_reference_plane does not support 'angle' yet: an " - "angled plane needs a second reference (an axis or edge to " - "rotate about) which this signature cannot take. Use 'offset' " - "for parallel planes." - ), - ) + refusal = reference_plane_refusal(reference, offset, angle, axis, face_point) + if refusal is not None: + return AdapterResult(status=AdapterResultStatus.ERROR, error=refusal) - if not offset: - return AdapterResult( - status=AdapterResultStatus.ERROR, - error=( - "create_reference_plane requires a non-zero offset - a plane " - "coincident with its reference is not useful" - ), - ) + described = ( + f"the face at {list(face_point)} mm" if face_point is not None else reference + ) def _plane_operation() -> dict[str, Any]: before = _feature_count(adapter) @@ -2787,34 +2884,48 @@ def _plane_operation() -> dict[str, Any]: lambda: adapter.currentModel.ClearSelection2(True), default=None ) - if not _select_reference_entity(adapter, reference, 0, append=False): + if face_point is not None: + if not _select_face_at_point(adapter, face_point, 0): + raise Exception(f"Failed to select {described}.") + elif not _select_reference_entity(adapter, reference, 0, append=False): raise Exception( f"Failed to select reference plane/face: {reference}. " - "Use an existing plane name (e.g. 'Front Plane') or a planar " - "face." + "Use an existing plane name (e.g. 'Front Plane'), or face_point " + "for a face." ) - value, flip_bits = _offset_plane_distance(offset, flip) - constraint = _REF_PLANE_DISTANCE | flip_bits + if angle: + if not _select_named_feature(adapter, str(axis), 1, append=True): + raise Exception( + f"Failed to select axis '{axis}'. Use an axis in the tree, " + "e.g. one made with create_axis." + ) + first = _REF_PLANE_ANGLE | (_REF_PLANE_OPTION_FLIP if flip else 0) + value = math.radians(float(angle)) + second = _REF_PLANE_COINCIDENT + elif offset: + value, flip_bits = _offset_plane_distance(offset, flip) + first = _REF_PLANE_DISTANCE | flip_bits + second = 0 + else: + first, value, second = _REF_PLANE_COINCIDENT, 0.0, 0 feature_manager = sw_type_info.flagged( adapter.currentModel.FeatureManager, "IFeatureManager" ) plane = adapter._attempt( - lambda: feature_manager.InsertRefPlane(constraint, value, 0, 0.0, 0, 0.0), + lambda: feature_manager.InsertRefPlane(first, value, second, 0.0, 0, 0.0), default=None, ) after = _feature_count(adapter) if before is not None and after is not None and after <= before: raise Exception( - f"No reference plane was added from '{reference}' - the " + f"No reference plane was added from {described} - the " f"feature tree still has {after} feature(s)." ) if plane is None: - raise Exception( - f"InsertRefPlane returned nothing for reference '{reference}'" - ) + raise Exception(f"InsertRefPlane returned nothing for {described}") name = adapter._attempt( lambda: adapter._get_attr_or_call(plane, "Name"), default=None @@ -2829,9 +2940,11 @@ def _plane_operation() -> dict[str, Any]: return { "name": str(name), - "reference": reference, + "reference": reference if face_point is None else None, + "face_point": list(face_point) if face_point is not None else None, "offset": offset or None, "angle": angle or None, + "axis": axis if angle else None, "flip": flip, "features_before": before, "features_after": after, @@ -2905,7 +3018,14 @@ def _axis_operation() -> dict[str, Any]: "unchanged." ) + # The new axis is the last feature; its name is what create_reference_plane's + # `axis` takes. None if it can't be read, never invented. + newest = adapter._attempt( + lambda: adapter.currentModel.FeatureByPositionReverse(0), default=None + ) + name = adapter._attempt(lambda: newest.Name, default=None) if newest else None return { + "name": str(name) if name else None, "reference": key, "planes": [plane_a, plane_b], "features_before": before, diff --git a/src/solidworks_mcp/adapters/solidworks/io.py b/src/solidworks_mcp/adapters/solidworks/io.py index 8f781a3b..af6bff79 100644 --- a/src/solidworks_mcp/adapters/solidworks/io.py +++ b/src/solidworks_mcp/adapters/solidworks/io.py @@ -14,6 +14,7 @@ from .. import sw_type_info as _sw_type_info from ..base import AdapterResult, AdapterResultStatus, MassProperties, SolidWorksModel +from .face_selection import mate_entity_label try: import pythoncom @@ -293,6 +294,118 @@ def _byref_bstr() -> Any: ) +def _find_components(adapter: Any, assembly: Any, names: list[str]) -> dict[str, Any]: + """The named top-level components of ``assembly``, as ``IComponent2``. + + Args: + adapter: The connected adapter. + assembly: The ``IAssemblyDoc`` dispatch. + names: Instance names, e.g. ``["bracket-1", "pin-1"]``. + + Returns: + dict[str, Any]: Name to wrapped component, one per name. + + Raises: + Exception: When the components can't be read, or a name isn't there + (the message lists what the assembly holds). + """ + components = adapter._attempt(lambda: assembly.GetComponents(True), default=None) + if not isinstance(components, (list, tuple)): + raise Exception("Could not read the assembly's components") + + found: dict[str, Any] = {} + for component in components: + wrapped = _as_com(adapter, component, "IComponent2") + if wrapped is None: + continue + name = adapter._attempt(lambda w=wrapped: w.Name2, default=None) + if name and str(name) in names: + found[str(name)] = wrapped + + missing = [n for n in names if n not in found] + if missing: + available = [ + str( + adapter._attempt( + lambda c=c: _as_com(adapter, c, "IComponent2").Name2, + default="?", + ) + ) + for c in components + ] + raise Exception( + f"Component(s) not found: {', '.join(missing)}. " + f"The assembly holds: {', '.join(available)}." + ) + return found + + +def _component_state(adapter: Any, component: Any) -> dict[str, Any]: + """Read what ``CompConfigProperties5`` sets, plus whether it is fixed.""" + visible = adapter._attempt(lambda: component.Visible, default=None) + return { + "fixed": adapter._attempt(lambda: bool(component.IsFixed()), default=None), + "flexible": adapter._attempt(lambda: component.Solving == 1, default=None), + "suppression": adapter._attempt( + lambda: component.GetSuppression2(), default=None + ), + "visible": None if visible is None else visible == 1, + "referenced_configuration": adapter._attempt( + lambda: component.ReferencedConfiguration, default=None + ), + "exclude_from_bom": adapter._attempt( + lambda: bool(component.ExcludeFromBOM), default=None + ), + "envelope": adapter._attempt( + lambda: bool(component.IsEnvelope()), default=None + ), + } + + +def _select_component_face( + adapter: Any, + model: Any, + component: Any, + component_name: str, + point: list[float], + append: bool, +) -> None: + """Select the face of ``component`` at ``point`` (its part coordinates, mm). + + Only that component's faces are searched, so coincident surfaces of two + parts (a pin in its hole) can't be confused. + + Args: + adapter: The connected adapter. + model: The active assembly document. + component: The ``IComponent2`` dispatch. + component_name: Its instance name, for messages. + point: ``[x, y, z]`` mm on the face, in the component's part coordinates. + append: Add to the current selection. + + Raises: + Exception: When no face of the component lies at the point, or it + won't select. + """ + from .face_selection import FaceEdgeSelector + + selector = FaceEdgeSelector(adapter) + try: + face = selector.face_at_point(selector.component_faces(component), point) + except LookupError as exc: + raise Exception( + f"Face not found on {component_name} at {list(point)} mm (part " + f"coordinates): {exc}" + ) from exc + sel_mgr = _sw_type_info.flagged(model.SelectionManager, "ISelectionMgr") + select_data = adapter._attempt(lambda: sel_mgr.CreateSelectData(), default=None) + handle = _sw_type_info.flagged(face.handle, "IEntity") + if not adapter._attempt(lambda: handle.Select4(append, select_data), default=False): + raise Exception( + f"Failed to select the face of {component_name} at {list(point)} mm" + ) + + def _byref_int() -> Any: """Return a byref long VARIANT for a SolidWorks out-parameter. @@ -2616,17 +2729,19 @@ async def add_mate( alignment: str = "aligned", distance: float = 0.0, angle: float = 0.0, + face_a: list[float] | None = None, + face_b: list[float] | None = None, ) -> AdapterResult[dict[str, Any]]: """Mate two components together. - Wraps ``IAssemblyDoc::AddMate5``. The two entities are selected via - ``IComponent2::FeatureByName`` + ``IFeature::Select2`` rather than - ``SelectByID2``, which raises ``Type mismatch`` on this build. - - That restricts the entities to *named tree features* — the reference - planes and axes of each component. Plane-to-plane mating covers - alignment and stacking, which is the common case; mating to a specific - face or edge needs entity names this adapter cannot enumerate. + Wraps ``IAssemblyDoc::AddMate5``. A named entity (a reference plane or + axis) is selected via ``IComponent2::FeatureByName`` + + ``IFeature::Select2``. A face is given as a point on it in the + component's own part coordinates: the face is found among *that + component's* faces only (``IComponent2::GetBodies2``, which report + part coordinates; measured on SW 2026) and selected with + ``IEntity::Select4``. Searching one component means a pin and the + hole it sits in, whose surfaces share points, can't be confused. Args: component_a (str): First component instance name, as reported by @@ -2639,6 +2754,10 @@ async def add_mate( alignment (str): ``aligned``, ``anti_aligned`` or ``closest``. distance (float): Distance in millimetres, for a distance mate. angle (float): Angle in degrees, for an angle mate. + face_a (list[float] | None): ``[x, y, z]`` mm on a face of the + first component, in its part coordinates; used instead of + ``entity_a``. + face_b (list[float] | None): The same for the second component. Returns: AdapterResult[dict[str, Any]]: The mate created, plus the bounding @@ -2685,42 +2804,20 @@ def _mate() -> dict[str, Any]: model = adapter.currentModel assembly = _sw_type_info.flagged(model, "IAssemblyDoc") - components = adapter._attempt( - lambda: assembly.GetComponents(True), default=None - ) - if not isinstance(components, (list, tuple)): - raise Exception("Could not read the assembly's components") - wanted = {component_a: entity_a, component_b: entity_b} - found: dict[str, Any] = {} - for component in components: - wrapped = _as_com(adapter, component, "IComponent2") - if wrapped is None: - continue - name = adapter._attempt(lambda w=wrapped: w.Name2, default=None) - if name and str(name) in wanted: - found[str(name)] = wrapped - - missing = [n for n in (component_a, component_b) if n not in found] - if missing: - available = [ - str( - adapter._attempt( - lambda c=c: _as_com(adapter, c, "IComponent2").Name2, - default="?", - ) - ) - for c in components - ] - raise Exception( - f"Component(s) not found: {', '.join(missing)}. " - f"The assembly holds: {', '.join(available)}." - ) + faces = {component_a: face_a, component_b: face_b} + found = _find_components(adapter, assembly, [component_a, component_b]) adapter._attempt(lambda: model.ClearSelection2(True), default=None) for index, component_name in enumerate((component_a, component_b)): wrapped = found[component_name] entity_name = wanted[component_name] + point = faces[component_name] + if point is not None: + _select_component_face( + adapter, model, wrapped, component_name, point, index > 0 + ) + continue feature = adapter._attempt( lambda w=wrapped, e=entity_name: w.FeatureByName(e), default=None ) @@ -2794,7 +2891,10 @@ def _mate() -> dict[str, Any]: "mate_type": mate_key, "alignment": align_key, "components": [component_a, component_b], - "entities": [entity_a, entity_b], + "entities": [ + mate_entity_label(entity_a, face_a), + mate_entity_label(entity_b, face_b), + ], "distance": distance or None, "angle": angle or None, "moved_components": moved, @@ -2806,6 +2906,98 @@ def _mate() -> dict[str, Any]: adapter._handle_com_operation("add_mate", _mate), ) + async def set_component_state( + self, + component: str, + fixed: bool | None = None, + flexible: bool | None = None, + ) -> AdapterResult[dict[str, Any]]: + """Fix or float a component, and make a sub-assembly rigid or flexible. + + Fixing selects the component and calls ``IAssemblyDoc::FixComponent`` + or ``UnfixComponent``. Solving goes through + ``IAssemblyDoc::CompConfigProperties5``, which also sets suppression, + visibility, configuration, BOM and envelope for the selected + component: each is read from the component first and passed back, so + only solving changes. + + Args: + component (str): Instance name, as ``list_components`` reports it. + fixed (bool | None): True fixes it, False floats it. + flexible (bool | None): True flexible, False rigid (sub-assemblies). + + Returns: + AdapterResult[dict[str, Any]]: The state before and after. + """ + adapter = self._adapter(self) + if _doc_type(adapter) != 2: + return AdapterResult( + status=AdapterResultStatus.ERROR, + error="set_component_state requires an assembly document", + ) + if fixed is None and flexible is None: + return AdapterResult( + status=AdapterResultStatus.ERROR, + error="Nothing to change: give fixed, flexible or both.", + ) + + def _state() -> dict[str, Any]: + model = adapter.currentModel + assembly = _sw_type_info.flagged(model, "IAssemblyDoc") + target = _find_components(adapter, assembly, [component])[component] + before = _component_state(adapter, target) + sel_mgr = _sw_type_info.flagged(model.SelectionManager, "ISelectionMgr") + + def select() -> None: + adapter._attempt(lambda: model.ClearSelection2(True), default=None) + data = adapter._attempt( + lambda: sel_mgr.CreateSelectData(), default=None + ) + if not adapter._attempt( + lambda: target.Select4(False, data, False), default=False + ): + raise Exception(f"Failed to select {component}") + + if fixed is not None: + select() + call = assembly.FixComponent if fixed else assembly.UnfixComponent + adapter._attempt(call, default=None) + if flexible is not None: + select() + config = before["referenced_configuration"] or "" + applied = adapter._attempt( + lambda: assembly.CompConfigProperties5( + before["suppression"], + 1 if flexible else 0, # swComponentSolvingOption_e + bool(before["visible"]), + bool(config), + config, + bool(before["exclude_from_bom"]), + bool(before["envelope"]), + ), + default=False, + ) + if not applied: + raise Exception( + f"SolidWorks refused to make {component} " + f"{'flexible' if flexible else 'rigid'}; only a " + "sub-assembly can be flexible." + ) + adapter._attempt(lambda: model.ClearSelection2(True), default=None) + adapter._attempt(lambda: model.EditRebuild3(), default=None) + after = _component_state(adapter, target) + if fixed is not None and after["fixed"] is not fixed: + raise Exception( + f"{component} is still {'floating' if fixed else 'fixed'} " + "after the change." + ) + return {"component": component, "before": before, "after": after} + + return cast( + AdapterResult[dict[str, Any]], + adapter._handle_com_operation("set_component_state", _state), + ) + async def list_components(self) -> AdapterResult[list[str]]: """List the top-level components of the active assembly. diff --git a/src/solidworks_mcp/server.py b/src/solidworks_mcp/server.py index 6e5e7e47..94faa70e 100644 --- a/src/solidworks_mcp/server.py +++ b/src/solidworks_mcp/server.py @@ -1,6 +1,6 @@ """Main SolidWorks MCP Server implementation using FastMCP and PydanticAI. -This server provides 155 tools for comprehensive SolidWorks automation with configurable +This server provides 156 tools for comprehensive SolidWorks automation with configurable deployment (local/remote) and security options. """ diff --git a/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md b/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md index a1c37d8b..0c4bbd54 100644 --- a/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md +++ b/src/solidworks_mcp/skills/solidworks-mcp/references/errors-and-limits.md @@ -51,8 +51,8 @@ mostly batch, template, macro-recording and drawing-analysis tools, plus | `create_technical_drawing` ignores its template, sheet size, title and output path. | `generate_drawing_package` for a complete drawing. | | `delete_feature`'s message may not mention the dependents it also removed. | `list_features` afterwards. | | `save_as` replaces an existing file whatever `overwrite` says. | `save_part` or `save_assembly`, which refuse an existing file unless `overwrite` is on. | -| The `export_*` tools take the format from the file extension, not `format_type`. | Name the file with the right extension. | +| The `export_*` file tools take the format from the file extension; `format_type` is optional and `options` is ignored. | Name the file with the right extension. | | `calculate_mass_properties` labels moments of inertia kg·mm², but they are kg·m². | Read them as kg·m². | | `check_interference` ignores `include_hidden`. | Show the components you want checked. | | `execute_macro` runs a macro once, whatever `repeat_count` says, and runs only binary `.swp` files. | Call it again for a repeat. | -| `add_mate` can mate only reference planes and axes, by name. | Model parts so their planes line up with the mates you need. | +| `add_mate` can't mate edges or vertices, only planes, axes and faces. | Mate the faces that meet at the edge. | diff --git a/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md b/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md index 01833070..85e9e069 100644 --- a/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md +++ b/src/solidworks_mcp/skills/solidworks-mcp/references/workflows.md @@ -29,8 +29,20 @@ step. Values are examples; sizes are millimetres. ## Sketching somewhere other than the three planes `create_reference_plane` with `reference: "Top Plane"` and `offset: 10` makes -a plane 10 mm up; its name (e.g. "Plane1") goes into `create_sketch`. Angled -planes aren't supported yet. +a plane 10 mm up; its name (e.g. "Plane1") goes into `create_sketch`. + +- At an angle: `create_axis` first (its reply names the axis, e.g. "Axis1"), + then `reference`, `angle` (degrees) and `axis`. +- On or off a face, sloped or not: `face_point`, a point on the face in mm, + with `offset` (0 puts the plane on the face). + +## A bent plate or other wall from an open outline + +Draw the outline as an open chain of `add_line` (the plate's centreline or +one edge), `exit_sketch`, then `create_extrusion` with `thin_feature: true`, +`thin_thickness` and `thin_side` (`"one_side"`, `"other_side"` or `"mid_plane"`). +`both_directions: true` extrudes half the depth each side of the sketch +plane; the reply's `end_condition` says `MidPlane`. ## A revolved part (a knob or a shaft) @@ -68,8 +80,14 @@ To turn a feature off without deleting it, `suppress_feature`. After 2. `create_assembly`, then `insert_component` for each part, with a position in mm. 3. `list_components` for the instance names (e.g. "plate-1"). -4. `add_mate` between components, by reference plane or axis name - ("Front Plane", "Axis1"). Faces and edges can't be mated by these tools. +4. `add_mate` between components. Each side is a reference plane or axis + name (`entity_a`: "Front Plane", "Axis1") or a face (`face_a`: a point on + it, in mm, in that part's own coordinates, as you modelled it). A pin in a + hole: `concentric`, a point on the pin's round face and one on the hole's + wall. + The first part inserted is fixed. To let mates move it, or to hold a part + still, use `set_component_state` (`fixed`); to let a sub-assembly's own + mates move in the main assembly (a crank that turns), `flexible: true`. 5. `check_interference`, then `verify_model` with `component_count`, `mate_count` and `no_interference: true`. 6. `save_assembly`, or `pack_and_go_assembly` for a self-contained folder. diff --git a/src/solidworks_mcp/tools/analysis.py b/src/solidworks_mcp/tools/analysis.py index 7c56060f..94b85c42 100644 --- a/src/solidworks_mcp/tools/analysis.py +++ b/src/solidworks_mcp/tools/analysis.py @@ -393,7 +393,7 @@ async def calculate_mass_properties( @mcp.tool() async def get_mass_properties( - input_data: MassPropertiesInput | dict[str, Any] | None = None, + input_data: MassPropertiesInput | None = None, ) -> dict[str, Any]: """Measure the active part or assembly's mass properties: volume, surface area, mass, centre of mass and moments of inertia. @@ -410,13 +410,7 @@ async def get_mass_properties( Returns: dict[str, Any]: The mass properties. """ - if input_data is None: - normalized_input = MassPropertiesInput() - elif isinstance(input_data, MassPropertiesInput): - normalized_input = input_data - else: - normalized_input = MassPropertiesInput.model_validate(input_data) - return await calculate_mass_properties(normalized_input) + return await calculate_mass_properties(input_data or MassPropertiesInput()) @mcp.tool() async def check_interference(input_data: InterferenceCheckInput) -> dict[str, Any]: diff --git a/src/solidworks_mcp/tools/drawing.py b/src/solidworks_mcp/tools/drawing.py index dde03e1b..955f2f88 100644 --- a/src/solidworks_mcp/tools/drawing.py +++ b/src/solidworks_mcp/tools/drawing.py @@ -439,39 +439,8 @@ async def add_dimension(input_data: AddDimensionInput) -> dict[str, Any]: Returns: dict[str, Any]: The dimension, or a `not_supported` error. """ - if hasattr(input_data, "model_dump"): - payload = input_data.model_dump() - else: - payload = dict(input_data) - - dimension_type = payload.get("dimension_type") - entity1 = payload.get("entity1") - entity2 = payload.get("entity2") - position_x = payload.get("position_x") - position_y = payload.get("position_y") - - if (entity1 is None or entity2 is None) and payload.get("entities"): - entities = payload["entities"] - if entity1 is None and len(entities) >= 1: - entity1 = entities[0] - if entity2 is None and len(entities) >= 2: - entity2 = entities[1] - - if (position_x is None or position_y is None) and payload.get("position"): - position = payload["position"] - if len(position) >= 2: - if position_x is None: - position_x = position[0] - if position_y is None: - position_y = position[1] - - # Feed the alias-normalized values back into the payload so the - # adapter call sees entity1/entity2/position_x/position_y even - # when the caller only supplied entities/position. - payload["entity1"] = entity1 - payload["entity2"] = entity2 - payload["position_x"] = position_x - payload["position_y"] = position_y + payload = input_data.model_dump() + dimension_type = payload["dimension_type"] require_capability( adapter, diff --git a/src/solidworks_mcp/tools/export.py b/src/solidworks_mcp/tools/export.py index 56b3b5e9..722fdaed 100644 --- a/src/solidworks_mcp/tools/export.py +++ b/src/solidworks_mcp/tools/export.py @@ -35,8 +35,12 @@ class ExportFileInput(CompatInput): """ file_path: str = Field(description="Full path for the exported file") - format_type: str = Field( - description="Export format (step, iges, stl, pdf, dwg, jpg, png)" + format_type: str | None = Field( + default=None, + description=( + "Not needed: the file extension sets the format; defaults to the " + "tool's own format" + ), ) options: dict[str, Any] | None = Field( default=None, description="Format-specific export options" @@ -350,18 +354,15 @@ def model_post_init(self, __context: Any) -> None: def _file_input(input_data: Any, format_type: str) -> Any: - """Validate a plain-dict argument as ``ExportFileInput``. + """Fill ``format_type`` with the tool's own format when it was left out. - Over MCP the arguments arrive already validated; a script calling the - tool function directly passes a dict, which has no ``file_path`` - attribute. The tool's own format fills ``format_type`` when the dict - leaves it out. Models, including the per-format aliases, pass through. + The file extension decides the format; the adapter is still told which + format the tool exports. The per-format models (``ExportSTEPInput`` and + the like) pass through unchanged. """ - if isinstance(input_data, dict): - return normalize_input( - {"format_type": format_type, **input_data}, ExportFileInput - ) - return input_data + if not isinstance(input_data, ExportFileInput) or input_data.format_type: + return input_data + return input_data.model_copy(update={"format_type": format_type}) async def register_export_tools( @@ -404,7 +405,7 @@ async def export_step(input_data: ExportFileInput) -> dict[str, Any]: Args: input_data: The export. `file_path` (ending in .step or .stp) is required; - `format_type` is required by the schema but the extension decides the + `format_type` is not needed: the file extension decides the format, and `options` is not used yet. Returns: @@ -449,7 +450,7 @@ async def export_iges(input_data: ExportFileInput) -> dict[str, Any]: Args: input_data: The export. `file_path` (ending in .igs or .iges) is required; - `format_type` is required by the schema but the extension decides the + `format_type` is not needed: the file extension decides the format, and `options` is not used yet. Returns: @@ -493,7 +494,7 @@ async def export_stl(input_data: ExportFileInput) -> dict[str, Any]: Args: input_data: The export. `file_path` (ending in .stl) is required; - `format_type` is required by the schema but the extension decides the + `format_type` is not needed: the file extension decides the format, and `options` is not used yet. Returns: @@ -538,7 +539,7 @@ async def export_pdf(input_data: ExportFileInput) -> dict[str, Any]: Args: input_data: The export. `file_path` (ending in .pdf) is required; - `format_type` is required by the schema but the extension decides the + `format_type` is not needed: the file extension decides the format, and `options` is not used yet. Returns: @@ -582,7 +583,7 @@ async def export_dwg(input_data: ExportFileInput) -> dict[str, Any]: Args: input_data: The export. `file_path` (ending in .dwg) is required; - `format_type` is required by the schema but the extension decides the + `format_type` is not needed: the file extension decides the format, and `options` is not used yet. Returns: diff --git a/src/solidworks_mcp/tools/macro_recording.py b/src/solidworks_mcp/tools/macro_recording.py index 7380c95d..0cd74895 100644 --- a/src/solidworks_mcp/tools/macro_recording.py +++ b/src/solidworks_mcp/tools/macro_recording.py @@ -364,11 +364,7 @@ async def batch_execute_macros(input_data: MacroBatchInput) -> dict[str, Any]: Returns: dict[str, Any]: The results, or a `not_supported` error. """ - payload = ( - input_data.model_dump() - if hasattr(input_data, "model_dump") - else dict(input_data) - ) + payload = input_data.model_dump() if hasattr(adapter, "batch_execute_macros"): result = await adapter.batch_execute_macros(payload) @@ -402,11 +398,7 @@ async def optimize_macro(input_data: dict[str, Any]) -> dict[str, Any]: Returns: dict[str, Any]: The suggestions, or a `not_supported` error. """ - payload = ( - input_data.model_dump() - if hasattr(input_data, "model_dump") - else dict(input_data) - ) + payload = dict(input_data) if hasattr(adapter, "optimize_macro"): result = await adapter.optimize_macro(payload) @@ -440,11 +432,7 @@ async def create_macro_library(input_data: dict[str, Any]) -> dict[str, Any]: Returns: dict[str, Any]: The library, or a `not_supported` error. """ - payload = ( - input_data.model_dump() - if hasattr(input_data, "model_dump") - else dict(input_data) - ) + payload = dict(input_data) if hasattr(adapter, "create_macro_library"): result = await adapter.create_macro_library(payload) diff --git a/src/solidworks_mcp/tools/modeling.py b/src/solidworks_mcp/tools/modeling.py index e9f736ee..2b738bce 100644 --- a/src/solidworks_mcp/tools/modeling.py +++ b/src/solidworks_mcp/tools/modeling.py @@ -4,7 +4,7 @@ assemblies, drawings, and features like extrusions, revolves, etc. """ -from typing import Any +from typing import Any, Literal from fastmcp import FastMCP from pydantic import BaseModel, Field @@ -195,6 +195,7 @@ class CreateExtrusionInput(CompatInput): sketch_name (str): The sketch name value. thin_feature (bool): The thin feature value. thin_thickness (float | None): The thin thickness value. + thin_side (str): The side a thin wall grows on. """ sketch_name: str = Field(description="Sketch name to extrude") @@ -206,11 +207,28 @@ class CreateExtrusionInput(CompatInput): default=False, description="Reverse extrusion direction" ) both_directions: bool = Field( - default=False, description="Extrude in both directions" + default=False, + description=( + "Extrude symmetrically about the sketch plane (mid-plane): `depth` is " + "the total thickness, half on each side" + ), + ) + thin_feature: bool = Field( + default=False, + description=( + "Make a thin wall of `thin_thickness` from the sketch's outline; the " + "sketch may be an open chain (a bent plate's centreline)" + ), ) - thin_feature: bool = Field(default=False, description="Create as thin wall feature") thin_thickness: float | None = Field( - default=None, description="Thickness for thin wall feature in mm" + default=None, description="Wall thickness in mm; required with thin_feature" + ) + thin_side: Literal["one_side", "other_side", "mid_plane"] = Field( + default="one_side", + description=( + "Which side of the sketch line the thin wall grows on: one_side, " + "other_side, or mid_plane (half each side)" + ), ) end_condition: str = Field(default="Blind", description="End condition type") merge_result: bool = Field(default=True, description="Merge with existing geometry") @@ -233,6 +251,8 @@ def model_post_init(self, __context: Any) -> None: raise ValueError("sketch_name is required") if self.reverse is not None: self.reverse_direction = self.reverse + if self.thin_feature and not (self.thin_thickness and self.thin_thickness > 0): + raise ValueError("thin_feature needs a positive thin_thickness (mm)") class CreateRevolveInput(CompatInput): @@ -502,14 +522,32 @@ def model_post_init(self, __context: Any) -> None: class AddChamferInput(CompatInput): - """Input schema for adding an equal-distance chamfer feature. + """Input schema for adding a chamfer feature. Attributes: distance (float): Chamfer distance in millimeters. edge_names (list[str]): Edge names to chamfer. + angle (float): Chamfer angle in degrees. + flip (bool): Measure the distance along the other face. """ - distance: float = Field(description="Chamfer distance in millimeters") + distance: float = Field( + description="Chamfer distance in millimeters, along one of the edge's faces" + ) + angle: float = Field( + default=45.0, + description=( + "Chamfer angle in degrees, between 0 and 90; 45 makes both legs " + "equal to distance" + ), + ) + flip: bool = Field( + default=False, + description=( + "Measure distance along the edge's other face (swaps the long and " + "short legs of an angled chamfer)" + ), + ) edge_names: list[str] = Field( default_factory=list, description=( @@ -533,6 +571,8 @@ class AddChamferInput(CompatInput): def model_post_init(self, __context: Any) -> None: if self.distance <= 0: raise ValueError("distance must be positive") + if not 0 < self.angle < 90: + raise ValueError("angle must be between 0 and 90 degrees") class CreateAssemblyInput(CompatInput): @@ -727,15 +767,34 @@ class AddMateInput(CompatInput): description="First component instance name, as reported by list_components" ) component_b: str = Field(description="Second component instance name") - entity_a: str = Field( - default="Front Plane", + entity_a: str | None = Field( + default=None, + description=( + "Named reference plane or axis on the first component, e.g. 'Front " + "Plane'; defaults to 'Front Plane' when face_a is not given either" + ), + ) + entity_b: str | None = Field( + default=None, + description=( + "Named reference plane or axis on the second component; defaults " + "to 'Front Plane' when face_b is not given either" + ), + ) + face_a: list[float] | None = Field( + default=None, description=( - "Named feature on the first component. Only tree features " - "(reference planes and axes) can be selected" + "[x, y, z] in mm on a face of the first component, in that part's " + "own coordinates (as it was modelled), instead of entity_a: e.g. a " + "point on a pin's round face for a concentric mate" ), ) - entity_b: str = Field( - default="Front Plane", description="Named feature on the second component" + face_b: list[float] | None = Field( + default=None, + description=( + "[x, y, z] in mm on a face of the second component, in its own " + "part coordinates, instead of entity_b" + ), ) mate_type: str = Field( default="coincident", @@ -755,38 +814,107 @@ class AddMateInput(CompatInput): def model_post_init(self, __context: Any) -> None: if not self.component_a.strip() or not self.component_b.strip(): raise ValueError("component_a and component_b are required") + for side in ("a", "b"): + entity, face = ( + getattr(self, f"entity_{side}"), + getattr(self, f"face_{side}"), + ) + if entity and face is not None: + raise ValueError(f"give entity_{side} or face_{side}, not both") + if face is not None and len(face) != 3: + raise ValueError(f"face_{side} must be [x, y, z] in millimetres") + + +class SetComponentStateInput(CompatInput): + """Input schema for fixing, floating and flexing a component. + + Attributes: + component (str): Component instance name. + fixed (bool | None): True fixes it, False floats it. + flexible (bool | None): True flexible, False rigid. + """ + + component: str = Field( + description="Component instance name, as reported by list_components" + ) + fixed: bool | None = Field( + default=None, + description=( + "true fixes the component in place; false floats it so mates can " + "move it; leave it out to keep it as it is" + ), + ) + flexible: bool | None = Field( + default=None, + description=( + "For a sub-assembly: true lets its own mates move inside this " + "assembly (a crank that turns); false makes it rigid; leave it out " + "to keep it as it is" + ), + ) + + def model_post_init(self, __context: Any) -> None: + if not self.component.strip(): + raise ValueError("component is required") + if self.fixed is None and self.flexible is None: + raise ValueError("give fixed, flexible or both") class CreateReferencePlaneInput(CompatInput): """Input schema for creating a reference plane. Attributes: - reference (str): Name of the reference plane or planar face. + reference (str): Name of the reference plane. + face_point (list[float] | None): A point on a flat face to use instead. offset (float): Offset distance in millimetres. angle (float): Angle in degrees. + axis (str | None): Axis to rotate about, for an angle. flip (bool): Reverse the offset or angle direction. """ reference: str = Field( - description="Name of the reference plane or planar face to offset " - "from, e.g. 'Front Plane'" + default="", + description=( + "Name of the reference plane, e.g. 'Front Plane' or 'Plane1'; leave " + "it out when giving face_point" + ), + ) + face_point: list[float] | None = Field( + default=None, + description=( + "[x, y, z] in mm on a flat face of the part (sloped or not), to use " + "that face as the reference instead of a named plane" + ), ) offset: float = Field( default=0.0, - description="Offset distance in millimetres. Must be non-zero — a " - "plane coincident with its reference is not useful", + description=( + "Offset distance in millimetres. Non-zero for a named plane; 0 with " + "face_point puts the plane on the face" + ), ) angle: float = Field( default=0.0, - description="Angle in degrees. Not yet supported: an angled plane " - "needs a second reference this tool cannot take, so a non-zero " - "value errors. Use offset for parallel planes", + description="Angle in degrees between the new plane and the reference, about axis", + ) + axis: str | None = Field( + default=None, + description=( + "Name of the axis the plane rotates about, e.g. 'Axis1' from " + "create_axis; required with angle" + ), + ) + flip: bool = Field( + default=False, description="Reverse the offset side or the angle direction" ) - flip: bool = Field(default=False, description="Reverse the offset direction") def model_post_init(self, __context: Any) -> None: - if not self.reference.strip(): - raise ValueError("reference is required") + if bool(self.reference.strip()) == (self.face_point is not None): + raise ValueError("give exactly one of reference or face_point") + if self.face_point is not None and len(self.face_point) != 3: + raise ValueError("face_point must be [x, y, z] in millimetres") + if self.angle and not self.axis: + raise ValueError("an angled plane needs axis, the axis to rotate about") class CreateAxisInput(CompatInput): @@ -1151,10 +1279,14 @@ async def create_extrusion(input_data: CreateExtrusionInput) -> dict[str, Any]: Use it after `exit_sketch`; to remove material, use `create_cut_extrude`. It extrudes the sketch `exit_sketch` closed, whatever `sketch_name` says. End - conditions: Blind (default), MidPlane, ThroughAll, UpToNext. Needs: an open part - and a closed profile just exited. Changes: adds a Boss-Extrude feature. Returns: - `extrusion` (`name`, `depth`) and `measured_change` (volume, bounding box and - solid-body count before and after; left out when the model can't be read). + conditions: Blind (default), MidPlane, ThroughAll, UpToNext; `both_directions` + means MidPlane. With `thin_feature` and `thin_thickness` it makes a wall from the + sketch's outline, and an open chain works (a bent plate from its centreline). + Needs: an open part and a profile just exited (closed, unless thin). Changes: + adds a Boss-Extrude feature. Returns: `extrusion` (`name`, `depth`, the + `end_condition` SolidWorks used, `both_directions`, and `thin` when thin) and + `measured_change` (volume, bounding box and solid-body count before and after; + left out when the model can't be read). Args: input_data: The extrusion. `sketch_name` and `depth` (mm) are required; @@ -1174,6 +1306,7 @@ async def create_extrusion(input_data: CreateExtrusionInput) -> dict[str, Any]: both_directions=input_data.both_directions, thin_feature=input_data.thin_feature, thin_thickness=input_data.thin_thickness, + thin_side=input_data.thin_side, end_condition=input_data.end_condition, merge_result=input_data.merge_result, feature_scope=False, @@ -1186,17 +1319,25 @@ async def create_extrusion(input_data: CreateExtrusionInput) -> dict[str, Any]: require_success(result, context="Failed to create extrusion") after = await _read_summary(adapter) if before is not None else None feature = result.data + name = _result_value(feature, "feature_name", "name", default="Extrusion") + applied = _result_value(feature, "parameters", default=None) or {} + extrusion: dict[str, Any] = { + "name": name, + "sketch": input_data.sketch_name, + "depth": input_data.depth, + "end_condition": applied.get("end_condition") + or ("MidPlane" if input_data.both_directions else input_data.end_condition), + "both_directions": input_data.both_directions, + } + if input_data.thin_feature: + extrusion["thin"] = { + "thickness": input_data.thin_thickness, + "side": input_data.thin_side, + } reply = { "status": "success", - "message": f"Created extrusion: {_result_value(feature, 'feature_name', 'name', default='Extrusion')}", - "extrusion": { - "name": _result_value( - feature, "feature_name", "name", default="Extrusion" - ), - "sketch": input_data.sketch_name, - "depth": input_data.depth, - "direction": input_data.direction, - }, + "message": f"Created extrusion: {name}", + "extrusion": extrusion, "execution_time": result.execution_time, } return _attach_measured_change(reply, before, after) @@ -1459,12 +1600,14 @@ async def add_fillet(input_data: AddFilletInput) -> dict[str, Any]: @mcp.tool() async def add_chamfer(input_data: AddChamferInput) -> dict[str, Any]: - """Bevel edges of the active part with an equal-distance chamfer at 45 degrees. + """Bevel edges of the active part with a chamfer: `distance` along one face at `angle` (45 by default). Name the edges as "Edge<1>", a point "x,y,z" (metres) or "face:x,y,z" for every edge of a face; or leave `edge_names` empty to use the current selection (e.g. - from `select_face_edges`). Needs: an open part with a solid body. Changes: adds - a Chamfer feature. Returns: `chamfer` with `name`, `distance` and `edges`. + from `select_face_edges`). At 45 degrees both legs equal `distance`; at another + angle, `flip` swaps which face `distance` is measured on. Needs: an open part + with a solid body. Changes: adds a Chamfer feature. Returns: `chamfer` with + `name`, `distance`, `angle`, `flip` and `edges`. Args: input_data: The chamfer. `distance` (mm) is required; `edge_names` @@ -1474,7 +1617,12 @@ async def add_chamfer(input_data: AddChamferInput) -> dict[str, Any]: dict[str, Any]: The created chamfer. """ input_data = normalize_input(input_data, AddChamferInput) - result = await adapter.add_chamfer(input_data.distance, input_data.edge_names) + result = await adapter.add_chamfer( + input_data.distance, + input_data.edge_names, + input_data.angle, + input_data.flip, + ) require_success(result, context="Failed to add chamfer") feature = result.data return { @@ -1485,6 +1633,8 @@ async def add_chamfer(input_data: AddChamferInput) -> dict[str, Any]: feature, "feature_name", "name", default="Chamfer" ), "distance": input_data.distance, + "angle": input_data.angle, + "flip": input_data.flip, "edges": input_data.edge_names, }, "execution_time": result.execution_time, @@ -1602,16 +1752,18 @@ async def insert_component(input_data: InsertComponentInput) -> dict[str, Any]: async def add_mate(input_data: AddMateInput) -> dict[str, Any]: """Mate two components of the active assembly: coincident, concentric, parallel, perpendicular, tangent, distance or angle. - Only reference planes and axes can be mated (by name, e.g. "Front Plane"), not - faces or edges. Get instance names from `list_components`. Needs: an open - assembly with both components. Changes: adds a mate and may move components. - Returns: `mate` with `mate_type`, `components`, `entities`, `geometry_moved` and - `moved_components`. + Each side is a named reference plane or axis (`entity_a`, e.g. "Front Plane"), + or a face given by a point on it (`face_a`, mm, in that part's own coordinates): + a pin's round face and a hole's face make a concentric mate. Get instance names + from `list_components`. Needs: an open assembly with both components. Changes: + adds a mate and may move components. Returns: `mate` with `mate_type`, + `components`, `entities`, `geometry_moved` and `moved_components`. Args: input_data: The mate. `component_a` and `component_b` are required; - `entity_a`/`entity_b` ("Front Plane"), `mate_type` (coincident), - `alignment`, `distance` (mm) and `angle` (degrees) have defaults. + each side takes `entity_*` or `face_*` (default "Front Plane"); + `mate_type` (coincident), `alignment`, `distance` (mm) and `angle` + (degrees) have defaults. Returns: dict[str, Any]: The mate added. @@ -1620,12 +1772,14 @@ async def add_mate(input_data: AddMateInput) -> dict[str, Any]: result = await adapter.add_mate( input_data.component_a, input_data.component_b, - input_data.entity_a, - input_data.entity_b, + input_data.entity_a or "Front Plane", + input_data.entity_b or "Front Plane", input_data.mate_type, input_data.alignment, input_data.distance, input_data.angle, + input_data.face_a, + input_data.face_b, ) require_success(result, context="Failed to add mate") data = result.data if isinstance(result.data, dict) else {} @@ -1645,6 +1799,44 @@ async def add_mate(input_data: AddMateInput) -> dict[str, Any]: "execution_time": result.execution_time, } + @mcp.tool() + async def set_component_state(input_data: SetComponentStateInput) -> dict[str, Any]: + """Fix or float a component of the active assembly, or make a sub-assembly rigid or flexible. + + Float a part so mates can position it (the first part inserted is fixed); fix + one to hold it still. Make a sub-assembly flexible so its own mates move in + this assembly (a crank whose knob turns). Only what you pass changes: the + component's suppression, visibility and configuration are kept. Needs: an + open assembly. Changes: the component's fixed or solving state. Returns: + `state` with `component`, `before` and `after` (`fixed`, `flexible` and the + properties kept). + + Args: + input_data: The component and what to change: `fixed`, `flexible` or + both. + + Returns: + dict[str, Any]: The state before and after. + """ + input_data = normalize_input(input_data, SetComponentStateInput) + result = await adapter.set_component_state( + input_data.component, input_data.fixed, input_data.flexible + ) + require_success(result, context="Failed to set the component state") + data = result.data if isinstance(result.data, dict) else {} + after = data.get("after", {}) + words = [] + if input_data.fixed is not None: + words.append("fixed" if after.get("fixed") else "floating") + if input_data.flexible is not None: + words.append("flexible" if after.get("flexible") else "rigid") + return { + "status": "success", + "message": f"{input_data.component} is now {' and '.join(words)}", + "state": data, + "execution_time": result.execution_time, + } + @mcp.tool() async def list_components() -> dict[str, Any]: """List the top-level components of the active assembly by instance name, e.g. "bracket-1". @@ -1908,17 +2100,19 @@ async def undo(input_data: UndoInput | None = None) -> dict[str, Any]: async def create_reference_plane( input_data: CreateReferencePlaneInput, ) -> dict[str, Any]: - """Add a reference plane parallel to a plane or flat face, offset by a distance in mm. + """Add a reference plane offset from, at an angle to, or on a plane or flat face. Use it to sketch somewhere other than the three default planes: pass the new - plane's name to `create_sketch`. Angled planes are not supported (a non-zero - `angle` is an error), and a zero offset is refused. Needs: an open part. - Changes: adds a plane feature. Returns: `plane` with `name`, `reference`, - `offset` and `flip`. + plane's name to `create_sketch`. Offset: `reference` and a non-zero `offset` + (mm). Angle: `reference`, `angle` (degrees) and `axis`, an axis lying in the + reference (make one with `create_axis`). Face: `face_point`, a point on a flat + face (sloped or not), with an `offset` or 0 for a plane on the face. Needs: an + open part. Changes: adds a plane feature. Returns: `plane` with `name` and the + inputs used. Args: - input_data: The plane. `reference` (e.g. "Front Plane") and a non-zero - `offset` (mm) are needed; `flip` is off. + input_data: The plane. Exactly one of `reference` and `face_point`; + then `offset`, or `angle` with `axis`; `flip` is off. Returns: dict[str, Any]: The new plane. @@ -1929,6 +2123,8 @@ async def create_reference_plane( input_data.offset, input_data.angle, input_data.flip, + input_data.axis, + input_data.face_point, ) require_success(result, context="Failed to create reference plane") data = result.data if isinstance(result.data, dict) else {} @@ -1945,7 +2141,8 @@ async def create_axis(input_data: CreateAxisInput) -> dict[str, Any]: Use it before `pattern_circular`, which rotates features about an axis. It is built where two default planes meet. Needs: an open part. Changes: adds an axis - feature. Returns: `axis` with `reference`, `planes` and the feature counts. + feature. Returns: `axis` with `name` (e.g. "Axis1", for + `create_reference_plane`'s `axis`), `reference`, `planes` and the feature counts. Args: input_data: The axis. `reference` ("x", "y" or "z") is required. diff --git a/src/solidworks_mcp/tools/support_levels.py b/src/solidworks_mcp/tools/support_levels.py index c0a18093..1958585a 100644 --- a/src/solidworks_mcp/tools/support_levels.py +++ b/src/solidworks_mcp/tools/support_levels.py @@ -82,9 +82,7 @@ def _beta(reason: str) -> Support: return Support(SupportLevel.BETA, reason) -_EXPORT_FORMAT = _beta( - "the file extension sets the format; `format_type` and `options` are ignored." -) +_EXPORT_FORMAT = _beta("`options` is ignored; the file extension sets the format.") SUPPORT_LEVELS: dict[str, Support] = { # analysis @@ -199,7 +197,7 @@ def _beta(reason: str) -> Support: # modeling "add_chamfer": _STABLE, "add_fillet": _STABLE, - "add_mate": _beta("mates only reference planes and axes, by name, not faces."), + "add_mate": _STABLE, "close_model": _STABLE, "create_assembly": _beta("`components` is ignored; use `insert_component`."), "create_axis": _STABLE, @@ -230,6 +228,7 @@ def _beta(reason: str) -> Support: "rename_feature": _STABLE, "select_face_edges": _STABLE, "select_feature": _STABLE, + "set_component_state": _STABLE, "set_dimension": _STABLE, "set_units": _STABLE, "suppress_feature": _STABLE, diff --git a/src/solidworks_mcp/tools/tool_boundary.py b/src/solidworks_mcp/tools/tool_boundary.py index 5cee766b..ddda5ad7 100644 --- a/src/solidworks_mcp/tools/tool_boundary.py +++ b/src/solidworks_mcp/tools/tool_boundary.py @@ -19,7 +19,10 @@ from __future__ import annotations import functools +import inspect import time +import types +import typing from collections.abc import Awaitable, Callable, Mapping from typing import Any, cast @@ -178,6 +181,36 @@ def error_payload( return payload +def model_parameters(fn: Callable[..., Any]) -> dict[str, type[BaseModel]]: + """The parameters of ``fn`` annotated with a pydantic model. + + A parameter counts when its annotation is a model, or a model ``| None``. + + Args: + fn: The tool function. + + Returns: + dict[str, type[BaseModel]]: Parameter name to its model; empty when + the annotations can't be resolved (a model defined inside a function). + """ + try: + hints = typing.get_type_hints(fn) + except NameError: + return {} + models: dict[str, type[BaseModel]] = {} + for name, hint in hints.items(): + options = ( + typing.get_args(hint) + if isinstance(hint, types.UnionType) + or typing.get_origin(hint) is typing.Union + else (hint,) + ) + found = [o for o in options if isinstance(o, type) and issubclass(o, BaseModel)] + if name != "return" and len(found) == 1: + models[name] = found[0] + return models + + def tool_boundary( fn: Callable[..., Awaitable[dict[str, Any]]], classifier: ErrorClassifier = _CLASSIFIER, @@ -195,10 +228,25 @@ def tool_boundary( experimental tool's level), after any warning of the tool's own; errors carry a ``hint`` instead. + A dict passed where the tool expects its input model is validated as + that model first, so a script can call any tool function with a dict, as + an MCP client effectively does. + Returns: The wrapped tool. """ tool_name = fn.__name__ + models = model_parameters(fn) + signature = inspect.signature(fn) + + def with_models( + args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> inspect.BoundArguments: + bound = signature.bind_partial(*args, **kwargs) + for name, model in models.items(): + if isinstance(bound.arguments.get(name), dict): + bound.arguments[name] = normalize_input(bound.arguments[name], model) + return bound @functools.wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> dict[str, Any]: @@ -208,7 +256,8 @@ def elapsed() -> float: return time.perf_counter() - started try: - result = await fn(*args, **kwargs) + bound = with_models(args, kwargs) + result = await fn(*bound.args, **bound.kwargs) except InvalidInputError as exc: return error_payload( f"Invalid input: {exc}", diff --git a/src/solidworks_mcp/tools/tool_hints.py b/src/solidworks_mcp/tools/tool_hints.py index cb8fd334..5f1c379d 100644 --- a/src/solidworks_mcp/tools/tool_hints.py +++ b/src/solidworks_mcp/tools/tool_hints.py @@ -215,6 +215,7 @@ def _discards(title: str) -> ToolHints: "create_loft": _adds("Create Loft"), "insert_component": _adds("Insert Component"), "add_mate": _adds("Add Mate"), + "set_component_state": _sets("Set Component State"), "list_components": _reads("List Components"), "delete_feature": _discards("Delete Feature"), "suppress_feature": _sets("Suppress or Unsuppress Feature"), diff --git a/tests/live/evidence.py b/tests/live/evidence.py index 3ae9decd..c14c929c 100644 --- a/tests/live/evidence.py +++ b/tests/live/evidence.py @@ -184,6 +184,13 @@ def _solidworks_window_image() -> Any: from PIL import Image hwnd = solidworks_window() + if win32gui.IsIconic(hwnd): + # A minimized window has nothing to copy; without this the frames + # come out as captions over a blank strip. + raise RuntimeError( + "SolidWorks is minimized, so its window can't be captured; restore " + "it (it may stay behind other windows) and run again." + ) left, top, right, bottom = win32gui.GetWindowRect(hwnd) width, height = right - left, bottom - top window_dc = win32gui.GetWindowDC(hwnd) diff --git a/tests/live/test_live_sw_ujoint_tool_gaps.py b/tests/live/test_live_sw_ujoint_tool_gaps.py new file mode 100644 index 00000000..443a5a48 --- /dev/null +++ b/tests/live/test_live_sw_ujoint_tool_gaps.py @@ -0,0 +1,572 @@ +"""The U-joint tool gaps (#209), each checked on real SolidWorks. + +One test per changed tool, all called as an agent would (through the MCP +tool functions, with plain dicts): + +- a thin wall from an open L-shaped sketch (a bent plate), mid-plane; +- a both-directions extrusion that is symmetric and says so; +- a 60° chamfer, and ``flip`` swapping its legs; +- a plane at 40° to the Top Plane about an axis; +- a plane offset from a sloped face; +- a pin mated concentric in a hole by two face points; +- fixing, floating and flexing components; +- interference before and after the mate, through dict calls. + +Run one with:: + + powershell -ExecutionPolicy Bypass -File tests\\scripts\\live_demo.ps1 ` + -Test tests/live/test_live_sw_ujoint_tool_gaps.py::test_a_pin_mates_concentric_in_its_hole_by_two_faces + +Collected only with ``SOLIDWORKS_MCP_RUN_REAL_INTEGRATION=1`` on Windows (see +``tests/live/conftest.py``). +""" + +from __future__ import annotations + +import functools +import json +import math +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest + +from tests.live.evidence import evidence, starting + +#: The bent plate: a 80 mm wall and a 40 mm foot, 6.35 mm thick, 38.1 mm wide. +_WALL, _FOOT, _THICK, _WIDE = 80.0, 40.0, 6.35, 38.1 + +#: Adapter steps the tests take directly; each is also an MCP tool of that name. +_CAPTIONED_STEPS = frozenset( + { + "create_part", + "create_sketch", + "add_rectangle", + "add_circle", + "add_line", + "exit_sketch", + "save_file", + "create_assembly", + "insert_component", + } +) + + +def _shown(value: Any) -> str: + """One argument as a caption shows it: a file path by its name only.""" + if isinstance(value, str) and ("\\" in value or "/" in value): + return json.dumps(Path(value).name) + return json.dumps(value, ensure_ascii=False) + + +class _Captioned: + """An adapter whose test-level steps caption the recording as they start. + + A recorded demo then shows every call under the motion it causes, not + only the final result. Everything else passes straight through. + """ + + def __init__(self, raw: Any) -> None: + self.raw = raw + + def __getattr__(self, name: str) -> Any: + member = getattr(self.raw, name) + if name not in _CAPTIONED_STEPS: + return member + + @functools.wraps(member) + async def step(*args: Any) -> Any: + starting(name, f"{name}({', '.join(_shown(a) for a in args)})") + return await member(*args) + + return step + + +@pytest.fixture(autouse=True) +def _caption_steps(scratch: Any) -> Iterator[None]: + """Caption each direct adapter step; restore the plain adapter for cleanup.""" + raw = scratch.adapter + scratch.adapter = _Captioned(raw) + yield + scratch.adapter = raw + + +def _captioned_tool(name: str, fn: Any) -> Any: + """``fn``, captioning the recording with its call as it starts.""" + + @functools.wraps(fn) + async def call(arguments: dict[str, Any]) -> Any: + starting(name, f"{name}({json.dumps(arguments, ensure_ascii=False)})") + return await fn(arguments) + + return call + + +async def _tools(adapter: Any) -> dict[str, Any]: + """Register the modelling and analysis tools against ``adapter``; name -> fn. + + Each tool captions the recording as it is called. The tools drive the + plain adapter, so a tool's own adapter call isn't captioned twice. + """ + from fastmcp import FastMCP + + from solidworks_mcp.tools.analysis import register_analysis_tools + from solidworks_mcp.tools.modeling import register_modeling_tools + + raw = getattr(adapter, "raw", adapter) + mcp = FastMCP("ujoint-tool-gaps-live") + await register_modeling_tools(mcp, raw, {}) + await register_analysis_tools(mcp, raw, {}) + return {t.name: _captioned_tool(t.name, t.fn) for t in await mcp.list_tools()} + + +async def _summary(adapter: Any) -> dict[str, Any]: + result = await adapter.get_model_geometry_summary() + assert result.is_success, result.error + return result.data + + +async def _block(adapter: Any, tools: dict[str, Any]) -> None: + """A 40 x 20 x 10 mm block: x -20..20, y 0..10, z -10..10.""" + assert (await adapter.create_part()).is_success + sketch = await adapter.create_sketch("Top") + assert (await adapter.add_rectangle(-20.0, -10.0, 20.0, 10.0)).is_success + assert (await adapter.exit_sketch()).is_success + boss = await tools["create_extrusion"]({"sketch_name": sketch.data, "depth": 10.0}) + assert boss["status"] == "success", boss + + +def _sloped_faces(adapter: Any) -> list[tuple[float, float, float]]: + """Normals of the active part's flat faces that aren't square to an axis.""" + from solidworks_mcp.adapters.solidworks.face_selection import FaceEdgeSelector + + return [ + f.normal + for f in FaceEdgeSelector(adapter).faces() + if f.is_planar and sum(abs(c) > 1e-6 for c in f.normal) > 1 + ] + + +def _plane(adapter: Any, name: str) -> tuple[tuple[float, ...], tuple[float, ...]]: + """``(origin_mm, unit normal)`` of a reference plane.""" + from solidworks_mcp.adapters.solidworks.face_selection import FaceEdgeSelector + + origin, normal = FaceEdgeSelector(adapter).plane_geometry(name) + return tuple(c * 1000 for c in origin), normal + + +async def _part(scratch: Any, tools: dict[str, Any], path: Path, build: Any) -> str: + """Build a part with ``build(tools)`` and save it to ``path``.""" + assert (await scratch.adapter.create_part()).is_success + await build(tools) + saved = await scratch.adapter.save_file(str(path)) + assert saved.is_success, saved.error + scratch.written_files.append(str(path)) + return str(path) + + +async def _plate_and_pin( + scratch: Any, tools: dict[str, Any], folder: Path, tag: str +) -> tuple[str, str]: + """A 60 x 40 x 10 plate with a Ø10 hole at x = 20, and a Ø9.5 x 30 pin. + + ``tag`` names the files, so two tests never open same-named parts. + """ + adapter = scratch.adapter + + async def plate(t: dict[str, Any]) -> None: + s = await adapter.create_sketch("Top") + assert (await adapter.add_rectangle(-30.0, -20.0, 30.0, 20.0)).is_success + assert (await adapter.exit_sketch()).is_success + assert (await t["create_extrusion"]({"sketch_name": s.data, "depth": 10.0}))[ + "status" + ] == "success" + h = await adapter.create_sketch("Top") + assert (await adapter.add_circle(20.0, 0.0, 5.0)).is_success + assert (await adapter.exit_sketch()).is_success + cut = await t["create_cut_extrude"]( + {"sketch_name": h.data, "depth": 10.0, "end_condition": "ThroughAllBoth"} + ) + assert cut["status"] == "success", cut + + async def pin(t: dict[str, Any]) -> None: + s = await adapter.create_sketch("Top") + assert (await adapter.add_circle(0.0, 0.0, 4.75)).is_success + assert (await adapter.exit_sketch()).is_success + assert (await t["create_extrusion"]({"sketch_name": s.data, "depth": 30.0}))[ + "status" + ] == "success" + + return ( + await _part(scratch, tools, folder / f"{tag}_plate.SLDPRT", plate), + await _part(scratch, tools, folder / f"{tag}_pin.SLDPRT", pin), + ) + + +async def _plate_then_pin(adapter: Any) -> tuple[str, str]: + """The plate's and the pin's instance names, by name, not list order.""" + names = (await adapter.list_components()).data + plate = next(n for n in names if "_plate" in n) + pin = next(n for n in names if "_pin" in n) + return plate, pin + + +def _transforms(adapter: Any) -> dict[str, list[float]]: + """Each top-level component's ``Transform2.ArrayData`` (rotation, then m).""" + from solidworks_mcp.adapters import sw_type_info + + assembly = sw_type_info.flagged(adapter.currentModel, "IAssemblyDoc") + out = {} + for component in assembly.GetComponents(True): + c = sw_type_info.flagged(component, "IComponent2") + out[str(c.Name2)] = list(c.Transform2.ArrayData) + return out + + +def _axis_point_mm(data: list[float], y: float) -> tuple[float, float, float]: + """Where the part point (0, y, 0) mm lands in the assembly, in mm. + + Only the rotation's y column matters for a point on the y axis. + """ + return ( + data[3] * y + data[9] * 1000, + data[4] * y + data[10] * 1000, + data[5] * y + data[11] * 1000, + ) + + +async def _seat_pin( + tools: dict[str, Any], plate: str, pin: str +) -> list[dict[str, Any]]: + """Two face mates: the pin concentric in the hole, its bottom on the plate's.""" + concentric = { + "component_a": plate, + "component_b": pin, + "face_a": [25.0, 5.0, 0.0], # the hole's wall, 5 mm from its axis at x = 20 + "face_b": [4.75, 15.0, 0.0], # the pin's round face + "mate_type": "concentric", + } + flush = { + "component_a": plate, + "component_b": pin, + "face_a": [-20.0, 0.0, 0.0], # the plate's bottom, clear of the hole + "face_b": [0.0, 0.0, 0.0], # the pin's bottom + "mate_type": "coincident", + "alignment": "aligned", + } + return [await tools["add_mate"](concentric), await tools["add_mate"](flush)] + + +@pytest.mark.asyncio +async def test_a_thin_wall_from_an_open_sketch_makes_a_bent_plate(scratch) -> None: + """An open L, 6.35 mm thin wall, mid-plane both ways: one body, exact volume.""" + adapter = scratch.adapter + tools = await _tools(adapter) + assert (await adapter.create_part()).is_success + sketch = await adapter.create_sketch("Right") + assert (await adapter.add_line(0.0, 0.0, 0.0, _WALL)).is_success + assert (await adapter.add_line(0.0, 0.0, _FOOT, 0.0)).is_success + assert (await adapter.exit_sketch()).is_success + + call = { + "sketch_name": sketch.data, + "depth": _WIDE, + "both_directions": True, + "thin_feature": True, + "thin_thickness": _THICK, + "thin_side": "mid_plane", + } + result = await tools["create_extrusion"](call) + summary = await _summary(adapter) + expected = (_WALL + _FOOT) * _THICK * _WIDE # mid-plane: corners cancel + evidence( + f"open L, thin 6.35 mm mid-plane, 38.1 mm both ways (expect {expected:.0f} mm³)", + { + "extrusion": result.get("extrusion"), + "volume_mm3": summary["volume_mm3"], + "solid_bodies": summary.get("solid_body_count"), + "x_mm": [ + summary["bounding_box_mm"]["min"][0], + summary["bounding_box_mm"]["max"][0], + ], + }, + call=f"create_extrusion({call})", + ) + assert result["status"] == "success", result + assert result["extrusion"]["end_condition"] == "MidPlane" + assert result["extrusion"]["thin"] == {"thickness": _THICK, "side": "mid_plane"} + assert summary["solid_body_count"] == 1 + assert summary["volume_mm3"] == pytest.approx(expected, rel=0.01) + assert summary["bounding_box_mm"]["min"][0] == pytest.approx(-_WIDE / 2, abs=0.01) + assert summary["bounding_box_mm"]["max"][0] == pytest.approx(_WIDE / 2, abs=0.01) + + +@pytest.mark.asyncio +async def test_both_directions_is_symmetric_and_says_mid_plane(scratch) -> None: + """A 20 mm both-ways extrusion spans -10..10 and reports MidPlane.""" + adapter = scratch.adapter + tools = await _tools(adapter) + assert (await adapter.create_part()).is_success + sketch = await adapter.create_sketch("Front") + assert (await adapter.add_rectangle(-15.0, -10.0, 15.0, 10.0)).is_success + assert (await adapter.exit_sketch()).is_success + + call = {"sketch_name": sketch.data, "depth": 20.0, "both_directions": True} + result = await tools["create_extrusion"](call) + box = (await _summary(adapter))["bounding_box_mm"] + evidence( + "both_directions: 20 mm about the Front Plane (expect z -10..10)", + {"extrusion": result.get("extrusion"), "z_mm": [box["min"][2], box["max"][2]]}, + call=f"create_extrusion({call})", + ) + assert result["extrusion"]["end_condition"] == "MidPlane" + assert box["min"][2] == pytest.approx(-10.0, abs=0.01) + assert box["max"][2] == pytest.approx(10.0, abs=0.01) + + +@pytest.mark.asyncio +async def test_a_60_degree_chamfer_and_flip_swaps_its_legs(scratch) -> None: + """2 mm at 60°: the right volume, and flip turns a 60° face into a 30° one.""" + adapter = scratch.adapter + tools = await _tools(adapter) + await _block(adapter, tools) + before = (await _summary(adapter))["volume_mm3"] + removed = 0.5 * 2.0 * 2.0 * math.tan(math.radians(60)) * 40.0 + + first = {"distance": 2.0, "angle": 60, "edge_names": ["0,0.01,0.01"]} + chamfer = await tools["add_chamfer"](first) + after_first = (await _summary(adapter))["volume_mm3"] + slopes = _sloped_faces(adapter) + tilt_1 = math.degrees(math.acos(abs(slopes[0][1]))) + evidence( + f"chamfer 2 mm at 60° along a 40 mm edge (expect -{removed:.1f} mm³)", + { + "chamfer": chamfer.get("chamfer"), + "volume_change_mm3": round(after_first - before, 2), + "face_tilt_from_top_deg": round(tilt_1, 2), + }, + call=f"add_chamfer({first})", + ) + assert chamfer["status"] == "success", chamfer + assert before - after_first == pytest.approx(removed, rel=0.01) + + second = { + "distance": 2.0, + "angle": 60, + "flip": True, + "edge_names": ["0,0.01,-0.01"], + } + flipped = await tools["add_chamfer"](second) + after_second = (await _summary(adapter))["volume_mm3"] + tilts = sorted( + round(math.degrees(math.acos(abs(n[1]))), 2) for n in _sloped_faces(adapter) + ) + evidence( + "the same chamfer with flip on the opposite edge (expect the other leg)", + { + "chamfer": flipped.get("chamfer"), + "volume_change_mm3": round(after_second - after_first, 2), + "face_tilts_from_top_deg": tilts, + }, + call=f"add_chamfer({second})", + ) + assert after_first - after_second == pytest.approx(removed, rel=0.01) + assert tilts == pytest.approx([30.0, 60.0], abs=0.1) + + +@pytest.mark.asyncio +async def test_a_plane_at_40_degrees_about_an_axis(scratch) -> None: + """create_axis names the axis; the plane is 40° from the Top Plane about it.""" + adapter = scratch.adapter + tools = await _tools(adapter) + await _block(adapter, tools) + + axis = await tools["create_axis"]({"reference": "x"}) + call = {"reference": "Top Plane", "angle": 40, "axis": axis["axis"]["name"]} + plane = await tools["create_reference_plane"](call) + _origin, normal = _plane(adapter, plane["plane"]["name"]) + tilt = math.degrees(math.acos(abs(normal[1]))) + evidence( + "a plane at 40° to the Top Plane about the x axis", + { + "axis": axis["axis"]["name"], + "plane": plane.get("plane"), + "tilt_deg": round(tilt, 3), + }, + call=f"create_reference_plane({call})", + ) + assert axis["axis"]["name"], axis + assert plane["status"] == "success", plane + assert tilt == pytest.approx(40.0, abs=0.01) + assert abs(normal[0]) < 1e-6 # rotated about x + + +@pytest.mark.asyncio +async def test_a_plane_offset_from_a_sloped_face(scratch) -> None: + """A 3-4-5 wedge: a plane 5 mm off its slope, parallel to it.""" + adapter = scratch.adapter + tools = await _tools(adapter) + assert (await adapter.create_part()).is_success + sketch = await adapter.create_sketch("Front") + for line in ((0, 0, 40, 0), (40, 0, 0, 30), (0, 30, 0, 0)): + assert (await adapter.add_line(*map(float, line))).is_success + assert (await adapter.exit_sketch()).is_success + assert ( + await tools["create_extrusion"]({"sketch_name": sketch.data, "depth": 10.0}) + )["status"] == "success" + + call = {"face_point": [20.0, 15.0, 5.0], "offset": 5.0} + plane = await tools["create_reference_plane"](call) + origin, normal = _plane(adapter, plane["plane"]["name"]) + # The slope is 0.6 x + 0.8 y = 24 mm; the new plane is 5 mm from it. + unit = (0.6, 0.8, 0.0) + gap = abs(abs(sum(a * b for a, b in zip(origin, unit, strict=True))) - 24.0) + evidence( + "a plane 5 mm off the wedge's 3-4-5 slope", + { + "plane": plane.get("plane"), + "normal": [round(c, 4) for c in normal], + "gap_from_slope_mm": round(gap, 4), + }, + call=f"create_reference_plane({call})", + ) + assert plane["status"] == "success", plane + assert [abs(c) for c in normal] == pytest.approx([0.6, 0.8, 0.0], abs=1e-4) + assert gap == pytest.approx(5.0, abs=0.01) + + +@pytest.mark.asyncio +async def test_a_pin_mates_concentric_in_its_hole_by_two_faces( + scratch, tmp_path +) -> None: + """Two face mates by points in each part's own coordinates seat the pin in its hole.""" + adapter = scratch.adapter + tools = await _tools(adapter) + plate, pin = await _plate_and_pin(scratch, tools, tmp_path, "mate") + assert (await adapter.create_assembly()).is_success + assert (await adapter.insert_component(plate, 0.0, 0.0, 0.0)).is_success + assert (await adapter.insert_component(pin, 100.0, 0.0, 0.0)).is_success + scratch.written_files.append(str(tmp_path / "gap_assembly.SLDASM")) + plate_name, pin_name = await _plate_then_pin(adapter) + + concentric, flush = await _seat_pin(tools, plate_name, pin_name) + placed = _transforms(adapter) + hole = _axis_point_mm(placed[plate_name], 0.0) # the plate's bottom, on its origin + ends = sorted( + _axis_point_mm(placed[pin_name], y) for y in (0.0, 30.0) + ) # the pin's axis, bottom to top + axis = [round(ends[0][0] - hole[0], 3), round(ends[0][2] - hole[2], 3)] + span = [round(ends[0][1] - hole[1], 3), round(ends[1][1] - hole[1], 3)] + evidence( + "pin seated in the plate's hole by two face mates (expect axis 20, 0; y 0..30)", + { + "mates": [m.get("mate", m).get("entities") for m in (concentric, flush)], + "pin_axis_from_plate_mm": axis, + "pin_height_above_plate_bottom_mm": span, + }, + call="add_mate(concentric, faces), add_mate(coincident, bottoms)", + ) + assert concentric["status"] == "success", concentric + assert flush["status"] == "success", flush + assert axis == pytest.approx([20.0, 0.0], abs=0.01) + assert span == pytest.approx([0.0, 30.0], abs=0.01) + + +@pytest.mark.asyncio +async def test_components_fix_float_and_a_sub_assembly_turns_flexible( + scratch, tmp_path +) -> None: + """Float and fix a part; flex a sub-assembly, keeping its other settings.""" + adapter = scratch.adapter + tools = await _tools(adapter) + plate, pin = await _plate_and_pin(scratch, tools, tmp_path, "state") + assert (await adapter.create_assembly()).is_success + assert (await adapter.insert_component(plate, 0.0, 0.0, 0.0)).is_success + assert (await adapter.insert_component(pin, 100.0, 0.0, 0.0)).is_success + sub = tmp_path / "gap_pin_plate.SLDASM" + assert (await adapter.save_file(str(sub))).is_success + scratch.written_files.append(str(sub)) + plate_name, _pin_name = await _plate_then_pin(adapter) + + floated = await tools["set_component_state"]( + {"component": plate_name, "fixed": False} + ) + fixed = await tools["set_component_state"]({"component": plate_name, "fixed": True}) + evidence( + "the first part in, which SolidWorks fixes: floated, then fixed again", + {"floated": floated.get("message"), "fixed": fixed.get("message")}, + call=f'set_component_state({{"component": "{plate_name}", "fixed": false}})', + ) + assert floated["state"]["before"]["fixed"] is True + assert floated["state"]["after"]["fixed"] is False + assert fixed["state"]["after"]["fixed"] is True + + assert (await adapter.create_assembly()).is_success + assert (await adapter.insert_component(str(sub), 0.0, 0.0, 0.0)).is_success + scratch.written_files.append(str(tmp_path / "gap_main.SLDASM")) + sub_name = (await adapter.list_components()).data[0] + flexed = await tools["set_component_state"]( + {"component": sub_name, "flexible": True} + ) + kept = ("suppression", "visible", "referenced_configuration", "exclude_from_bom") + evidence( + "the pin-and-plate sub-assembly made flexible; everything else kept", + { + "message": flexed.get("message"), + "before": { + k: flexed["state"]["before"].get(k) for k in ("flexible", *kept) + }, + "after": {k: flexed["state"]["after"].get(k) for k in ("flexible", *kept)}, + }, + call=f'set_component_state({{"component": "{sub_name}", "flexible": true}})', + ) + assert flexed["status"] == "success", flexed + assert flexed["state"]["after"]["flexible"] is True + for key in kept: + assert flexed["state"]["after"][key] == flexed["state"]["before"][key], key + rigid = await tools["set_component_state"]( + {"component": sub_name, "flexible": False} + ) + evidence( + "the sub-assembly back to rigid", + { + "message": rigid.get("message"), + "flexible": [ + rigid["state"]["before"].get("flexible"), + rigid["state"]["after"].get("flexible"), + ], + }, + call=f'set_component_state({{"component": "{sub_name}", "flexible": false}})', + ) + assert rigid["state"]["after"]["flexible"] is False + + +@pytest.mark.asyncio +async def test_interference_is_fresh_after_a_mate(scratch, tmp_path) -> None: + """Called with dicts: the pin overlaps the plate, then sits in the Ø10 hole clear.""" + adapter = scratch.adapter + tools = await _tools(adapter) + plate, pin = await _plate_and_pin(scratch, tools, tmp_path, "clash") + assert (await adapter.create_assembly()).is_success + assert (await adapter.insert_component(plate, 0.0, 0.0, 0.0)).is_success + assert (await adapter.insert_component(pin, 0.0, 0.0, 0.0)).is_success + scratch.written_files.append(str(tmp_path / "gap_clash.SLDASM")) + plate_name, pin_name = await _plate_then_pin(adapter) + check = {"components": [plate_name, pin_name]} + + clash = await tools["check_interference"](check) + seated = await _seat_pin(tools, plate_name, pin_name) + assert all(m["status"] == "success" for m in seated), seated + clear = await tools["check_interference"](check) + before = clash["data"]["interference_count"] + after = clear["data"]["interference_count"] + evidence( + "interference: the pin through solid plate, then seated in the Ø10 hole", + {"before_mate": before, "after_mate": after}, + call=f"check_interference({check})", + ) + assert clash["status"] == "success", clash + assert clear["status"] == "success", clear + assert before >= 1 + assert after == 0 diff --git a/tests/scripts/make_demo_gif.py b/tests/scripts/make_demo_gif.py index 72beecd2..ed520541 100644 --- a/tests/scripts/make_demo_gif.py +++ b/tests/scripts/make_demo_gif.py @@ -116,12 +116,14 @@ def build_recording( max_hold: float, min_caption: float = 0.6, prompt_seconds: float = 4.0, + end_seconds: float = 5.0, ) -> Path | None: """One recording as a GIF; ``None`` when it holds no frames. A frame where a new caption appears shows for at least ``min_caption`` seconds, so a tool call can be read however fast the rest plays; the - first caption, the prompt, for at least ``prompt_seconds``. + first caption, the prompt, for at least ``prompt_seconds``; and the last + frame, the result, for at least ``end_seconds`` before the GIF loops. """ frames, captions, end = _timeline(folder) if not frames: @@ -164,6 +166,8 @@ def scaled(path: Path) -> Image.Image: # The first caption is the prompt: give it time to be read. first = caption_path == captions[0][1] seconds = max(seconds, prompt_seconds if first else min_caption) + if i == len(events) - 1: + seconds = max(seconds, end_seconds) images.append(frame.quantize(palette=palette, dither=Image.Dither.NONE)) durations.append(max(20, int(seconds * 1000))) images[0].save( @@ -203,6 +207,7 @@ def build_recordings( max_hold: float, min_caption: float = 0.6, prompt_seconds: float = 4.0, + end_seconds: float = 5.0, ) -> list[Path]: """``-motion.gif`` for every recording under ``directory/recording``.""" root = directory / "recording" @@ -218,6 +223,7 @@ def build_recordings( max_hold, min_caption, prompt_seconds, + end_seconds, ) if gif is not None: written.append(gif) @@ -251,6 +257,12 @@ def main() -> None: default=4.0, help="how long the first frame, the prompt, shows (s)", ) + parser.add_argument( + "--end-seconds", + type=float, + default=5.0, + help="how long the last motion frame, the result, shows (s)", + ) args = parser.parse_args() paths = build_gifs(args.directory, args.width, args.seconds, args.prompt_seconds) if args.recording: @@ -261,6 +273,7 @@ def main() -> None: args.max_hold, args.min_caption, args.prompt_seconds, + args.end_seconds, ) for path in paths: print(f"{path} ({path.stat().st_size // 1024} KB)") diff --git a/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py b/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py index c31c4cec..011c9a3c 100644 --- a/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py +++ b/tests/solidworks_mcp/adapters/solidworks/test_face_selection.py @@ -450,3 +450,31 @@ async def test_side_without_a_planar_face_is_an_error() -> None: assert result.is_error assert "No planar face faces 'top'" in (result.error or "") + + +# ---- component faces and mate labels (#209) ---------------------------------- + + +def test_component_faces_reads_the_components_own_bodies() -> None: + faces, _ = _block() + body = SimpleNamespace(GetFaces=lambda: faces) + asked: list[int] = [] + component = SimpleNamespace(GetBodies2=lambda t: asked.append(t) or [body]) + selector = fs.FaceEdgeSelector(_Harness(_model([]))) + + found = selector.component_faces(component) + + assert asked == [0] # swSolidBody + assert len(found) == len(faces) + assert selector.face_at_point(found, [0.0, 10.0, 0.0]).handle in faces + + +@pytest.mark.parametrize( + ("entity", "face", "label"), + [ + ("Front Plane", None, "Front Plane"), + ("Front Plane", [0, 5, 1.5], "face at [0.0, 5.0, 1.5] mm"), + ], +) +def test_mate_entity_label(entity: str, face: list[float] | None, label: str) -> None: + assert fs.mate_entity_label(entity, face) == label diff --git a/tests/solidworks_mcp/adapters/solidworks/test_features.py b/tests/solidworks_mcp/adapters/solidworks/test_features.py index b7d4db19..eb8e8647 100644 --- a/tests/solidworks_mcp/adapters/solidworks/test_features.py +++ b/tests/solidworks_mcp/adapters/solidworks/test_features.py @@ -503,6 +503,32 @@ def test_add_chamfer_empty_edge_names_does_not_touch_selection() -> None: select_by_id2.assert_not_called() +@pytest.mark.parametrize( + ("angle", "flip", "options"), [(45.0, False, 1), (60.0, False, 1), (60.0, True, 0)] +) +def test_add_chamfer_passes_angle_and_flip( + angle: float, flip: bool, options: int +) -> None: + import math + + adapter = _FakeFeatureAdapter() + insert = Mock(return_value=SimpleNamespace(Name="Chamfer1")) + adapter.currentModel = SimpleNamespace( + ClearSelection2=Mock(return_value=True), + Extension=SimpleNamespace(SelectByID2=Mock(return_value=True)), + FeatureManager=SimpleNamespace(InsertFeatureChamfer=insert), + ) + + result = features._add_chamfer_impl(adapter, 2.0, [], angle, flip) + + assert result.is_success + args = insert.call_args.args + assert args[:3] == (options, 1, 0.002) # Options, swChamferAngleDistance, m + assert args[3] == pytest.approx(math.radians(angle)) + assert result.data.parameters["angle"] == angle + assert result.data.parameters["flip"] is flip + + def test_add_fillet_sw33_also_uses_new_path() -> None: """major=33 (SW 2025) hits the >= 33 branch; name defaults to 'Fillet' because IModelDoc2.FeatureFillet3 returns int, not IFeature.""" @@ -1579,7 +1605,7 @@ def test_errors_when_reference_blank(self, monkeypatch) -> None: result = _create_reference_plane_impl(adapter, "", 10.0, 0.0, False) assert result.is_error - assert "requires a reference plane/face name" in (result.error or "") + assert "requires a reference plane name or a face_point" in (result.error or "") def test_flip_true_combines_the_option_flag_on_success(self, monkeypatch) -> None: adapter = _build_adapter(monkeypatch) @@ -2472,6 +2498,27 @@ def test_fillet_and_chamfer_pick_coordinate_specs_by_nearest_entity( assert "Failed to select edge: face:0.01,0.02,0.0" in (result.error or "") +def _patch_face( + monkeypatch: pytest.MonkeyPatch, normal: tuple[float, float, float], handle: Any +) -> None: + """Make FaceEdgeSelector find one face with ``normal`` at any point.""" + from solidworks_mcp.adapters.solidworks import face_selection + + face = face_selection.FaceInfo(0, normal, (0.0,) * 6, 1.0, handle) + + class _Selector: + def __init__(self, adapter: Any) -> None: + pass + + def faces(self) -> list[Any]: + return [face] + + def face_at_point(self, faces: list[Any], point: list[float]) -> Any: + return faces[0] + + monkeypatch.setattr(face_selection, "FaceEdgeSelector", _Selector) + + def _selectable_feature(**attrs: Any) -> SimpleNamespace: return SimpleNamespace(Select2=lambda _append, _mark: True, **attrs) @@ -2605,11 +2652,113 @@ def test_undo_reports_whether_the_tree_changed( class TestReferenceGeometryOutcomes: """Reference plane, axis and point: refusals and successes.""" - def test_angled_plane_is_refused(self, monkeypatch) -> None: + def test_an_angle_without_an_axis_is_refused(self, monkeypatch) -> None: adapter = _build_adapter(monkeypatch) adapter.currentModel = MagicMock() result = _create_reference_plane_impl(adapter, "Top Plane", 0.0, 30.0, False) - assert "does not support 'angle' yet" in (result.error or "") + assert "needs an axis to rotate about" in (result.error or "") + + @pytest.mark.parametrize(("flip", "first"), [(False, 16), (True, 16 | 256)]) + def test_an_angled_plane_selects_the_axis_under_mark_1( + self, monkeypatch, flip: bool, first: int + ) -> None: + adapter = _build_adapter(monkeypatch) + adapter.currentModel = MagicMock() + marks: list[tuple[str, bool, int]] = [] + + def feature(name: str) -> SimpleNamespace: + return SimpleNamespace( + Select2=lambda append, mark: marks.append((name, append, mark)) or True + ) + + adapter.currentModel.FeatureByName.side_effect = feature + manager = MagicMock() + manager.GetFeatureCount.side_effect = [5, 6] + manager.InsertRefPlane.return_value = SimpleNamespace(Name="Plane1") + adapter.currentModel.FeatureManager = manager + + result = _create_reference_plane_impl( + adapter, "Top Plane", 0.0, 40.0, flip, axis="Axis1" + ) + + assert result.is_success, result.error + assert marks == [("Top Plane", False, 0), ("Axis1", True, 1)] + args = manager.InsertRefPlane.call_args.args + assert args[0] == first + assert args[1] == pytest.approx(math.radians(40)) + assert args[2] == 4 # Coincident with the axis + assert result.data["axis"] == "Axis1" + assert result.data["angle"] == 40.0 + + def test_an_axis_that_cannot_be_selected_is_named(self, monkeypatch) -> None: + adapter = _build_adapter(monkeypatch) + adapter.currentModel = MagicMock() + adapter.currentModel.FeatureByName.side_effect = lambda name: ( + _selectable_feature() if name == "Top Plane" else None + ) + + result = _create_reference_plane_impl( + adapter, "Top Plane", 0.0, 40.0, False, axis="Axis9" + ) + + assert result.is_error + assert "Failed to select axis 'Axis9'" in (result.error or "") + + @pytest.mark.parametrize(("offset", "first"), [(5.0, 8), (0.0, 4)]) + def test_a_face_point_selects_that_face_under_mark_0( + self, monkeypatch, offset: float, first: int + ) -> None: + adapter = _build_adapter(monkeypatch) + adapter.currentModel = MagicMock() + data = SimpleNamespace(Mark=None) + adapter.currentModel.SelectionManager.CreateSelectData.return_value = data + selected: list[Any] = [] + handle = SimpleNamespace( + Select4=lambda append, d: selected.append((append, d.Mark)) or True + ) + _patch_face(monkeypatch, normal=(0.0, 0.6, 0.8), handle=handle) + manager = MagicMock() + manager.GetFeatureCount.side_effect = [5, 6] + manager.InsertRefPlane.return_value = SimpleNamespace(Name="Plane1") + adapter.currentModel.FeatureManager = manager + + result = _create_reference_plane_impl( + adapter, "", offset, 0.0, False, face_point=[1.0, 2.0, 3.0] + ) + + assert result.is_success, result.error + assert selected == [(False, 0)] + assert manager.InsertRefPlane.call_args.args[0] == first + assert result.data["face_point"] == [1.0, 2.0, 3.0] + assert result.data["reference"] is None + + def test_a_curved_face_is_refused(self, monkeypatch) -> None: + adapter = _build_adapter(monkeypatch) + adapter.currentModel = MagicMock() + _patch_face(monkeypatch, normal=(0.0, 0.0, 0.0), handle=None) + + result = _create_reference_plane_impl( + adapter, "", 5.0, 0.0, False, face_point=[1.0, 2.0, 3.0] + ) + + assert result.is_error + assert "is not flat" in (result.error or "") + + def test_a_face_that_will_not_select_is_reported(self, monkeypatch) -> None: + adapter = _build_adapter(monkeypatch) + adapter.currentModel = MagicMock() + _patch_face( + monkeypatch, + normal=(0.0, 1.0, 0.0), + handle=SimpleNamespace(Select4=lambda append, d: False), + ) + + result = _create_reference_plane_impl( + adapter, "", 5.0, 0.0, False, face_point=[1.0, 2.0, 3.0] + ) + + assert result.is_error + assert "Failed to select the face at [1.0, 2.0, 3.0] mm" in (result.error or "") def test_plane_without_offset_is_refused(self, monkeypatch) -> None: adapter = _build_adapter(monkeypatch) @@ -2629,14 +2778,30 @@ def test_axis_reports_its_planes(self, monkeypatch) -> None: adapter.currentModel = MagicMock() adapter.currentModel.FeatureByName.return_value = _selectable_feature() adapter.currentModel.FeatureManager.GetFeatureCount.side_effect = [5, 6] + adapter.currentModel.FeatureByPositionReverse.return_value = SimpleNamespace( + Name="Axis1" + ) result = _create_axis_impl(adapter, "-Z") + assert result.data["name"] == "Axis1" assert result.data["reference"] == "z" assert len(result.data["planes"]) == 2 assert result.data["features_before"] == 5 assert result.data["features_after"] == 6 + def test_an_axis_whose_name_cannot_be_read_says_none(self, monkeypatch) -> None: + adapter = _build_adapter(monkeypatch) + adapter.currentModel = MagicMock() + adapter.currentModel.FeatureByName.return_value = _selectable_feature() + adapter.currentModel.FeatureManager.GetFeatureCount.side_effect = [5, 6] + adapter.currentModel.FeatureByPositionReverse.return_value = None + + result = _create_axis_impl(adapter, "x") + + assert result.is_success + assert result.data["name"] is None + def test_unknown_point_mode_is_refused(self) -> None: result = features._create_reference_point_impl( _point_adapter(), "vertex", 0, 0, 0, None, None @@ -2873,6 +3038,43 @@ def test_thin_extrusion_uses_the_same_end_condition() -> None: assert calls[0][3] == 6 +@pytest.mark.parametrize( + ("side", "rev_thin_dir"), [("one_side", 0), ("other_side", 1), ("mid_plane", 2)] +) +def test_thin_side_sets_the_thin_wall_type(side: str, rev_thin_dir: int) -> None: + calls: list[tuple] = [] + adapter = _extrusion_adapter( + FeatureExtrusionThin2=lambda *a: ( + calls.append(a) or SimpleNamespace(Name="Thin1") + ) + ) + + result = features._create_extrusion_impl( + adapter, + ExtrusionParameters( + depth=38.1, thin_feature=True, thin_thickness=6.35, thin_side=side + ), + ) + + assert result.is_success, result.error + assert calls[0][21] == rev_thin_dir # RevThinDir + assert result.data.parameters["thin_side"] == side + + +def test_an_unknown_thin_side_fails_clearly() -> None: + adapter = _extrusion_adapter(FeatureExtrusionThin2=Mock()) + + result = features._create_extrusion_impl( + adapter, + ExtrusionParameters( + depth=5.0, thin_feature=True, thin_thickness=1.0, thin_side="inside" + ), + ) + + assert result.is_error + assert "Unsupported thin_side 'inside'" in (result.error or "") + + def test_extrusion_with_an_unsupported_end_condition_fails_clearly() -> None: adapter = _extrusion_adapter(FeatureExtrusion3=Mock()) diff --git a/tests/solidworks_mcp/adapters/solidworks/test_io.py b/tests/solidworks_mcp/adapters/solidworks/test_io.py index ca56560a..c0ec2cb9 100644 --- a/tests/solidworks_mcp/adapters/solidworks/test_io.py +++ b/tests/solidworks_mcp/adapters/solidworks/test_io.py @@ -36,6 +36,7 @@ def _handle_com_operation(self, _name, callback, *args): import struct import sys from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call import pytest @@ -3934,6 +3935,194 @@ async def test_add_mate_reports_which_components_moved(self, monkeypatch) -> Non assert result.data["moved_components"] == ["part-2"] assert result.data["geometry_moved"] is True + @staticmethod + def _face_mate(monkeypatch, handle: Any, found: bool = True) -> PyWin32Adapter: + """A two-component assembly whose 'pin-1' has one face, at ``handle``.""" + from solidworks_mcp.adapters.solidworks import face_selection + + adapter = TestAssemblyAndDrawingSuccessPaths._document(monkeypatch, 2) + monkeypatch.setattr( + _io_module, "_dynamic", SimpleNamespace(Dispatch=lambda obj: obj) + ) + feature = SimpleNamespace(Select2=lambda append, mark: True) + adapter.currentModel.GetComponents.return_value = [ + SimpleNamespace(Name2="bracket-1", FeatureByName=lambda e: feature), + SimpleNamespace(Name2="pin-1", FeatureByName=lambda e: feature), + ] + adapter.currentModel.SelectionManager.GetSelectedObjectCount2.return_value = 2 + adapter.currentModel.AddMate5.return_value = SimpleNamespace() + monkeypatch.setattr(_io_module, "_byref_int", lambda: SimpleNamespace(value=1)) + monkeypatch.setattr(_io_module, "_component_transforms", lambda _a, _asm: {}) + face = face_selection.FaceInfo(0, (0.0, 0.0, 0.0), (0.0,) * 6, 1.0, handle) + + class _Selector: + def __init__(self, _adapter: Any) -> None: + pass + + def component_faces(self, component: Any) -> list[Any]: + assert component.Name2 == "pin-1" + return [face] + + def face_at_point(self, faces: list[Any], point: list[float]) -> Any: + if not found: + raise LookupError(f"No face found at point {point} mm.") + return faces[0] + + monkeypatch.setattr(face_selection, "FaceEdgeSelector", _Selector) + return adapter + + @pytest.mark.asyncio + async def test_add_mate_selects_a_face_of_that_component(self, monkeypatch) -> None: + picks: list[bool] = [] + handle = SimpleNamespace( + Select4=lambda append, data: picks.append(append) or True + ) + adapter = self._face_mate(monkeypatch, handle) + + result = await adapter.add_mate( + "bracket-1", "pin-1", "Top Plane", mate_type="concentric", face_b=[0, 5, 0] + ) + + assert result.is_success, result.error + assert picks == [True] # the second side appends to the first + assert result.data["entities"] == ["Top Plane", "face at [0.0, 5.0, 0.0] mm"] + assert result.data["geometry_moved"] is None + + @pytest.mark.asyncio + async def test_add_mate_names_a_missing_face(self, monkeypatch) -> None: + adapter = self._face_mate(monkeypatch, None, found=False) + + result = await adapter.add_mate("bracket-1", "pin-1", face_b=[0, 99, 0]) + + assert result.is_error + assert "Face not found on pin-1 at [0, 99, 0] mm" in (result.error or "") + + @pytest.mark.asyncio + async def test_add_mate_reports_a_face_that_will_not_select( + self, monkeypatch + ) -> None: + handle = SimpleNamespace(Select4=lambda append, data: False) + adapter = self._face_mate(monkeypatch, handle) + + result = await adapter.add_mate("bracket-1", "pin-1", face_b=[0, 5, 0]) + + assert result.is_error + assert "Failed to select the face of pin-1" in (result.error or "") + + @staticmethod + def _state_adapter( + monkeypatch, *, fixed: bool = True, applied: bool = True, selects: bool = True + ) -> tuple[PyWin32Adapter, SimpleNamespace, list[Any]]: + """An assembly holding 'crank-1', whose state the fakes track.""" + adapter = TestAssemblyAndDrawingSuccessPaths._document(monkeypatch, 2) + monkeypatch.setattr( + _io_module, "_dynamic", SimpleNamespace(Dispatch=lambda obj: obj) + ) + calls: list[Any] = [] + comp = SimpleNamespace( + Name2="crank-1", + Visible=1, + Solving=0, + ReferencedConfiguration="Default", + ExcludeFromBOM=False, + IsEnvelope=lambda: False, + GetSuppression2=lambda: 3, + Select4=lambda append, data, popup: selects, + ) + state = {"fixed": fixed} + comp.IsFixed = lambda: state["fixed"] + model = adapter.currentModel + model.GetComponents.return_value = [comp] + + def fix() -> None: + calls.append("fix") + state["fixed"] = True + + def unfix() -> None: + calls.append("unfix") + state["fixed"] = False + + def config(*args: Any) -> bool: + calls.append(("config", args)) + if applied: + comp.Solving = args[1] + return applied + + model.FixComponent = fix + model.UnfixComponent = unfix + model.CompConfigProperties5 = config + return adapter, comp, calls + + @pytest.mark.asyncio + async def test_set_component_state_floats_and_flexes(self, monkeypatch) -> None: + adapter, _comp, calls = self._state_adapter(monkeypatch) + + result = await adapter.set_component_state( + "crank-1", fixed=False, flexible=True + ) + + assert result.is_success, result.error + assert calls[0] == "unfix" + # Suppression, visibility, configuration, BOM and envelope go back as read. + assert calls[1] == ("config", (3, 1, True, True, "Default", False, False)) + assert result.data["before"]["fixed"] is True + assert result.data["after"]["fixed"] is False + assert result.data["after"]["flexible"] is True + assert result.data["after"]["referenced_configuration"] == "Default" + + @pytest.mark.asyncio + async def test_set_component_state_fixes(self, monkeypatch) -> None: + adapter, _comp, calls = self._state_adapter(monkeypatch, fixed=False) + + result = await adapter.set_component_state("crank-1", fixed=True) + + assert result.is_success, result.error + assert calls == ["fix"] + assert result.data["after"]["fixed"] is True + + @pytest.mark.asyncio + async def test_a_part_cannot_be_flexible(self, monkeypatch) -> None: + adapter, _comp, _calls = self._state_adapter(monkeypatch, applied=False) + + result = await adapter.set_component_state("crank-1", flexible=True) + + assert result.is_error + assert "only a sub-assembly can be flexible" in (result.error or "") + + @pytest.mark.asyncio + async def test_a_component_that_will_not_select_is_reported( + self, monkeypatch + ) -> None: + adapter, _comp, _calls = self._state_adapter(monkeypatch, selects=False) + + result = await adapter.set_component_state("crank-1", fixed=False) + + assert result.is_error + assert "Failed to select crank-1" in (result.error or "") + + @pytest.mark.asyncio + async def test_a_fix_that_does_not_take_is_reported(self, monkeypatch) -> None: + adapter, _comp, _calls = self._state_adapter(monkeypatch, fixed=False) + adapter.currentModel.FixComponent = lambda: None # SolidWorks ignores it + + result = await adapter.set_component_state("crank-1", fixed=True) + + assert result.is_error + assert "crank-1 is still floating" in (result.error or "") + + @pytest.mark.asyncio + async def test_set_component_state_refusals(self, monkeypatch) -> None: + part = self._document(monkeypatch, 1) + adapter, _comp, _calls = self._state_adapter(monkeypatch) + + wrong_doc = await part.set_component_state("crank-1", fixed=True) + nothing = await adapter.set_component_state("crank-1") + missing = await adapter.set_component_state("ghost-1", fixed=True) + + assert "requires an assembly document" in (wrong_doc.error or "") + assert "Nothing to change" in (nothing.error or "") + assert "Component(s) not found: ghost-1" in (missing.error or "") + @pytest.mark.asyncio async def test_list_components_returns_their_names(self, monkeypatch) -> None: adapter = self._document(monkeypatch, 2) diff --git a/tests/solidworks_mcp/adapters/test_base.py b/tests/solidworks_mcp/adapters/test_base.py index c028d49a..14c6b4c4 100644 --- a/tests/solidworks_mcp/adapters/test_base.py +++ b/tests/solidworks_mcp/adapters/test_base.py @@ -634,6 +634,10 @@ async def set_dimension(self, name, value): assert not mate_result.is_success assert "add_mate" in (mate_result.error or "") + state_result = await adapter.set_component_state("a-1", fixed=True) + assert not state_result.is_success + assert "set_component_state" in (state_result.error or "") + CAPABILITIESFeatureEditingCapability = [ "delete_feature", diff --git a/tests/solidworks_mcp/adapters/test_circuit_breaker.py b/tests/solidworks_mcp/adapters/test_circuit_breaker.py index 6049823f..c01fcce9 100644 --- a/tests/solidworks_mcp/adapters/test_circuit_breaker.py +++ b/tests/solidworks_mcp/adapters/test_circuit_breaker.py @@ -914,6 +914,7 @@ async def create_cut_extrude(self, params): "insert_component", "list_components", "add_mate", + "set_component_state", ] diff --git a/tests/solidworks_mcp/adapters/test_connection_pool.py b/tests/solidworks_mcp/adapters/test_connection_pool.py index 819dfafc..396b0103 100644 --- a/tests/solidworks_mcp/adapters/test_connection_pool.py +++ b/tests/solidworks_mcp/adapters/test_connection_pool.py @@ -786,7 +786,13 @@ async def disconnect(self): return None async def create_reference_plane( - self, reference, offset=0.0, angle=0.0, flip=False + self, + reference, + offset=0.0, + angle=0.0, + flip=False, + axis=None, + face_point=None, ): return _ok("plane") @@ -871,10 +877,15 @@ async def add_mate( alignment="aligned", distance=0.0, angle=0.0, + face_a=None, + face_b=None, ): return _ok("mate") - async def add_chamfer(self, distance, edge_names): + async def set_component_state(self, component, fixed=None, flexible=None): + return _ok("state") + + async def add_chamfer(self, distance, edge_names, angle=45.0, flip=False): return _ok("chamfer") async def get_selected_entities(self): @@ -906,6 +917,7 @@ async def _exercise_every_passthrough(wrapped) -> None: assert (await wrapped.rename_feature("Sketch1", "Sketch2")).is_success assert (await wrapped.undo()).is_success assert (await wrapped.add_mate("Comp1", "Comp2")).is_success + assert (await wrapped.set_component_state("Comp1", fixed=True)).is_success assert (await wrapped.add_chamfer(1.0, ["Edge1"])).is_success assert (await wrapped.get_selected_entities()).is_success diff --git a/tests/solidworks_mcp/tools/test_analysis.py b/tests/solidworks_mcp/tools/test_analysis.py index b9c82d42..53430299 100644 --- a/tests/solidworks_mcp/tools/test_analysis.py +++ b/tests/solidworks_mcp/tools/test_analysis.py @@ -7,6 +7,7 @@ import pytest from fastmcp import FastMCP +from solidworks_mcp.adapters.base import AdapterResult, AdapterResultStatus from solidworks_mcp.adapters.mock_adapter import MockSolidWorksAdapter from solidworks_mcp.config import ( AdapterType, @@ -491,3 +492,25 @@ def test_capability_name_matches_the_tool_gate() -> None: f"no tool in analysis.py gates on adapter.{CAPABILITY}; either the " "gate was renamed or this capability is unreachable" ) + + +@pytest.mark.asyncio +async def test_check_interference_accepts_a_plain_dict( + mcp_server, mock_adapter +) -> None: + """A script can pass a dict, as #209's U-joint session did.""" + mock_adapter.check_interference = AsyncMock( + return_value=AdapterResult( + status=AdapterResultStatus.SUCCESS, + data={"interference_count": 0, "interferences": []}, + ) + ) + await register_analysis_tools(mcp_server, mock_adapter, {}) + tool = next( + t.fn for t in await mcp_server.list_tools() if t.name == "check_interference" + ) + + result = await tool({"components": ["a-1", "b-1"]}) + + assert result["status"] == "success" + assert mock_adapter.check_interference.await_count == 1 diff --git a/tests/solidworks_mcp/tools/test_drawing.py b/tests/solidworks_mcp/tools/test_drawing.py index f8615405..04ee002b 100644 --- a/tests/solidworks_mcp/tools/test_drawing.py +++ b/tests/solidworks_mcp/tools/test_drawing.py @@ -812,15 +812,10 @@ async def test_typed_drawing_tools_exception_paths( # ── additional drawing tool branches (legacy tool set) ─────────────── @pytest.mark.asyncio - async def test_add_dimension_raw_dict_payload_normalization( + async def test_add_dimension_accepts_a_plain_dict( self, mcp_server, mock_adapter, mock_config ): - """Covers dict payload branch with entities/position alias mapping. - - The entities/position aliases must reach the adapter call as - entity1/entity2/position_x/position_y even when the caller only - supplied entities/position. - """ + """A plain dict is validated as AddDimensionInput and reaches the adapter.""" await register_drawing_tools(mcp_server, mock_adapter, mock_config) mock_adapter.add_dimension = AsyncMock( @@ -834,8 +829,10 @@ async def test_add_dimension_raw_dict_payload_normalization( result = await tool_func( { "dimension_type": "linear", - "entities": ["Edge1", "Edge2"], - "position": [11.0, 22.0], + "entity1": "Edge1", + "entity2": "Edge2", + "position_x": 11.0, + "position_y": 22.0, "precision": 3, } ) diff --git a/tests/solidworks_mcp/tools/test_drawing_automation.py b/tests/solidworks_mcp/tools/test_drawing_automation.py index 1e2ffe1b..ab60c207 100644 --- a/tests/solidworks_mcp/tools/test_drawing_automation.py +++ b/tests/solidworks_mcp/tools/test_drawing_automation.py @@ -33,6 +33,22 @@ ) +#: The smallest valid input of each tool that has required fields. +_VALID_INPUT: dict[str, dict[str, Any]] = { + "insert_bom_table": {"view_name": "Drawing View1"}, + "add_gtol": {"characteristic": "position", "tolerance": 0.1}, + "add_datum_tag": { + "label": "A", + "attach_x_mm": 10, + "attach_y_mm": 10, + "x_mm": 20, + "y_mm": 20, + }, + "set_sheet_scale": {"numerator": 1, "denominator": 2}, + "generate_drawing_package": {"model_path": "C:/parts/block.sldprt"}, +} + + async def _tool(mcp_server: Any, name: str) -> Any: return next(t.fn for t in await mcp_server.list_tools() if t.name == name) @@ -331,7 +347,7 @@ async def test_missing_adapter_method_is_reported( ) -> None: await register_drawing_automation_tools(mcp_server, object(), mock_config) - result = await (await _tool(mcp_server, tool))({}) + result = await (await _tool(mcp_server, tool))(_VALID_INPUT.get(tool, {})) assert result["error_type"] == "not_supported" assert f"does not support {tool}" in result["message"] diff --git a/tests/solidworks_mcp/tools/test_export.py b/tests/solidworks_mcp/tools/test_export.py index a837db50..1bdebab6 100644 --- a/tests/solidworks_mcp/tools/test_export.py +++ b/tests/solidworks_mcp/tools/test_export.py @@ -800,3 +800,20 @@ async def test_export_tools_native_adapter_methods(mcp_server, mock_config): ) ) )["status"] == "success" + + +@pytest.mark.asyncio +async def test_a_file_export_without_format_type_uses_the_tools_own( + mcp_server, mock_adapter, mock_config +) -> None: + await register_export_tools(mcp_server, mock_adapter, mock_config) + mock_adapter.export_step = AsyncMock( + return_value=Mock(is_success=True, data={}, execution_time=0.1) + ) + tool = next(t.fn for t in await mcp_server.list_tools() if t.name == "export_step") + + result = await tool({"file_path": "C:/out/part.step"}) + + assert result["status"] == "success" + assert mock_adapter.export_step.call_args.args[0]["format_type"] == "step" + assert ExportFileInput(file_path="C:/out/part.step").format_type is None diff --git a/tests/solidworks_mcp/tools/test_modeling.py b/tests/solidworks_mcp/tools/test_modeling.py index 1746b67d..cfc7e3cf 100644 --- a/tests/solidworks_mcp/tools/test_modeling.py +++ b/tests/solidworks_mcp/tools/test_modeling.py @@ -43,6 +43,7 @@ RenameFeatureInput, SelectFaceEdgesInput, SelectFeatureInput, + SetComponentStateInput, SetDimensionInput, SetUnitsInput, SuppressFeatureInput, @@ -257,7 +258,8 @@ async def test_create_extrusion_blind(self, mcp_server, mock_adapter, mock_confi assert result["status"] == "success" assert result["extrusion"]["sketch"] == "Sketch1" assert result["extrusion"]["depth"] == 25.0 - assert result["extrusion"]["direction"] == "blind" + assert result["extrusion"]["end_condition"] == "Blind" + assert "direction" not in result["extrusion"] @pytest.mark.asyncio async def test_create_revolve_full(self, mcp_server, mock_adapter, mock_config): @@ -807,7 +809,9 @@ async def test_add_chamfer_success_and_failure_paths( assert success_result["status"] == "success" assert success_result["chamfer"]["distance"] == 1.5 assert success_result["chamfer"]["edges"] == ["Edge<1>", "Edge<2>"] - mock_adapter.add_chamfer.assert_awaited_once_with(1.5, ["Edge<1>", "Edge<2>"]) + mock_adapter.add_chamfer.assert_awaited_once_with( + 1.5, ["Edge<1>", "Edge<2>"], 45.0, False + ) mock_adapter.add_chamfer = AsyncMock( return_value=Mock( @@ -1361,7 +1365,7 @@ async def test_create_reference_plane_surfaces_adapter_error( def test_create_reference_plane_input_rejects_blank_reference(self): """CreateReferencePlaneInput.model_post_init rejects a blank reference.""" - with pytest.raises(ValueError, match="reference is required"): + with pytest.raises(ValueError, match="exactly one of reference or face_point"): CreateReferencePlaneInput(reference=" ", offset=10.0) def test_set_units_input_rejects_blank_unit_system(self): @@ -2060,3 +2064,199 @@ async def test_rebuild_model_tool_no_model( assert result["status"] == "error" assert "No active model" in result["message"] assert "data" not in result + + +@pytest.mark.asyncio +async def test_extrusion_reply_says_mid_plane_for_both_directions( + mcp_server, mock_adapter +) -> None: + await mock_adapter.create_part() + await register_modeling_tools(mcp_server, mock_adapter, {}) + tool = next( + t.fn for t in await mcp_server.list_tools() if t.name == "create_extrusion" + ) + + result = await tool( + {"sketch_name": "Sketch1", "depth": 38.1, "both_directions": True} + ) + + assert result["extrusion"]["end_condition"] == "MidPlane" + assert result["extrusion"]["both_directions"] is True + assert "thin" not in result["extrusion"] + + +@pytest.mark.asyncio +async def test_a_thin_extrusion_reports_its_wall(mcp_server, mock_adapter) -> None: + await mock_adapter.create_part() + await register_modeling_tools(mcp_server, mock_adapter, {}) + tool = next( + t.fn for t in await mcp_server.list_tools() if t.name == "create_extrusion" + ) + + result = await tool( + { + "sketch_name": "Sketch1", + "depth": 38.1, + "thin_feature": True, + "thin_thickness": 6.35, + "thin_side": "other_side", + } + ) + + assert result["extrusion"]["thin"] == {"thickness": 6.35, "side": "other_side"} + feature = next(iter(mock_adapter._features.values())) + assert feature.parameters["thin_side"] == "other_side" + + +@pytest.mark.asyncio +async def test_extrusion_falls_back_to_the_request_when_the_adapter_is_silent( + mcp_server, mock_adapter +) -> None: + mock_adapter.create_extrusion = AsyncMock( + return_value=AdapterResult( + status=AdapterResultStatus.SUCCESS, data={"name": "Boss-Extrude1"} + ) + ) + await register_modeling_tools(mcp_server, mock_adapter, {}) + tool = next( + t.fn for t in await mcp_server.list_tools() if t.name == "create_extrusion" + ) + + result = await tool({"sketch_name": "Sketch1", "depth": 5, "both_directions": True}) + + assert result["extrusion"]["end_condition"] == "MidPlane" + + +def test_a_thin_wall_needs_a_thickness() -> None: + with pytest.raises( + ValueError, match="thin_feature needs a positive thin_thickness" + ): + CreateExtrusionInput(sketch_name="Sketch1", depth=5, thin_feature=True) + + +def test_a_chamfer_angle_must_be_between_0_and_90() -> None: + with pytest.raises(ValueError, match="angle must be between 0 and 90"): + AddChamferInput(distance=1.0, angle=90) + + +@pytest.mark.asyncio +async def test_the_mock_chamfer_records_angle_and_flip(mock_adapter) -> None: + await mock_adapter.create_part() + + result = await mock_adapter.add_chamfer(1.0, ["Edge<1>"], 60.0, True) + refused = await mock_adapter.add_chamfer(1.0, [], 60.0, True) + + assert result.data.parameters["angle"] == 60.0 + assert result.data.parameters["flip"] is True + assert refused.is_error + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"reference": "Top Plane", "face_point": [0, 0, 0]}, "exactly one of"), + ({"face_point": [0, 0]}, "face_point must be"), + ({"reference": "Top Plane", "angle": 30}, "needs axis"), + ], +) +def test_reference_plane_input_refusals(kwargs: dict[str, Any], message: str) -> None: + with pytest.raises(ValueError, match=message): + CreateReferencePlaneInput(**kwargs) + + +@pytest.mark.asyncio +async def test_reference_plane_tool_passes_axis_and_face_point( + mcp_server, mock_adapter +) -> None: + await mock_adapter.create_part() + await register_modeling_tools(mcp_server, mock_adapter, {}) + tool = next( + t.fn + for t in await mcp_server.list_tools() + if t.name == "create_reference_plane" + ) + + angled = await tool({"reference": "Top Plane", "angle": 40, "axis": "Axis1"}) + on_face = await tool({"face_point": [0, 10, 5]}) + + assert angled["plane"]["axis"] == "Axis1" + assert angled["plane"]["angle"] == 40 + assert on_face["plane"]["face_point"] == [0, 10, 5] + assert on_face["plane"]["reference"] is None + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"entity_a": "Top Plane", "face_a": [0, 0, 0]}, "entity_a or face_a"), + ({"face_b": [0, 0]}, "face_b must be"), + ], +) +def test_add_mate_input_refusals(kwargs: dict[str, Any], message: str) -> None: + with pytest.raises(ValueError, match=message): + AddMateInput(component_a="a-1", component_b="b-1", **kwargs) + + +@pytest.mark.asyncio +async def test_add_mate_tool_passes_faces_and_defaults_the_planes( + mcp_server, mock_adapter +) -> None: + await register_modeling_tools(mcp_server, mock_adapter, {}) + tool = next(t.fn for t in await mcp_server.list_tools() if t.name == "add_mate") + + by_face = await tool( + { + "component_a": "bracket-1", + "component_b": "pin-1", + "face_a": [0, 76.2, 38.1], + "face_b": [0, 4.7, 0], + "mate_type": "concentric", + } + ) + by_plane = await tool({"component_a": "bracket-1", "component_b": "pin-1"}) + + assert by_face["mate"]["entities"] == [ + "face at [0.0, 76.2, 38.1] mm", + "face at [0.0, 4.7, 0.0] mm", + ] + assert by_plane["mate"]["entities"] == ["Front Plane", "Front Plane"] + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"component": " ", "fixed": True}, "component is required"), + ({"component": "a-1"}, "give fixed"), + ], +) +def test_set_component_state_input_refusals( + kwargs: dict[str, Any], message: str +) -> None: + with pytest.raises(ValueError, match=message): + SetComponentStateInput(**kwargs) + + +@pytest.mark.asyncio +async def test_set_component_state_tool_says_what_changed( + mcp_server, mock_adapter +) -> None: + await register_modeling_tools(mcp_server, mock_adapter, {}) + tool = next( + t.fn for t in await mcp_server.list_tools() if t.name == "set_component_state" + ) + + both = await tool({"component": "crank-1", "fixed": False, "flexible": True}) + fixed = await tool({"component": "crank-1", "fixed": True}) + rigid = await tool({"component": "crank-1", "flexible": False}) + + assert both["message"] == "crank-1 is now floating and flexible" + assert both["state"]["before"] == {"fixed": False, "flexible": False} + assert fixed["message"] == "crank-1 is now fixed" + assert rigid["message"] == "crank-1 is now rigid" + + +@pytest.mark.asyncio +async def test_the_mock_refuses_an_empty_state_change(mock_adapter) -> None: + result = await mock_adapter.set_component_state("crank-1") + + assert result.is_error diff --git a/tests/solidworks_mcp/tools/test_tool_boundary.py b/tests/solidworks_mcp/tools/test_tool_boundary.py index 5345e4e9..bf7db27c 100644 --- a/tests/solidworks_mcp/tools/test_tool_boundary.py +++ b/tests/solidworks_mcp/tools/test_tool_boundary.py @@ -17,6 +17,7 @@ ToolOperationError, ToolRegistrar, describe_validation_error, + model_parameters, normalize_input, require_success, tool_boundary, @@ -493,3 +494,52 @@ async def without_data() -> dict[str, Any]: assert (await with_data())["data"] == {"error_count": 1} assert "data" not in await without_data() + + +# ---- plain dicts -------------------------------------------------------------- + + +class _Payload(BaseModel): + depth: float + + +@pytest.mark.asyncio +async def test_a_dict_is_validated_as_the_tools_model() -> None: + seen: list[Any] = [] + + async def body(input_data: _Payload) -> dict[str, Any]: + seen.append(input_data) + return {"status": "success"} + + wrapped = tool_boundary(body) + + assert (await wrapped({"depth": 2})) == {"status": "success"} + assert (await wrapped(input_data={"depth": 3})) == {"status": "success"} + assert seen == [_Payload(depth=2), _Payload(depth=3)] + + +@pytest.mark.asyncio +async def test_a_bad_dict_is_invalid_input() -> None: + async def body(input_data: _Payload | None = None) -> dict[str, Any]: + return {"status": "success"} + + result = await tool_boundary(body)({"depth": "deep"}) + + assert result["error_type"] == "invalid_input" + assert "depth" in result["message"] + + +def test_model_parameters_finds_models_alone_or_optional() -> None: + async def body( + a: _Payload, b: _Payload | None, c: dict[str, Any], d: int | str + ) -> dict[str, Any]: + return {} + + assert model_parameters(body) == {"a": _Payload, "b": _Payload} + + +def test_model_parameters_is_empty_when_annotations_cannot_resolve() -> None: + async def body(input_data: NoSuchModel) -> dict[str, Any]: # noqa: F821 + return {} + + assert model_parameters(body) == {}