From 10c2beb0b9da421711609ee6595f8ee2137970e8 Mon Sep 17 00:00:00 2001 From: denglobert <291886786+denglobert@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:31:10 +0200 Subject: [PATCH 01/31] Fix absolute `from sw_core import` inside tool functions Nine tool functions imported helpers with `from sw_core import ...` instead of the package-relative `from .sw_core import ...`. Once the package is installed, or imported as `solidworks_mcp`, there is no top-level `sw_core` module, so each of these tools raised ModuleNotFoundError on its first call: create_axis, draw_spline, sketch_mirror, set_appearance, list_mates, set_drawing_view, insert_section_view, insert_detail_view, and the selected-only path of insert_center_marks. Verified against a live SOLIDWORKS 2016 SP3 session (German UI): all nine tools now run and their results were checked numerically. The offline suite and the server import stay green. Co-Authored-By: Claude Fable 5.1 --- solidworks_mcp/sw_assembly.py | 2 +- solidworks_mcp/sw_drawing.py | 8 ++++---- solidworks_mcp/sw_file.py | 2 +- solidworks_mcp/sw_refgeom.py | 2 +- solidworks_mcp/sw_sketch.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/solidworks_mcp/sw_assembly.py b/solidworks_mcp/sw_assembly.py index 8bc6cce..e55a4ef 100644 --- a/solidworks_mcp/sw_assembly.py +++ b/solidworks_mcp/sw_assembly.py @@ -193,7 +193,7 @@ def add_mate(args: dict[str, Any]) -> dict[str, Any]: {}, ) def list_mates(args: dict[str, Any]) -> dict[str, Any]: - from sw_core import as_list, feature_property, iter_feature_objects + from .sw_core import as_list, feature_property, iter_feature_objects _, doc = require_assembly() reverse_types = {v: k for k, v in MATE_TYPES.items()} diff --git a/solidworks_mcp/sw_drawing.py b/solidworks_mcp/sw_drawing.py index 98fcf74..8830a9b 100644 --- a/solidworks_mcp/sw_drawing.py +++ b/solidworks_mcp/sw_drawing.py @@ -529,7 +529,7 @@ def _draw_in_view(doc: Any, parent_view: str | None, draw: Any) -> tuple[str, An if entity is None: raise RuntimeError("SOLIDWORKS did not draw the reference geometry in the view.") clear_selection(doc) - from sw_core import select_object + from .sw_core import select_object if not select_object(doc, entity, mark=0, append=False): raise RuntimeError("Could not select the reference geometry just drawn.") @@ -557,7 +557,7 @@ def _draw_in_view(doc: Any, parent_view: str | None, draw: Any) -> tuple[str, An ["place_x_mm", "place_y_mm"], ) def insert_section_view(args: dict[str, Any]) -> dict[str, Any]: - from sw_core import empty_variant + from .sw_core import empty_variant _, doc = require_drawing() parent = args.get("parent_view") @@ -721,7 +721,7 @@ def create_drawing_sketch(args: dict[str, Any]) -> dict[str, Any]: ["name"], ) def set_drawing_view(args: dict[str, Any]) -> dict[str, Any]: - from sw_core import double_array + from .sw_core import double_array _, doc = require_drawing() name = str(args["name"]) @@ -904,7 +904,7 @@ def _select_circular_edges(doc: Any, view: Any) -> int: view, so a tool that means "mark every hole here" has to build that selection itself. """ - from sw_core import nothing + from .sw_core import nothing clear_selection(doc) count = 0 diff --git a/solidworks_mcp/sw_file.py b/solidworks_mcp/sw_file.py index 2a4617c..bb70c60 100644 --- a/solidworks_mcp/sw_file.py +++ b/solidworks_mcp/sw_file.py @@ -302,7 +302,7 @@ def rebuild_document(args: dict[str, Any]) -> dict[str, Any]: ["red", "green", "blue"], ) def set_appearance(args: dict[str, Any]) -> dict[str, Any]: - from sw_core import as_list, enumerate_faces, get_bodies, safe + from .sw_core import as_list, enumerate_faces, get_bodies, safe _, doc = require_part() values = ( diff --git a/solidworks_mcp/sw_refgeom.py b/solidworks_mcp/sw_refgeom.py index eb8ed9f..b16a198 100644 --- a/solidworks_mcp/sw_refgeom.py +++ b/solidworks_mcp/sw_refgeom.py @@ -133,7 +133,7 @@ def create_plane(args: dict[str, Any]) -> dict[str, Any]: def create_axis(args: dict[str, Any]) -> dict[str, Any]: _, doc = require_part() exit_active_sketch(doc) - from sw_core import find_feature, iter_features + from .sw_core import find_feature, iter_features before = {f["name"] for f in iter_features(doc)} count = require_selection(doc, args["selection"]) diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 9fecad3..85adc63 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -454,7 +454,7 @@ def draw_spline(args: dict[str, Any]) -> dict[str, Any]: flat: list[float] = [] for point in args["points"]: flat.extend([to_m(point["x_mm"]), to_m(point["y_mm"]), 0.0]) - from sw_core import double_array + from .sw_core import double_array before = _segment_count(doc) manager.CreateSpline(double_array(flat)) @@ -615,7 +615,7 @@ def sketch_mirror(args: dict[str, Any]) -> dict[str, Any]: index = int(args["mirror_segment"]) if not 0 <= index < len(segments): return result(False, f"mirror_segment {index} is out of range (0..{len(segments) - 1}).") - from sw_core import select_object + from .sw_core import select_object if not select_object(doc, segments[index], mark=0, append=True): return result(False, "Could not add the mirror centerline to the selection.") From 4831e37482ec1e4622bacd163bea55426036bf9a Mon Sep 17 00:00:00 2001 From: denglobert <291886786+denglobert@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:58:02 +0200 Subject: [PATCH 02/31] Read the assigned material back in the shapes older builds return it set_material verified its work by calling GetMaterialPropertyName2 with the database as a plain string. SOLIDWORKS 2016 returns an empty name for that form even though the material was applied -- the mass changes -- so the tool reported failure on every call there. The database argument is [in, out], so pass it as a typed by-reference VARIANT first, accept a tuple return, and fall back to MaterialIdName, which reports "database|name" and has been stable since 2008. Verified on 2016: 'AISI 1020' reads back as such and moves a 36 cm^3 block from 36 g to 284 g; an unknown name still reports the material actually on the part. Co-Authored-By: Claude Fable 5.1 --- solidworks_mcp/sw_file.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/solidworks_mcp/sw_file.py b/solidworks_mcp/sw_file.py index bb70c60..d95057e 100644 --- a/solidworks_mcp/sw_file.py +++ b/solidworks_mcp/sw_file.py @@ -40,6 +40,7 @@ require_part, result, running_app, + safe, discover_template, tool, value, @@ -328,6 +329,32 @@ def set_appearance(args: dict[str, Any]) -> dict[str, Any]: return result(True, f"Applied the colour to {target}.", rgb=[args["red"], args["green"], args["blue"]]) +def _material_readback(doc: Any, database: str) -> str: + """Read the assigned material name back, across the API's several shapes. + + GetMaterialPropertyName2 takes the database as an [in, out] string. Given + a plain Python string, SOLIDWORKS 2016 returns nothing even though the + material was applied (the mass changes), so the typed by-reference form is + tried first. MaterialIdName, stable since 2008, reports "database|name" + and is the last resort. + """ + for database_arg in ( + win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_BSTR, database), + database, + ): + try: + raw = doc.GetMaterialPropertyName2("", database_arg) + except Exception: + continue + if isinstance(raw, (list, tuple)): + raw = raw[0] if raw else "" + name = str(raw or "") + if name: + return name + ident = str(safe(doc, "MaterialIdName", "") or "") + return ident.split("|", 1)[1] if "|" in ident else ident + + @tool( "set_material", "Assign a SOLIDWORKS material to the active part, which is what makes get_mass_properties " @@ -346,11 +373,7 @@ def set_material(args: dict[str, Any]) -> dict[str, Any]: doc.SetMaterialPropertyName2("", database, name) except Exception as exc: return result(False, f"SOLIDWORKS rejected that material: {exc}") - applied = "" - try: - applied = str(doc.GetMaterialPropertyName2("", database) or "") - except Exception: - pass + applied = _material_readback(doc, database) rebuild(doc) # An unknown material name is accepted silently and leaves the part # unassigned, so treat anything but an exact read-back as a failure. From d07a29b90c584cdfdaf3f1b09f239eaff6be48bd Mon Sep 17 00:00:00 2001 From: denglobert <291886786+denglobert@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:59:25 +0200 Subject: [PATCH 03/31] Fall back to earlier API method variants on releases that lack the newest Each SOLIDWORKS release adds numbered method variants whose argument list extends the previous one, and keeps the old names. The server always called the newest, so on a build without it -- SOLIDWORKS 2016 has no FeatureCut4 -- cut_extrude, sweep, linear_pattern, circular_pattern and insert_detail_view raised AttributeError on every call. call_versioned() in sw_core tries the candidates in order and treats AttributeError as "not on this build", remembering absent names in _UNFLAGGABLE_NAMES so the cross-process lookup is not repeated. The older names join the flag lists so they are flagged as methods like everything else. Read off the 2016 type library with tools/tlb_probe.py, every older variant takes a strict prefix of the arguments already passed: FeatureCut3 26 of 27 InsertProtrusionSwept3 17 of 20 InsertCutSwept4 19 of 22 FeatureLinearPattern4 20 of 22 FeatureCircularPattern4 7 of 14 CreateDetailViewAt3 9 of 12 On newer releases the first candidate always resolves, so behaviour there is unchanged. Verified numerically against a live SOLIDWORKS 2016 SP3 session: the through-all cut removes pi*8^2*20 mm^3 exactly, the linear and circular patterns remove exactly one and five further holes, the swept rod and its swept cut match pi*r^2*L, and the detail view appears at the requested scale. The offline suite gains tests for the helper and for the flag lists. Refs #1 Co-Authored-By: Claude Fable 5.1 --- README.md | 6 +++ solidworks_mcp/sw_core.py | 31 ++++++++++++++ solidworks_mcp/sw_drawing.py | 12 +++++- solidworks_mcp/sw_feature.py | 42 ++++++++++++++++--- tests/test_sw_core.py | 78 ++++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6f41706..733e1c8 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,12 @@ ring, swept rod and lofted cone match their closed-form volumes; a 5-degree under its profile; `through_all_both` removes the full cylinder rather than half of it. The limitations above are what survived that pass. +Older releases work too, back to at least SOLIDWORKS 2016 SP3, where every tool +was exercised the same way on a German install. Where a release lacks the newest +numbered method (`FeatureCut4`, `FeatureLinearPattern5`, `CreateDetailViewAt4`, +...) the tool falls back to the earlier variant with the arguments it takes; on +newer releases the newest name is always tried first, so nothing changes there. + ## Layout | Module | Contents | diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index ac1c03c..c08b384 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -350,6 +350,11 @@ def flag_methods(obj: Any, *names: str) -> Any: "InsertFeatureChamfer", "InsertRib", "InsertMultiFaceDraft", "SimpleHole2", "InsertProtrusionSwept4", "InsertCutSwept5", "InsertProtrusionBlend2", "InsertCutBlend", "FeatureLinearPattern5", "FeatureCircularPattern5", "InsertMirrorFeature2", "InsertRefPlane", + # Older variants that call_versioned falls back to on builds without the + # newer names above (SOLIDWORKS 2016 has FeatureCut3 but no FeatureCut4). + # Newer builds keep the old names, so flagging them costs nothing there. + "FeatureCut3", "InsertProtrusionSwept3", "InsertCutSwept4", + "FeatureLinearPattern4", "FeatureCircularPattern4", ) _EXTENSION_METHODS = ( @@ -392,6 +397,32 @@ def selectable(obj: Any) -> Any: return flag_methods(obj, "Select2", "Select4") +def call_versioned(obj: Any, *candidates: tuple[str, Sequence[Any]]) -> Any: + """Call the first member of ``obj`` that this SOLIDWORKS build exposes. + + Each release adds numbered variants -- FeatureCut4 over FeatureCut3 -- whose + argument list extends the older one, while the older name keeps working. + List the newest first with its full arguments and the older ones with the + prefix they take; a build that lacks the newer name (SOLIDWORKS 2016 has + no FeatureCut4) silently gets the older call, and a build that has it never + pays for the fallback. Names already known to be absent are skipped + without another cross-process lookup. + """ + last: Exception | None = None + for name, args in candidates: + if name in _UNFLAGGABLE_NAMES: + continue + try: + method = getattr(obj, name) + except AttributeError as exc: + _UNFLAGGABLE_NAMES.add(name) + last = exc + continue + return method(*args) + names = ", ".join(name for name, _ in candidates) + raise RuntimeError(f"This SOLIDWORKS build exposes none of: {names}.") from last + + def safe(obj: Any, member: str, default: Any = None) -> Any: try: return value(obj, member) diff --git a/solidworks_mcp/sw_drawing.py b/solidworks_mcp/sw_drawing.py index 8830a9b..e7349e3 100644 --- a/solidworks_mcp/sw_drawing.py +++ b/solidworks_mcp/sw_drawing.py @@ -36,6 +36,7 @@ active_document, as_list, byref_long, + call_versioned, clear_selection, document_info, document_type, @@ -108,7 +109,7 @@ _DRAWING_METHODS = ( "NewSheet3", "SetupSheet5", "SetupSheet4", "ActivateSheet", "ActivateView", "CreateDrawViewFromModelView3", "Create3rdAngleViews2", "Create1stAngleViews2", - "CreateUnfoldedViewAt3", "CreateSectionViewAt5", "CreateDetailViewAt4", + "CreateUnfoldedViewAt3", "CreateSectionViewAt5", "CreateDetailViewAt4", "CreateDetailViewAt3", "InsertModelAnnotations3", "AutoDimension", "InsertCenterMark3", "InsertCenterLine2", "AutoInsertCenterMarks2", "SelectEntity", "GetSheetNames", "GetFirstView", "GetCurrentSheet", @@ -632,7 +633,7 @@ def insert_detail_view(args: dict[str, Any]) -> dict[str, Any]: ), ) - view = doc.CreateDetailViewAt4( + detail_args = ( to_m(args["place_x_mm"]), to_m(args["place_y_mm"]), 0.0, 1, # swDetailCircleStyle_e: 1 = circle profile float(args.get("scale_numerator", 2)), float(args.get("scale_denominator", 1)), @@ -642,6 +643,13 @@ def insert_detail_view(args: dict[str, Any]) -> dict[str, Any]: bool(args.get("jagged_outline", False)), False, 0, ) + # CreateDetailViewAt3 (2016) stops after FullOutline: no jagged outline, + # no fixed-size circle, no border style. + view = call_versioned( + doc, + ("CreateDetailViewAt4", detail_args), + ("CreateDetailViewAt3", detail_args[:9]), + ) if view is None: return result( False, diff --git a/solidworks_mcp/sw_feature.py b/solidworks_mcp/sw_feature.py index 01b1f85..8b57e26 100644 --- a/solidworks_mcp/sw_feature.py +++ b/solidworks_mcp/sw_feature.py @@ -28,6 +28,7 @@ from .sw_core import ( active_document, apply_selection, + call_versioned, CHAMFER_ANGLE_DISTANCE, CHAMFER_DISTANCE_DISTANCE, CHAMFER_EQUAL_DISTANCE, @@ -170,7 +171,7 @@ def cut_extrude(args: dict[str, Any]) -> dict[str, Any]: def attempt(reverse: bool) -> tuple[str, Any]: name = select_sketch_for_feature(doc, args.get("sketch_name")) - return name, feature_manager(doc).FeatureCut4( + cut_args = ( single_direction, bool(args.get("flip_side", False)), reverse, condition_1, condition_2, depth, depth, has_draft, False, False, False, @@ -180,6 +181,13 @@ def attempt(reverse: bool) -> tuple[str, Any]: False, False, False, 0, 0.0, False, False, ) + # FeatureCut3 (2014-2016 builds) takes the same list without the + # trailing OptimizeGeometry flag. + return name, call_versioned( + feature_manager(doc), + ("FeatureCut4", cut_args), + ("FeatureCut3", cut_args[:26]), + ) sketch, feature = attempt(requested) reversed_automatically = False @@ -508,20 +516,32 @@ def sweep(args: dict[str, Any]) -> dict[str, Any]: twist = to_rad(args.get("twist_angle_deg", 0)) if bool(args.get("cut", False)): # InsertCutSwept5 has no Merge flag but does carry assembly scope flags. - feature = manager.InsertCutSwept5( + # InsertCutSwept4 (2016) is the same list without the last three. + cut_args = ( False, True, 0, keep_tangency, False, 0, 0, False, 0.0, 0.0, 0, 0, True, True, twist, False, False, False, False, False, 0.0, 0, ) + feature = call_versioned( + manager, + ("InsertCutSwept5", cut_args), + ("InsertCutSwept4", cut_args[:19]), + ) else: - feature = manager.InsertProtrusionSwept4( + # InsertProtrusionSwept3 (2016) stops after BMergeSmoothFaces. + boss_args = ( False, True, 0, keep_tangency, False, 0, 0, False, 0.0, 0.0, 0, 0, bool(args.get("merge", True)), True, True, twist, False, False, 0.0, 0, ) + feature = call_versioned( + manager, + ("InsertProtrusionSwept4", boss_args), + ("InsertProtrusionSwept3", boss_args[:17]), + ) rename_feature(feature, args.get("name")) return feature_result( doc, feature, "sweep", @@ -609,7 +629,7 @@ def linear_pattern(args: dict[str, Any]) -> dict[str, Any]: require_selection(doc, args["direction2_selection"], mark=2, append=True) require_selection(doc, args["selection"], mark=4, append=True) - feature = feature_manager(doc).FeatureLinearPattern5( + pattern_args = ( int(args.get("count1", 2)), to_m(args["spacing1_mm"]), int(args.get("count2", 1)) if has_second else 1, to_m(args.get("spacing2_mm", 0)) if has_second else 0.0, @@ -619,6 +639,12 @@ def linear_pattern(args: dict[str, Any]) -> dict[str, Any]: False, False, True, True, False, False, False, False, 0.0, 0.0, False, False, ) + # FeatureLinearPattern4 (2016) ends at Offset2, i.e. the first 20 arguments. + feature = call_versioned( + feature_manager(doc), + ("FeatureLinearPattern5", pattern_args), + ("FeatureLinearPattern4", pattern_args[:20]), + ) rename_feature(feature, args.get("name")) return feature_result(doc, feature, "linear pattern", count1=int(args.get("count1", 2))) @@ -649,13 +675,19 @@ def circular_pattern(args: dict[str, Any]) -> dict[str, Any]: # SOLIDWORKS always stores a per-instance step; for equal spacing it wants # the full spread, which it then divides internally. spacing = to_rad(args.get("angle_deg", 360)) - feature = feature_manager(doc).FeatureCircularPattern5( + pattern_args = ( int(args.get("count", 4)), spacing, bool(args.get("reverse", False)), "", bool(args.get("geometry_pattern", False)), bool(args.get("equal_spacing", True)), False, False, False, False, 1, 0.0, "", False, ) + # FeatureCircularPattern4 (2016) ends at VaryInstance, the first 7 arguments. + feature = call_versioned( + feature_manager(doc), + ("FeatureCircularPattern5", pattern_args), + ("FeatureCircularPattern4", pattern_args[:7]), + ) rename_feature(feature, args.get("name")) return feature_result(doc, feature, "circular pattern", count=int(args.get("count", 4))) diff --git a/tests/test_sw_core.py b/tests/test_sw_core.py index 968a7bd..056041c 100644 --- a/tests/test_sw_core.py +++ b/tests/test_sw_core.py @@ -144,6 +144,84 @@ def test_the_flag_lists_are_non_empty_and_free_of_duplicates(self) -> None: self.assertEqual(len(names), len(set(names)), f"{label} repeats a name") +class FakeVersionedDispatch: + """A dispatch whose members resolve the way pywin32's dynamic lookup does. + + Attribute access on a name the build does not expose raises AttributeError + from ``__getattr__``, exactly as ``CDispatch`` does when ``GetIDsOfNames`` + fails; a known name returns a callable that records the call. + """ + + def __init__(self, known: set[str]) -> None: + self.known = set(known) + self.attempted: list[str] = [] + self.calls: list[tuple[str, tuple]] = [] + + def __getattr__(self, name: str): + if name.startswith("_"): + raise AttributeError(name) + self.attempted.append(name) + if name not in self.known: + raise AttributeError(f".{name}") + + def method(*args): + self.calls.append((name, args)) + return f"{name} feature" + + return method + + +class CallVersionedTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = set(sw_core._UNFLAGGABLE_NAMES) + sw_core._UNFLAGGABLE_NAMES.clear() + + def tearDown(self) -> None: + sw_core._UNFLAGGABLE_NAMES.clear() + sw_core._UNFLAGGABLE_NAMES.update(self._saved) + + def test_the_newest_name_wins_when_the_build_has_it(self) -> None: + obj = FakeVersionedDispatch({"FeatureCut4", "FeatureCut3"}) + outcome = sw_core.call_versioned(obj, ("FeatureCut4", (1, 2, 3)), ("FeatureCut3", (1, 2))) + self.assertEqual(outcome, "FeatureCut4 feature") + self.assertEqual(obj.calls, [("FeatureCut4", (1, 2, 3))]) + + def test_an_older_build_gets_the_older_name_with_its_own_arguments(self) -> None: + """The case this helper exists for: SOLIDWORKS 2016 has no FeatureCut4.""" + obj = FakeVersionedDispatch({"FeatureCut3"}) + outcome = sw_core.call_versioned(obj, ("FeatureCut4", (1, 2, 3)), ("FeatureCut3", (1, 2))) + self.assertEqual(outcome, "FeatureCut3 feature") + self.assertEqual(obj.calls, [("FeatureCut3", (1, 2))]) + self.assertIn("FeatureCut4", sw_core._UNFLAGGABLE_NAMES) + + def test_a_name_known_to_be_absent_is_not_looked_up_again(self) -> None: + """Each lookup is a cross-process GetIDsOfNames, so remember the misses.""" + sw_core._UNFLAGGABLE_NAMES.add("FeatureCut4") + obj = FakeVersionedDispatch({"FeatureCut3"}) + sw_core.call_versioned(obj, ("FeatureCut4", ()), ("FeatureCut3", ())) + self.assertEqual(obj.attempted, ["FeatureCut3"]) + + def test_no_candidate_at_all_is_a_clear_error_naming_every_candidate(self) -> None: + obj = FakeVersionedDispatch(set()) + with self.assertRaises(RuntimeError) as raised: + sw_core.call_versioned(obj, ("FeatureCut4", ()), ("FeatureCut3", ())) + self.assertIn("FeatureCut4", str(raised.exception)) + self.assertIn("FeatureCut3", str(raised.exception)) + + def test_every_fallback_name_is_flagged_as_a_method(self) -> None: + """An unflagged argument-taking member is what silently returns None or + takes SOLIDWORKS down, so the older variants must be in the flag lists + like the current ones.""" + from solidworks_mcp import sw_drawing + + fallbacks = { + "FeatureCut3", "InsertProtrusionSwept3", "InsertCutSwept4", + "FeatureLinearPattern4", "FeatureCircularPattern4", + } + self.assertLessEqual(fallbacks, set(sw_core._FEATURE_MANAGER_METHODS)) + self.assertIn("CreateDetailViewAt3", sw_drawing._DRAWING_METHODS) + + class UnitTests(unittest.TestCase): def test_millimetres_and_metres_round_trip(self) -> None: self.assertEqual(sw_core.to_m(1000), 1.0) From 8a14c1c5a79ca18ef6ad9f22c7222356a6bf719c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:42:27 +0800 Subject: [PATCH 04/31] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E7=9A=84=E5=85=A8=E5=B1=80=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E5=B9=B6=E8=A1=A5=E5=85=85=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=8C=85=E4=B8=8E=E6=9D=90=E8=B4=A8=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- solidworks_mcp/sw_core.py | 6 +-- tests/test_sw_core.py | 24 +++++++++--- tests/test_sw_file.py | 81 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 tests/test_sw_file.py diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index c08b384..ebe6f0a 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -405,13 +405,11 @@ def call_versioned(obj: Any, *candidates: tuple[str, Sequence[Any]]) -> Any: List the newest first with its full arguments and the older ones with the prefix they take; a build that lacks the newer name (SOLIDWORKS 2016 has no FeatureCut4) silently gets the older call, and a build that has it never - pays for the fallback. Names already known to be absent are skipped - without another cross-process lookup. + pays for the fallback. Method availability is checked on this object: + a failed flag on another interface does not prove this member is absent. """ last: Exception | None = None for name, args in candidates: - if name in _UNFLAGGABLE_NAMES: - continue try: method = getattr(obj, name) except AttributeError as exc: diff --git a/tests/test_sw_core.py b/tests/test_sw_core.py index 056041c..293ab34 100644 --- a/tests/test_sw_core.py +++ b/tests/test_sw_core.py @@ -194,12 +194,26 @@ def test_an_older_build_gets_the_older_name_with_its_own_arguments(self) -> None self.assertEqual(obj.calls, [("FeatureCut3", (1, 2))]) self.assertIn("FeatureCut4", sw_core._UNFLAGGABLE_NAMES) - def test_a_name_known_to_be_absent_is_not_looked_up_again(self) -> None: - """Each lookup is a cross-process GetIDsOfNames, so remember the misses.""" + def test_a_flag_failure_on_another_interface_does_not_hide_a_member(self) -> None: sw_core._UNFLAGGABLE_NAMES.add("FeatureCut4") - obj = FakeVersionedDispatch({"FeatureCut3"}) - sw_core.call_versioned(obj, ("FeatureCut4", ()), ("FeatureCut3", ())) - self.assertEqual(obj.attempted, ["FeatureCut3"]) + obj = FakeVersionedDispatch({"FeatureCut4", "FeatureCut3"}) + outcome = sw_core.call_versioned(obj, ("FeatureCut4", ()), ("FeatureCut3", ())) + self.assertEqual(outcome, "FeatureCut4 feature") + self.assertEqual(obj.attempted, ["FeatureCut4"]) + + def test_missing_member_on_one_object_does_not_poison_another(self) -> None: + old = FakeVersionedDispatch({"FeatureCut3"}) + new = FakeVersionedDispatch({"FeatureCut4", "FeatureCut3"}) + candidates = (("FeatureCut4", ()), ("FeatureCut3", ())) + sw_core.call_versioned(old, *candidates) + self.assertEqual(sw_core.call_versioned(new, *candidates), "FeatureCut4 feature") + + def test_a_failed_invocation_does_not_retry_with_an_older_member(self) -> None: + obj = unittest.mock.Mock() + obj.FeatureCut4.side_effect = RuntimeError("rebuild failed") + with self.assertRaisesRegex(RuntimeError, "rebuild failed"): + sw_core.call_versioned(obj, ("FeatureCut4", ()), ("FeatureCut3", ())) + obj.FeatureCut3.assert_not_called() def test_no_candidate_at_all_is_a_clear_error_naming_every_candidate(self) -> None: obj = FakeVersionedDispatch(set()) diff --git a/tests/test_sw_file.py b/tests/test_sw_file.py new file mode 100644 index 0000000..5f07c55 --- /dev/null +++ b/tests/test_sw_file.py @@ -0,0 +1,81 @@ +# Copyright 2026 JIALE LIU +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Material readback and package import regressions, without a CAD session.""" + +import ast +import importlib +from pathlib import Path +import unittest +from unittest.mock import Mock, patch + +from solidworks_mcp import sw_file + + +class MaterialReadbackTests(unittest.TestCase): + def test_byref_name(self): + doc = Mock() + doc.GetMaterialPropertyName2.return_value = "AISI 1020" + self.assertEqual(sw_file._material_readback(doc, "Materials"), "AISI 1020") + doc.GetMaterialPropertyName2.assert_called_once() + + def test_tuple_return(self): + doc = Mock() + doc.GetMaterialPropertyName2.return_value = ("AISI 1020", "Materials") + self.assertEqual(sw_file._material_readback(doc, "Materials"), "AISI 1020") + + def test_plain_string_fallback(self): + doc = Mock() + doc.GetMaterialPropertyName2.side_effect = [TypeError("byref unsupported"), "Steel"] + self.assertEqual(sw_file._material_readback(doc, "Materials"), "Steel") + + def test_material_id_fallback(self): + doc = Mock() + doc.GetMaterialPropertyName2.return_value = "" + doc.MaterialIdName = "Materials|Steel" + self.assertEqual(sw_file._material_readback(doc, "Materials"), "Steel") + + def test_missing_material(self): + doc = Mock() + doc.GetMaterialPropertyName2.return_value = "" + doc.MaterialIdName = "" + self.assertEqual(sw_file._material_readback(doc, "Materials"), "") + + def test_unknown_material_is_not_reported_as_applied(self): + doc = Mock() + doc.GetMaterialPropertyName2.return_value = "Previous material" + with patch.object(sw_file, "require_part", return_value=(None, doc)), \ + patch.object(sw_file, "rebuild"): + payload = sw_file.set_material({"name": "Missing material"}) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["material"], "Previous material") + + +class PackageImportTests(unittest.TestCase): + def test_deferred_helper_imports_resolve_in_package_context(self): + # Server import alone never executes imports hidden inside handlers. + # Resolve those imports too, without invoking geometry-changing tools. + root = Path(sw_file.__file__).parent + for source in root.glob("sw_*.py"): + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if not isinstance(node, ast.ImportFrom): + continue + if not (node.module or "").startswith("sw_"): + continue + with self.subTest(source=source.name, line=node.lineno): + module = importlib.import_module( + "." * node.level + node.module, "solidworks_mcp" + ) + for alias in node.names: + self.assertTrue(hasattr(module, alias.name), alias.name) From c48ed7ac6702bb6c2b152a73dd1b0b86926b3d66 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:01:21 +0800 Subject: [PATCH 05/31] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=8C=E5=8D=81?= =?UTF-8?q?=E5=85=AB=E4=B8=AA=E5=A4=9A=E5=AE=9E=E4=BD=93=E6=9B=B2=E9=9D=A2?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=B1=9E=E6=80=A7=E6=96=B9=E7=A8=8B=E4=B8=8E?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=AE=A1=E7=90=86=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 51 ++++- solidworks_mcp/server.py | 2 + solidworks_mcp/sw_assembly.py | 54 +++++ solidworks_mcp/sw_core.py | 10 +- solidworks_mcp/sw_manage.py | 326 +++++++++++++++++++++++++++++ solidworks_mcp/sw_multibody.py | 218 +++++++++++++++++++ tests/live_expansion_regression.py | 217 +++++++++++++++++++ tests/test_expansion.py | 125 +++++++++++ 8 files changed, 998 insertions(+), 5 deletions(-) create mode 100644 solidworks_mcp/sw_manage.py create mode 100644 solidworks_mcp/sw_multibody.py create mode 100644 tests/live_expansion_regression.py create mode 100644 tests/test_expansion.py diff --git a/README.md b/README.md index 733e1c8..1407c66 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -92 tools: sketching with real relations and driving dimensions, the solid +120 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -160,7 +160,37 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` ### Assemblies `list_components`, `insert_component`, `add_mate`, `list_mates`, -`set_component_fixed`. +`set_component_fixed`, `set_component_visibility`, `set_component_suppression`, +`set_component_configuration`. + +### 多实体、曲面与螺旋线 + +| 工具 | 功能 | +| --- | --- | +| `scale_bodies` | 选定实体等比例或按 X/Y/Z 缩放,支持质心与原点。 | +| `move_copy_bodies` | 实体平移、旋转或复制;平移和旋转分两次调用,长度为 mm、角度为度。 | +| `combine_bodies` | 多实体并集、差集与交集;差集的第一个实体为保留的主体。 | +| `delete_bodies` | 创建删除/保留实体特征。 | +| `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | +| `list_surface_bodies` | 查询曲面体及其面积,面积单位为 mm²。 | +| `create_helix` | 从圆草图按螺距和圈数生成螺旋线,读取实际螺距、圈数及高度。 | + +实体操作沿用 `selection`,例如 `{"selection": {"bodies": [0, 1]}}`。 +索引取自 `list_bodies`,几何变化后需重新查询。曲面体索引与实体索引独立, +本批实体编辑工具只接受实体。 + +### 文档、配置、属性与方程 + +| 工具 | 功能 | +| --- | --- | +| `list_open_documents` / `activate_document` / `close_document` | 查询、切换和关闭已有文档;关闭有未保存修改的文档需显式传入 `discard_changes=true`。 | +| `list_configurations` / `create_configuration` / `activate_configuration` / `delete_configuration` | 查询、创建、激活和删除配置;不能删除活动配置。 | +| `list_custom_properties` / `get_custom_property` / `set_custom_property` / `delete_custom_property` | 查询和编辑文档级或配置级自定义属性,包括原始值、解析值与类型。 | +| `list_equations` / `add_equation` / `set_equation` / `delete_equation` / `evaluate_equations` | 查询和编辑方程与全局变量,使用 SOLIDWORKS 方程语法,例如 `"Width" = 25mm`。 | +| `list_materials` | 搜索实际配置的材质库,返回可传给 `set_material` 的准确名称与数据库路径。 | + +`configuration` 为空时操作文档级属性,非空时必须是已存在的配置名称。 +方程里的单位由 SOLIDWORKS 语法解释,应明确写出 `mm` 等单位;方程索引在删除后变化。 ### Engineering drawings `create_drawing`, `list_sheets`, `add_sheet`, `activate_sheet`, @@ -297,8 +327,18 @@ ring, swept rod and lofted cone match their closed-form volumes; a 5-degree under its profile; `through_all_both` removes the full cylinder rather than half of it. The limitations above are what survived that pass. -Older releases work too, back to at least SOLIDWORKS 2016 SP3, where every tool -was exercised the same way on a German install. Where a release lacks the newest +新增 28 个工具使用 `tests/live_expansion_regression.py` 验证。该脚本只修改新建测试文档, +结束时关闭它们并恢复原活动文档;装配体引用的测试零件保存在输出根目录下 +`.expansion-tests//`。验证包括缩放体积、旋转包围盒、布尔运算体积、 +曲面面积、螺旋线高度、配置、属性、方程以及组件状态的读取结果。 + +```powershell +python tests/live_expansion_regression.py +``` + +The original 92 tools were also exercised on SOLIDWORKS 2016 SP3 with a German +install. The 28 expansion tools have been checked on SOLIDWORKS 2026 SP3.2; +their behaviour on older releases has not yet been verified. Where a release lacks the newest numbered method (`FeatureCut4`, `FeatureLinearPattern5`, `CreateDetailViewAt4`, ...) the tool falls back to the earlier variant with the arguments it takes; on newer releases the newest name is always tried first, so nothing changes there. @@ -316,6 +356,9 @@ newer releases the newest name is always tried first, so nothing changes there. | `sw_assembly.py` | Components and mates | | `sw_drawing.py` | Sheets, views, model items, dimensions, center marks, notes | | `sw_demo.py` | Basketball demo, opt-in via `SW_MCP_DEMO_TOOLS` | +| `sw_manage.py` | 文档切换、配置、自定义属性、方程、材质库搜索 | +| `sw_multibody.py` | 多实体操作、曲面、螺旋线和曲面面积查询 | +| `tests/live_expansion_regression.py` | 新增工具的实机回归与测试文档清理 | | `tests/live_p0_regression.py` | Live regression checks for the confirmed P0 part/sketch defects | | `tools/tlb_probe.py` | Reads signatures and enums straight off your installed type library | | `server.py` | Registry assembly and stdio dispatch | diff --git a/solidworks_mcp/server.py b/solidworks_mcp/server.py index 02f0a95..8377715 100644 --- a/solidworks_mcp/server.py +++ b/solidworks_mcp/server.py @@ -59,6 +59,8 @@ from . import sw_assembly # noqa: F401 from . import sw_drawing # noqa: F401 from . import sw_demo # noqa: F401 +from . import sw_manage # noqa: F401 +from . import sw_multibody # noqa: F401 server = Server("solidworks-mcp") diff --git a/solidworks_mcp/sw_assembly.py b/solidworks_mcp/sw_assembly.py index e55a4ef..e550f3e 100644 --- a/solidworks_mcp/sw_assembly.py +++ b/solidworks_mcp/sw_assembly.py @@ -32,6 +32,7 @@ clear_selection, component_transform, document_info, + flag_methods, iter_components, logger, mm_point, @@ -47,6 +48,7 @@ to_mm, tool, value, + whats_wrong, ) @@ -240,3 +242,55 @@ def set_component_fixed(args: dict[str, Any]) -> dict[str, Any]: clear_selection(doc) rebuild(doc) return result(True, f"Set fixed={args.get('fixed', True)} on {len(names)} components.", components=names) + + +def _named_component(doc: Any, name: str) -> Any: + for component in iter_components(doc): + if str(safe(component, "Name2", "")) == name: + return component + raise RuntimeError(f"No component named '{name}'. Use list_components first.") + + +def _component_result(doc: Any, ok: bool, message: str, **data: Any) -> dict[str, Any]: + rebuild(doc) + problems = whats_wrong(doc) + return result(ok and not problems, message, problems=problems, **data) + + +@tool("set_component_visibility", "Show or hide a named assembly component without suppressing it.", + {"name": {"type": "string"}, "visible": {"type": "boolean"}}, ["name", "visible"]) +def set_component_visibility(args: dict[str, Any]) -> dict[str, Any]: + _, doc = require_assembly() + component = _named_component(doc, str(args["name"])) + desired = 1 if args["visible"] else 0 + component.Visible = desired + actual = int(value(component, "Visible")) + return _component_result(doc, actual == desired, "Updated component visibility.", name=args["name"], visible=actual == 1) + + +@tool("set_component_suppression", "Suppress or fully resolve a named assembly component in the active configuration.", + {"name": {"type": "string"}, "suppressed": {"type": "boolean"}}, ["name", "suppressed"]) +def set_component_suppression(args: dict[str, Any]) -> dict[str, Any]: + _, doc = require_assembly() + component = flag_methods(_named_component(doc, str(args["name"])), "SetSuppression2") + code = int(component.SetSuppression2(0 if args["suppressed"] else 2)) + actual = bool(value(component, "IsSuppressed")) + return _component_result(doc, actual == bool(args["suppressed"]), "Updated component suppression.", name=args["name"], suppressed=actual, status=code) + + +@tool("set_component_configuration", "Switch a component to an existing configuration in its referenced part/subassembly, then rebuild.", + {"name": {"type": "string"}, "configuration": {"type": "string", "minLength": 1}}, ["name", "configuration"]) +def set_component_configuration(args: dict[str, Any]) -> dict[str, Any]: + _, doc = require_assembly() + component = _named_component(doc, str(args["name"])) + model = value(component, "GetModelDoc2") + if model is None: + return result(False, "Resolve the component before changing its configuration.") + name = str(args["configuration"]) + names = [str(n) for n in value(model, "GetConfigurationNames") or []] + if name not in names: + return result(False, "Referenced configuration does not exist.", available_configurations=names) + component.ReferencedConfiguration = name + rebuild(doc) + actual = str(value(component, "ReferencedConfiguration")) + return _component_result(doc, actual == name, "Updated component configuration.", name=args["name"], configuration=actual) diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index ebe6f0a..1ec2a99 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -722,6 +722,14 @@ def select_object(doc: Any, obj: Any, mark: int = 0, append: bool = True) -> boo return False +def select_body(doc: Any, body: Any, mark: int = 0, append: bool = True) -> bool: + """IBody2.Select2 takes ISelectData, unlike IFeature.Select2's integer mark.""" + selection_manager = flag_methods(doc.SelectionManager, "CreateSelectData") + data = selection_manager.CreateSelectData() + data.Mark = mark + return bool(flag_methods(body, "Select2").Select2(append, data)) + + def resolve_plane_name(doc: Any, name: str, planes: Sequence[str] | None = None) -> str: """Accept either an exact localized plane name or front/top/right. @@ -1397,7 +1405,7 @@ def apply_selection(doc: Any, spec: dict[str, Any] | None, mark: int = 0, append index = int(raw_index) if not 0 <= index < len(bodies): raise RuntimeError(f"Body index {index} is out of range (0..{len(bodies) - 1}).") - if not select_object(doc, bodies[index], mark, True): + if not select_body(doc, bodies[index], mark, True): raise RuntimeError(f"Could not select body {index}.") count += 1 diff --git a/solidworks_mcp/sw_manage.py b/solidworks_mcp/sw_manage.py new file mode 100644 index 0000000..5ca5401 --- /dev/null +++ b/solidworks_mcp/sw_manage.py @@ -0,0 +1,326 @@ +# Copyright 2026 JIALE LIU +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Documents, configurations, custom properties, equations and material discovery.""" + +from pathlib import Path +from typing import Any +import xml.etree.ElementTree as ET + +import pythoncom +import win32com.client + +from .sw_core import ( + active_document, as_list, byref_long, document_info, document_type, + extension, flag_methods, rebuild, result, running_app, safe, tool, + value, whats_wrong, +) + +NAME = {"type": "string", "minLength": 1} +CONFIG = {"type": "string", "default": "", "description": "Empty means document-level properties; otherwise an existing configuration name."} +PROPERTY_TYPES = {"text": 30, "number": 3, "date": 64, "yes_or_no": 11} + + +def _nonempty(raw: Any, label: str) -> str: + name = str(raw).strip() + if not name: + raise RuntimeError(f"{label} must not be empty.") + return name + + +def _changed(doc: Any, message: str, **data: Any) -> dict[str, Any]: + rebuild(doc) + problems = whats_wrong(doc) + return result(not problems, message, problems=problems, **data) + + +@tool("list_open_documents", "Read-only: list all open documents, including unsaved state and the active title.") +def list_open_documents(args): + app = running_app() + active = app.ActiveDoc + return result(True, "Read open documents.", documents=[document_info(d) for d in as_list(value(app, "GetDocuments"))], + active_title=str(value(active, "GetTitle")) if active is not None else None) + + +def _open_doc(app, name): + for doc in as_list(value(app, "GetDocuments")): + if name in (str(value(doc, "GetTitle")), str(value(doc, "GetPathName") or "")): + return doc + raise RuntimeError(f"No open document matches '{name}'. Use list_open_documents first.") + + +@tool("activate_document", "Activate an already-open document by its exact title or full path.", {"name": NAME}, ["name"]) +def activate_document(args): + app = running_app() + doc = _open_doc(app, str(args["name"])) + errors = byref_long() + app.ActivateDoc3(str(value(doc, "GetTitle")), False, 0, errors) + active = app.ActiveDoc + ok = active is not None and str(value(active, "GetTitle")) == str(value(doc, "GetTitle")) + return result(ok, "Activated document." if ok else "Document activation failed.", error_code=int(errors.value)) + + +@tool("close_document", "Close an open document without saving. Refuses unsaved changes unless discard_changes is explicitly true.", + {"name": NAME, "discard_changes": {"type": "boolean", "default": False}}, ["name"]) +def close_document(args): + app = running_app() + doc = _open_doc(app, str(args["name"])) + info = document_info(doc) + if info["dirty"] and not args.get("discard_changes", False): + return result(False, "Document has unsaved changes. Save it or explicitly set discard_changes=true.", document=info) + app.CloseDoc(info["title"]) + remaining = [str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))] + return result(info["title"] not in remaining, "Requested document close.", document=info) + + +def _configuration_doc(): + _, doc = active_document() + if document_type(doc) not in (1, 2): + raise RuntimeError("Configurations require a part or assembly document.") + return flag_methods(doc, "GetConfigurationByName", "ShowConfiguration2", "AddConfiguration3", "DeleteConfiguration2") + + +def _config_names(doc): + return [str(n) for n in as_list(value(doc, "GetConfigurationNames"))] + + +def _config(doc, name): + if name not in _config_names(doc): + raise RuntimeError(f"No configuration named '{name}'.") + return doc.GetConfigurationByName(name) + + +@tool("list_configurations", "Read-only: list configurations and identify the active configuration.") +def list_configurations(args): + doc = _configuration_doc() + active = doc.ConfigurationManager.ActiveConfiguration + configs = [] + for name in _config_names(doc): + cfg = _config(doc, name) + configs.append({"name": name, "description": str(safe(cfg, "Description", "")), "active": name == str(value(active, "Name"))}) + return result(True, "Read configurations.", configurations=configs) + + +@tool("activate_configuration", "Switch the active part or assembly configuration and rebuild.", {"name": NAME}, ["name"]) +def activate_configuration(args): + doc = _configuration_doc() + name = str(args["name"]) + _config(doc, name) + doc.ShowConfiguration2(name) + actual = str(value(doc.ConfigurationManager.ActiveConfiguration, "Name")) + if actual != name: + return result(False, "Configuration activation failed.", active_configuration=actual) + return _changed(doc, "Activated configuration.", active_configuration=actual) + + +@tool("create_configuration", "Create and activate a configuration inheriting current document state.", + {"name": NAME, "description": {"type": "string", "default": ""}}, ["name"]) +def create_configuration(args): + doc = _configuration_doc() + name = _nonempty(args["name"], "Configuration name") + if name in _config_names(doc): + return result(False, "Configuration already exists.") + doc.AddConfiguration3(name, str(args.get("description", "")), "", 0) + if name not in _config_names(doc): + return result(False, "SOLIDWORKS did not create the configuration.") + _config(doc, name).Description = str(args.get("description", "")) + return _changed(doc, "Created configuration.", name=name) + + +@tool("delete_configuration", "Delete a named inactive configuration. Refuses the active or last configuration.", {"name": NAME}, ["name"]) +def delete_configuration(args): + doc = _configuration_doc() + name = str(args["name"]) + _config(doc, name) + active = str(value(doc.ConfigurationManager.ActiveConfiguration, "Name")) + if name == active or len(_config_names(doc)) <= 1: + return result(False, "Activate another configuration before deleting this one.") + doc.DeleteConfiguration2(name) + if name in _config_names(doc): + return result(False, "SOLIDWORKS did not delete the configuration.") + return _changed(doc, "Deleted configuration.", name=name) + + +def _property_manager(doc, configuration): + if configuration: + flag_methods(doc, "GetConfigurationByName") + _config(doc, configuration) + ext = flag_methods(extension(doc), "CustomPropertyManager") + return flag_methods(ext.CustomPropertyManager(configuration), "Get6", "GetType2", "Add3", "Set2", "Delete2") + + +def _property(manager, name): + if name not in [str(n) for n in as_list(value(manager, "GetNames"))]: + raise RuntimeError(f"No custom property named '{name}'.") + raw = win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_BSTR, "") + resolved = win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_BSTR, "") + was_resolved = win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_BOOL, False) + linked = win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_BOOL, False) + manager.Get6(name, False, raw, resolved, was_resolved, linked) + code = int(manager.GetType2(name)) + return {"name": name, "value": str(raw.value), "resolved_value": str(resolved.value), + "type": next((k for k, v in PROPERTY_TYPES.items() if v == code), str(code)), "linked": bool(linked.value)} + + +@tool("list_custom_properties", "Read-only: list raw and resolved custom properties, optionally for a configuration.", {"configuration": CONFIG}) +def list_custom_properties(args): + _, doc = active_document() + manager = _property_manager(doc, str(args.get("configuration", ""))) + return result(True, "Read custom properties.", properties=[_property(manager, str(n)) for n in as_list(value(manager, "GetNames"))]) + + +@tool("get_custom_property", "Read-only: read one custom property with its resolved value and type.", {"name": NAME, "configuration": CONFIG}, ["name"]) +def get_custom_property(args): + _, doc = active_document() + return result(True, "Read custom property.", property=_property(_property_manager(doc, str(args.get("configuration", ""))), str(args["name"]))) + + +@tool("set_custom_property", "Add or replace a custom property, then read it back. Configuration may be omitted for document-level properties.", + {"name": NAME, "value": {"type": "string"}, "type": {"type": "string", "enum": list(PROPERTY_TYPES), "default": "text"}, "configuration": CONFIG}, ["name", "value"]) +def set_custom_property(args): + _, doc = active_document() + manager = _property_manager(doc, str(args.get("configuration", ""))) + name = _nonempty(args["name"], "Property name") + text = str(args["value"]) + status = int(manager.Add3(name, PROPERTY_TYPES[str(args.get("type", "text"))], text, 1)) + if status != 0: + return result(False, "SOLIDWORKS rejected the custom property.", status=status) + prop = _property(manager, name) + return _changed(doc, "Set custom property.", property=prop) if prop["value"] == text else result(False, "Property readback differs.", property=prop) + + +@tool("delete_custom_property", "Delete a named document-level or configuration-specific custom property and verify removal.", {"name": NAME, "configuration": CONFIG}, ["name"]) +def delete_custom_property(args): + _, doc = active_document() + manager = _property_manager(doc, str(args.get("configuration", ""))) + name = str(args["name"]) + _property(manager, name) + status = int(manager.Delete2(name)) + if name in [str(n) for n in as_list(value(manager, "GetNames"))]: + return result(False, "Custom property was not removed.", status=status) + return _changed(doc, "Deleted custom property.", name=name, status=status) + + +def _equation_manager(doc): + if document_type(doc) not in (1, 2): + raise RuntimeError("Equations require a part or assembly.") + return flag_methods(value(doc, "GetEquationMgr"), "Equation", "Value", "GlobalVariable", "Add2", "Delete") + + +def _equations(manager): + entries = [] + for i in range(int(value(manager, "GetCount"))): + equation = str(manager.Equation(i)) + evaluated = float(manager.Value(i)) + status = int(value(manager, "Status")) + entries.append({"index": i, "equation": equation, "value": evaluated, + "status": status, "global_variable": bool(manager.GlobalVariable(i))}) + return entries + + +def _equation_result(doc, manager, message, **data): + value(manager, "EvaluateAll") + entries = _equations(manager) + payload = _changed(doc, message, equations=entries, **data) + if any(entry["status"] != 0 for entry in entries): + payload.update(ok=False, message="SOLIDWORKS reports an equation error; inspect equation status.") + return payload + + +def _eq_index(manager, raw): + index = int(raw) + if index < 0 or index >= int(value(manager, "GetCount")): + raise RuntimeError("Equation index is out of range. Use list_equations first.") + return index + + +@tool("list_equations", "Read-only: list equations/global variables with current indices and evaluated values.") +def list_equations(args): + _, doc = active_document() + return result(True, "Read equations.", equations=_equations(_equation_manager(doc))) + + +@tool("add_equation", 'Append an equation/global variable, e.g. "Width" = 25mm. Uses SOLIDWORKS equation syntax.', {"equation": NAME}, ["equation"]) +def add_equation(args): + _, doc = active_document() + manager = _equation_manager(doc) + before = int(value(manager, "GetCount")) + index = int(manager.Add2(-1, _nonempty(args["equation"], "Equation"), True)) + if index < 0 or int(value(manager, "GetCount")) != before + 1: + return result(False, "SOLIDWORKS rejected the equation.", index=index) + return _equation_result(doc, manager, "Added equation.", index=index) + + +@tool("set_equation", "Replace an equation by current index, preserving its existing configuration scope, and solve it.", {"index": {"type": "integer", "minimum": 0}, "equation": NAME}, ["index", "equation"]) +def set_equation(args): + _, doc = active_document() + manager = _equation_manager(doc) + index = _eq_index(manager, args["index"]) + text = _nonempty(args["equation"], "Equation") + # Indexed PROPSET needs an explicit dispatch invocation: Equation(i) is + # a flagged PROPGET, and ordinary Python assignment cannot carry i. + dispid = manager._oleobj_.GetIDsOfNames("Equation") + manager._oleobj_.Invoke(dispid, 0, pythoncom.DISPATCH_PROPERTYPUT, 0, index, text) + value(manager, "EvaluateAll") + actual = str(manager.Equation(index)) + if actual != text: + return result(False, "Equation readback differs.", equation=actual) + return _equation_result(doc, manager, "Updated equation.") + + +@tool("delete_equation", "Delete an equation/global variable by index. Re-list afterwards because indices shift.", {"index": {"type": "integer", "minimum": 0}}, ["index"]) +def delete_equation(args): + _, doc = active_document() + manager = _equation_manager(doc) + index = _eq_index(manager, args["index"]) + before = int(value(manager, "GetCount")) + manager.Delete(index) + if int(value(manager, "GetCount")) != before - 1: + return result(False, "Equation was not deleted.") + return _equation_result(doc, manager, "Deleted equation.") + + +@tool("evaluate_equations", "Solve all equations and rebuild the active part or assembly, reporting equation status.") +def evaluate_equations(args): + _, doc = active_document() + manager = _equation_manager(doc) + return _equation_result(doc, manager, "Evaluated equations.") + + +@tool("list_materials", "Read-only: search actual installed material databases for exact names usable by set_material. Returns database paths.", + {"query": {"type": "string", "default": ""}, "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}}) +def list_materials(args): + app = running_app() + paths = [Path(str(p)) for p in as_list(value(app, "GetMaterialDatabases"))] + query = str(args.get("query", "")).casefold() + limit = int(args.get("limit", 100)) + if not 1 <= limit <= 500: + raise RuntimeError("limit must be between 1 and 500.") + matches, failures = [], [] + total = 0 + for path in paths: + try: + root = ET.parse(path).getroot() + except (OSError, ET.ParseError) as exc: + failures.append({"database": str(path), "error": str(exc)}) + continue + for node in root.iter(): + if node.tag.rsplit("}", 1)[-1].lower() != "material": + continue + name = node.get("name", "") + if name and query in name.casefold(): + total += 1 + if len(matches) < limit: + matches.append({"name": name, "database": str(path)}) + return result(not failures, "Read material databases." if not failures else "Some material databases could not be read.", materials=matches, total_matches=total, truncated=total > limit, failures=failures) diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py new file mode 100644 index 0000000..3b205c0 --- /dev/null +++ b/solidworks_mcp/sw_multibody.py @@ -0,0 +1,218 @@ +# Copyright 2026 JIALE LIU +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Multibody editing, surface construction and helix curves.""" + +import math +from .sw_core import ( + BODY_SHEET, dispatch_array, + feature_manager, feature_result, flag_methods, get_bodies, nothing, + rename_feature, require_part, require_selection, result, + select_sketch_for_feature, to_m, to_rad, tool, as_list, value, +) +from .sw_feature import _feature_created_after, _feature_names + +BODY_SELECTION = {"type": "object", "properties": {"bodies": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True}}, "required": ["bodies"], "additionalProperties": False} +NAME = {"type": "string"} +NUM = {"type": "number", "default": 0} + + +def _positive(raw, label): + number = float(raw) + if not math.isfinite(number) or number <= 0: + raise RuntimeError(f"{label} must be finite and positive.") + return number + + +def _selected_bodies(doc, selection, minimum=1, mark=0): + indices = selection.get("bodies", []) + if len(indices) < minimum or len(set(indices)) != len(indices): + raise RuntimeError(f"Select at least {minimum} distinct solid bodies from list_bodies.") + if set(selection) != {"bodies"}: + raise RuntimeError("This tool accepts only selection.bodies.") + bodies = get_bodies(doc) + if any(not isinstance(i, int) or i < 0 or i >= len(bodies) for i in indices): + raise RuntimeError("Body index is out of range. Use list_bodies again.") + require_selection(doc, selection, mark=mark) + return [bodies[i] for i in indices] + + +def _finish(doc, feature, args, action, **data): + rename_feature(feature, args.get("name")) + return feature_result(doc, feature, action, **data) + + +@tool("scale_bodies", "Scale selected solid bodies uniformly or along X/Y/Z about their centroid or the model origin. Re-list topology afterwards.", + {"selection": BODY_SELECTION, "factor": {"type": "number", "exclusiveMinimum": 0, "default": 1}, + "uniform": {"type": "boolean", "default": True}, "y_factor": {"type": "number", "exclusiveMinimum": 0, "default": 1}, + "z_factor": {"type": "number", "exclusiveMinimum": 0, "default": 1}, "about": {"type": "string", "enum": ["centroid", "origin"], "default": "centroid"}, "name": NAME}, ["selection"]) +def scale_bodies(args): + x = _positive(args.get("factor", 1), "factor") + uniform = bool(args.get("uniform", True)) + y = x if uniform else _positive(args.get("y_factor", 1), "y_factor") + z = x if uniform else _positive(args.get("z_factor", 1), "z_factor") + about = {"centroid": 0, "origin": 1}[args.get("about", "centroid")] + _, doc = require_part() + _selected_bodies(doc, args["selection"]) + manager = flag_methods(feature_manager(doc), "InsertScale") + return _finish(doc, manager.InsertScale(about, uniform, x, y, z), args, "body scale", factors=[x, y, z]) + + +@tool("move_copy_bodies", "Translate OR rotate selected solid bodies, optionally copying them. Use separate calls for translation and rotation. Lengths in mm, rotations in degrees around the supplied point. Copies must be >=1 when copy=true.", + {"selection": BODY_SELECTION, "x_mm": NUM, "y_mm": NUM, "z_mm": NUM, + "rotation_x_deg": NUM, "rotation_y_deg": NUM, "rotation_z_deg": NUM, + "rotation_point_x_mm": NUM, "rotation_point_y_mm": NUM, "rotation_point_z_mm": NUM, + "copy": {"type": "boolean", "default": False}, "copies": {"type": "integer", "minimum": 1, "maximum": 100, "default": 1}, "name": NAME}, ["selection"]) +def move_copy_bodies(args): + copies = int(args.get("copies", 1)) + if not 1 <= copies <= 100: + raise RuntimeError("copies must be between 1 and 100.") + translations = [float(args.get(k, 0)) for k in ("x_mm", "y_mm", "z_mm")] + angles = [float(args.get(k, 0)) for k in ("rotation_x_deg", "rotation_y_deg", "rotation_z_deg")] + point = [float(args.get(k, 0)) for k in ("rotation_point_x_mm", "rotation_point_y_mm", "rotation_point_z_mm")] + if not all(math.isfinite(v) for v in translations + angles + point): + raise RuntimeError("Translations, rotation angles and rotation points must be finite.") + if any(translations) and any(angles): + raise RuntimeError("Use separate calls for translation and rotation; SOLIDWORKS ignores rotation when translation is also specified.") + _, doc = require_part() + selected = _selected_bodies(doc, args["selection"], mark=1) + before = len(get_bodies(doc)) + manager = flag_methods(feature_manager(doc), "InsertMoveCopyBody2") + feature = manager.InsertMoveCopyBody2(*(to_m(v) for v in translations), 0.0, + *(to_m(v) for v in point), *(to_rad(v) for v in angles), bool(args.get("copy", False)), copies) + if feature is not None and any(angles): + # SW2026 maps the InsertMoveCopyBody2 angle arguments to different + # axes than their typelib names. Set the definition's explicit XYZ + # properties instead of relying on the positional angle mapping. + definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") + if not definition.AccessSelections(doc, nothing()): + return result(False, "Cannot access move/copy definition to verify rotation.") + modified = False + try: + definition.TransformX, definition.TransformY, definition.TransformZ = [to_rad(v) for v in angles] + definition.RotationOriginX, definition.RotationOriginY, definition.RotationOriginZ = [to_m(v) for v in point] + modified = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(definition, doc, nothing())) + finally: + if not modified: + value(definition, "ReleaseSelectionAccess") + if not modified: + return result(False, "SOLIDWORKS did not accept the requested rotation definition.") + actual = value(feature, "GetDefinition") + readback = [float(value(actual, name)) for name in ("TransformX", "TransformY", "TransformZ")] + if not all(math.isclose(a, to_rad(b), abs_tol=1e-9) for a, b in zip(readback, angles)): + return result(False, "Rotation definition readback differs from the requested axes.") + payload = _finish(doc, feature, args, "body move/copy") + after = len(get_bodies(doc)) + payload.setdefault("data", {}).update(body_count=after) + expected = before + len(selected) * copies if args.get("copy", False) else before + if payload["ok"] and after != expected: + payload.update(ok=False, message="Body move/copy feature exists, but body count differs from the requested result.") + return payload + + +@tool("combine_bodies", "Boolean add, subtract or intersect at least two solid bodies. For subtract, the first body is the main body and the rest are tools.", + {"selection": BODY_SELECTION, "operation": {"type": "string", "enum": ["add", "subtract", "intersect"]}, "name": NAME}, ["selection", "operation"]) +def combine_bodies(args): + _, doc = require_part() + bodies = _selected_bodies(doc, args["selection"], 2) + operation = str(args["operation"]) + code = {"add": 15903, "subtract": 15902, "intersect": 15901}[operation] + main = bodies[0] if operation == "subtract" else nothing() + tools = bodies[1:] if operation == "subtract" else bodies + manager = flag_methods(feature_manager(doc), "InsertCombineFeature") + return _finish(doc, manager.InsertCombineFeature(code, main, dispatch_array(tools)), args, "body combination", operation=operation) + + +@tool("delete_bodies", "Create a Delete/Keep Body feature using solid body indices. keep_selected=true retains only selected bodies; otherwise deletes them.", + {"selection": BODY_SELECTION, "keep_selected": {"type": "boolean", "default": False}, "name": NAME}, ["selection"]) +def delete_bodies(args): + _, doc = require_part() + selected = _selected_bodies(doc, args["selection"]) + before = len(get_bodies(doc)) + keep = bool(args.get("keep_selected", False)) + manager = flag_methods(feature_manager(doc), "InsertDeleteBody2") + payload = _finish(doc, manager.InsertDeleteBody2(keep), args, "delete/keep body") + expected = len(selected) if keep else before - len(selected) + if payload["ok"] and len(get_bodies(doc)) != expected: + payload.update(ok=False, message="Delete/keep body count differs from the requested result.") + return payload + + +@tool("surface_extrude", "Extrude an open or closed sketch into a surface body (no solid volume). Blind depth in mm; reverse flips direction.", + {"depth_mm": {"type": "number", "exclusiveMinimum": 0}, "sketch_name": NAME, "reverse": {"type": "boolean", "default": False}, "name": NAME}, ["depth_mm"]) +def surface_extrude(args): + depth = to_m(_positive(args["depth_mm"], "depth_mm")) + _, doc = require_part() + sketch = select_sketch_for_feature(doc, args.get("sketch_name")) + before = len(get_bodies(doc, BODY_SHEET)) + previous_features = _feature_names(doc) + manager = flag_methods(feature_manager(doc), "FeatureExtruRefSurface3") + manager.FeatureExtruRefSurface3(True, bool(args.get("reverse", False)), 0, 0.0, + 0, 0, depth, depth, False, False, False, False, 0.0, 0.0, + False, False, False, False, False, False, False, False) + feature = _feature_created_after(doc, previous_features) + payload = _finish(doc, feature, args, "extruded surface", sketch=sketch) + if payload["ok"] and len(get_bodies(doc, BODY_SHEET)) <= before: + payload.update(ok=False, message="Extruded surface feature did not add a surface body.") + return payload + + +@tool("list_surface_bodies", "Read-only: list surface bodies of the active part with names, face counts and areas in square millimetres. Solid-body indices are separate.") +def list_surface_bodies(args): + _, doc = require_part() + entries = [] + for index, body in enumerate(get_bodies(doc, BODY_SHEET)): + faces = as_list(value(body, "GetFaces")) + entries.append({"index": index, "name": str(value(body, "Name")), "face_count": len(faces), + "area_mm2": sum(float(value(face, "GetArea")) for face in faces) * 1e6}) + return result(True, "Read surface bodies.", surface_bodies=entries) + + +@tool("planar_surface", "Create a planar surface from a closed planar sketch. Produces a surface body rather than a solid.", + {"sketch_name": NAME, "name": NAME}) +def planar_surface(args): + _, doc = require_part() + sketch = select_sketch_for_feature(doc, args.get("sketch_name")) + before = _feature_names(doc) + count = len(get_bodies(doc, BODY_SHEET)) + flag_methods(doc, "InsertPlanarRefSurface").InsertPlanarRefSurface() + feature = _feature_created_after(doc, before) + payload = _finish(doc, feature, args, "planar surface", sketch=sketch) + if payload["ok"] and len(get_bodies(doc, BODY_SHEET)) <= count: + payload.update(ok=False, message="Planar surface feature did not add a surface body.") + return payload + + +@tool("create_helix", "Create a constant-pitch helix from a sketch containing one circle. Defined by pitch in mm and revolutions; angles are degrees.", + {"sketch_name": NAME, "pitch_mm": {"type": "number", "exclusiveMinimum": 0}, "revolutions": {"type": "number", "exclusiveMinimum": 0}, + "start_angle_deg": NUM, "clockwise": {"type": "boolean", "default": True}, "reverse": {"type": "boolean", "default": False}, "name": NAME}, ["pitch_mm", "revolutions"]) +def create_helix(args): + pitch = _positive(args["pitch_mm"], "pitch_mm") + turns = _positive(args["revolutions"], "revolutions") + _, doc = require_part() + select_sketch_for_feature(doc, args.get("sketch_name")) + before = _feature_names(doc) + flag_methods(doc, "InsertHelix").InsertHelix(bool(args.get("reverse", False)), bool(args.get("clockwise", True)), False, False, + 0, to_m(pitch * turns), to_m(pitch), turns, 0.0, to_rad(args.get("start_angle_deg", 0))) + feature = _feature_created_after(doc, before) + payload = _finish(doc, feature, args, "helix") + if payload["ok"]: + definition = value(feature, "GetDefinition") + actual_pitch = float(value(definition, "Pitch")) * 1000 + actual_turns = float(value(definition, "Revolution")) + height = float(value(definition, "Height")) * 1000 + payload["data"].update(pitch_mm=actual_pitch, revolutions=actual_turns, height_mm=height) + if not (math.isclose(actual_pitch, pitch, rel_tol=1e-6) and math.isclose(actual_turns, turns, rel_tol=1e-6)): + payload.update(ok=False, message="Helix feature exists, but pitch/revolution readback differs.") + return payload diff --git a/tests/live_expansion_regression.py b/tests/live_expansion_regression.py new file mode 100644 index 0000000..450a3af --- /dev/null +++ b/tests/live_expansion_regression.py @@ -0,0 +1,217 @@ +# Copyright 2026 JIALE LIU +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Live checks for expanded tools; creates scratch documents and restores originals. + +Run with SOLIDWORKS already open. Only newly-created documents are changed. +Saved assembly fixtures stay under SW_MCP_OUTPUT_ROOT/.expansion-tests/. +""" + +import json +import math +from pathlib import Path +import sys +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from solidworks_mcp import server +from solidworks_mcp.sw_core import ( + BODY_SHEET, HANDLERS, OUTPUT_ROOT, as_list, byref_long, get_bodies, + require_part, running_app, safe, value, +) + +CALLED = set() + + +def call(tool_name, **args): + payload = HANDLERS[tool_name](args) + if not payload["ok"]: + raise AssertionError(f"{tool_name}: {payload}") + CALLED.add(tool_name) + print(f"PASS {tool_name}", flush=True) + return payload.get("data", {}) + + +def volume(): + return call("get_mass_properties")["volume_mm3"] + + +def near(actual, expected, label): + if not math.isclose(actual, expected, rel_tol=1e-6, abs_tol=0.05): + raise AssertionError(f"{label}: actual={actual}, expected={expected}") + + +def block(): + title = call("create_new_document", kind="part")["document"]["title"] + call("create_sketch", plane="front", name="Base") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + call("close_sketch") + call("boss_extrude", sketch_name="Base", depth_mm=10) + near(volume(), 1000, "block volume") + return title + + +def test_management(): + title = block() + docs = call("list_open_documents") + assert title in {d["title"] for d in docs["documents"]} + call("activate_document", name=title) + default = next(c["name"] for c in call("list_configurations")["configurations"] if c["active"]) + call("create_configuration", name="MCP_Alternate", description="MCP regression") + call("activate_configuration", name=default) + call("delete_configuration", name="MCP_Alternate") + for scope in ("", default): + call("set_custom_property", name="MCP_Test", value="Fixture", configuration=scope) + read = call("get_custom_property", name="MCP_Test", configuration=scope)["property"] + assert read["value"] == "Fixture", read + listed = call("list_custom_properties", configuration=scope)["properties"] + assert any(p["name"] == "MCP_Test" for p in listed) + call("delete_custom_property", name="MCP_Test", configuration=scope) + assert not any(p["name"] == "MCP_Test" for p in call("list_custom_properties", configuration=scope)["properties"]) + added = call("add_equation", equation='"MCP_Width" = 25mm') + index = added["index"] + entries = call("list_equations")["equations"] + assert entries[index]["global_variable"], entries + call("set_equation", index=index, equation='"MCP_Width" = 30mm') + call("evaluate_equations") + entries = call("list_equations")["equations"] + assert "30" in entries[index]["equation"], entries + call("delete_equation", index=index) + assert not call("list_equations")["equations"] + materials = call("list_materials", query="", limit=2) + assert len(materials["materials"]) > 0, materials + exact = materials["materials"][0] + matched = call("list_materials", query=exact["name"], limit=500) + assert exact in matched["materials"], matched + refused = HANDLERS["close_document"]({"name": title}) + assert not refused["ok"], refused + call("close_document", name=title, discard_changes=True) + + +def test_multibody(): + block() + call("scale_bodies", selection={"bodies": [0]}, factor=2) + near(volume(), 8000, "scaled volume") + bounds = call("get_bounding_box") + for actual in bounds["size_mm"]: + near(actual, 20, "uniform scale bounds") + block() + call("scale_bodies", selection={"bodies": [0]}, factor=2, uniform=False, y_factor=3, z_factor=4, about="origin") + near(volume(), 24000, "nonuniform scale volume") + block() + call("scale_bodies", selection={"bodies": [0]}, factor=1, uniform=False, y_factor=2) + call("move_copy_bodies", selection={"bodies": [0]}, rotation_z_deg=90) + bounds = call("get_bounding_box") + near(bounds["size_mm"][0], 20, "rotated X size") + near(bounds["size_mm"][1], 10, "rotated Y size") + near(volume(), 2000, "rotation volume") + block() + call("move_copy_bodies", selection={"bodies": [0]}, x_mm=5, copy=True) + assert len(call("list_bodies")["bodies"]) == 2 + near(volume(), 2000, "copied volume") + call("combine_bodies", selection={"bodies": [0, 1]}, operation="add") + near(volume(), 1500, "union volume") + block() + call("move_copy_bodies", selection={"bodies": [0]}, x_mm=5, copy=True) + call("combine_bodies", selection={"bodies": [0, 1]}, operation="subtract") + near(volume(), 500, "subtraction volume") + block() + call("move_copy_bodies", selection={"bodies": [0]}, x_mm=5, copy=True) + call("combine_bodies", selection={"bodies": [0, 1]}, operation="intersect") + near(volume(), 500, "intersection volume") + block() + call("move_copy_bodies", selection={"bodies": [0]}, x_mm=20, copy=True, copies=2) + call("delete_bodies", selection={"bodies": [1]}) + near(volume(), 2000, "delete-body volume") + call("delete_bodies", selection={"bodies": [0]}, keep_selected=True) + near(volume(), 1000, "keep-body volume") + + +def test_surfaces_and_helix(): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="SurfaceLine") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=0) + call("close_sketch") + call("surface_extrude", sketch_name="SurfaceLine", depth_mm=20) + near(sum(b["area_mm2"] for b in call("list_surface_bodies")["surface_bodies"]), 200, "surface tool area") + _, doc = require_part() + bodies = get_bodies(doc, BODY_SHEET) + near(sum(float(value(face, "GetArea")) for b in bodies for face in as_list(value(b, "GetFaces"))) * 1e6, 200, "surface area") + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="SurfaceRectangle") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + call("close_sketch") + call("planar_surface", sketch_name="SurfaceRectangle") + _, doc = require_part() + near(sum(float(value(face, "GetArea")) for b in get_bodies(doc, BODY_SHEET) for face in as_list(value(b, "GetFaces"))) * 1e6, 100, "planar area") + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="HelixCircle") + call("draw_circle", x_mm=0, y_mm=0, radius_mm=5) + call("close_sketch") + helix = call("create_helix", sketch_name="HelixCircle", pitch_mm=2, revolutions=3) + near(helix["height_mm"], 6, "helix height") + features = call("list_features")["features"] + assert any(f["type"] == "Helix" for f in features), features + + +def test_components(): + part_title = block() + default = next(c["name"] for c in call("list_configurations")["configurations"] if c["active"]) + call("create_configuration", name="MCP_Alternate") + call("activate_configuration", name=default) + fixture = OUTPUT_ROOT / ".expansion-tests" / uuid.uuid4().hex / "block.SLDPRT" + call("save_document", path=str(fixture)) + assembly_title = call("create_new_document", kind="assembly")["document"]["title"] + inserted = call("insert_component", path=str(fixture)) + call("activate_document", name=assembly_title) + name = inserted["component"] + call("set_component_visibility", name=name, visible=False) + call("set_component_visibility", name=name, visible=True) + call("set_component_configuration", name=name, configuration="MCP_Alternate") + call("set_component_suppression", name=name, suppressed=True) + call("set_component_suppression", name=name, suppressed=False) + + +def main(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + test_management() + test_multibody() + test_surfaces_and_helix() + test_components() + expected = {t.name for t in server.TOOLS if HANDLERS[t.name].__module__ in + ("solidworks_mcp.sw_manage", "solidworks_mcp.sw_multibody")} | { + "set_component_visibility", "set_component_suppression", "set_component_configuration"} + assert expected <= CALLED, expected - CALLED + print(f"Expanded tools verified: {len(expected)}; total tools: {len(server.TOOLS)}", flush=True) + finally: + # Assemblies first, so their referenced scratch parts can also close. + docs = as_list(value(app, "GetDocuments")) + docs.sort(key=lambda d: int(value(d, "GetType")) != 2) + for doc in docs: + title = str(value(doc, "GetTitle")) + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + print("Scratch documents closed; original documents restored.", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/test_expansion.py b/tests/test_expansion.py new file mode 100644 index 0000000..d8ebabd --- /dev/null +++ b/tests/test_expansion.py @@ -0,0 +1,125 @@ +# Copyright 2026 JIALE LIU +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Offline regressions for expanded tool boundaries and result verification.""" + +from pathlib import Path +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from solidworks_mcp import server, sw_core, sw_manage, sw_multibody + + +class BodySelectionTests(unittest.TestCase): + def test_body_select_uses_select_data_not_an_integer(self): + body, doc, data = Mock(), Mock(), Mock() + doc.SelectionManager.CreateSelectData.return_value = data + body.Select2.return_value = True + self.assertTrue(sw_core.select_body(doc, body, mark=7, append=False)) + self.assertEqual(data.Mark, 7) + body.Select2.assert_called_once_with(False, data) + + def test_invalid_indices_do_not_change_selection(self): + body = Mock() + for spec in ({"bodies": [-1]}, {"bodies": [1]}, {"bodies": [0, 0]}, {"bodies": []}, {"bodies": [0], "faces": [1]}): + with self.subTest(spec=spec), patch.object(sw_multibody, "get_bodies", return_value=[body]), \ + patch.object(sw_multibody, "require_selection") as select: + with self.assertRaises(RuntimeError): + sw_multibody._selected_bodies(Mock(), spec) + select.assert_not_called() + + def test_scale_rejects_nonpositive_and_nonfinite_factors_before_com(self): + for factor in (0, -1, float("nan"), float("inf")): + with self.subTest(factor=factor), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.scale_bodies({"selection": {"bodies": [0]}, "factor": factor}) + require.assert_not_called() + + def test_translation_and_rotation_cannot_be_silently_combined(self): + with patch.object(sw_multibody, "require_part") as require: + with self.assertRaisesRegex(RuntimeError, "separate calls"): + sw_multibody.move_copy_bodies({"selection": {"bodies": [0]}, "x_mm": 5, "rotation_z_deg": 90}) + require.assert_not_called() + + def test_move_uses_the_body_selection_mark_required_by_solidworks(self): + doc, body = Mock(), Mock() + with patch.object(sw_multibody, "require_part", return_value=(None, doc)), \ + patch.object(sw_multibody, "_selected_bodies", return_value=[body]) as selected, \ + patch.object(sw_multibody, "get_bodies", return_value=[body]), \ + patch.object(sw_multibody, "feature_manager"), \ + patch.object(sw_multibody, "_finish", return_value={"ok": True}): + self.assertTrue(sw_multibody.move_copy_bodies({"selection": {"bodies": [0]}, "x_mm": 5})["ok"]) + selected.assert_called_once_with(doc, {"bodies": [0]}, mark=1) + + +class ManagementTests(unittest.TestCase): + def test_dirty_document_close_requires_explicit_discard(self): + app, doc = Mock(), Mock() + with patch.object(sw_manage, "running_app", return_value=app), \ + patch.object(sw_manage, "_open_doc", return_value=doc), \ + patch.object(sw_manage, "document_info", return_value={"dirty": True, "title": "Part1"}): + self.assertFalse(sw_manage.close_document({"name": "Part1"})["ok"]) + app.CloseDoc.assert_not_called() + + def test_active_configuration_cannot_be_deleted(self): + doc = Mock() + doc.ConfigurationManager.ActiveConfiguration.Name = "Default" + with patch.object(sw_manage, "_configuration_doc", return_value=doc), \ + patch.object(sw_manage, "_config"), \ + patch.object(sw_manage, "_config_names", return_value=["Default", "Alt"]): + self.assertFalse(sw_manage.delete_configuration({"name": "Default"})["ok"]) + doc.DeleteConfiguration2.assert_not_called() + + def test_out_of_range_equation_indices(self): + manager = SimpleNamespace(GetCount=lambda: 2) + for index in (-1, 2, 100): + with self.subTest(index=index), self.assertRaises(RuntimeError): + sw_manage._eq_index(manager, index) + + def test_equation_error_is_not_reported_as_success(self): + manager = Mock() + with patch.object(sw_manage, "_equations", return_value=[{"index": 0, "status": -1}]), \ + patch.object(sw_manage, "_changed", return_value={"ok": True, "message": "done"}): + self.assertFalse(sw_manage._equation_result(Mock(), manager, "done")["ok"]) + + def test_material_search_supports_namespaces_and_truncation(self): + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "materials.sldmat" + path.write_text('', encoding="utf-8") + app = SimpleNamespace(GetMaterialDatabases=lambda: [str(path)]) + with patch.object(sw_manage, "running_app", return_value=app): + payload = sw_manage.list_materials({"query": "STEEL", "limit": 1}) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["data"]["total_matches"], 2) + self.assertTrue(payload["data"]["truncated"]) + self.assertEqual(payload["data"]["materials"], [{"name": "Steel", "database": str(path)}]) + + def test_unreadable_material_database_is_reported(self): + with tempfile.TemporaryDirectory() as folder: + app = SimpleNamespace(GetMaterialDatabases=lambda: [str(Path(folder) / "missing.sldmat")]) + with patch.object(sw_manage, "running_app", return_value=app): + payload = sw_manage.list_materials({}) + self.assertFalse(payload["ok"]) + self.assertEqual(len(payload["data"]["failures"]), 1) + + +class RegistryTests(unittest.TestCase): + def test_expanded_tools_have_unique_names_and_valid_handlers(self): + names = [entry.name for entry in server.TOOLS] + self.assertEqual(len(names), len(set(names))) + self.assertEqual(set(names), set(sw_core.HANDLERS)) + expected = {"scale_bodies", "move_copy_bodies", "combine_bodies", "delete_bodies", "surface_extrude", "planar_surface", "create_helix", "list_surface_bodies", "list_materials", "create_configuration", "set_equation", "set_custom_property", "set_component_visibility", "set_component_suppression", "set_component_configuration"} + self.assertLessEqual(expected, set(names)) From 808a1405740cc6f05235ccc0a08e909dbe8d789c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:39:52 +0800 Subject: [PATCH 06/31] =?UTF-8?q?=E6=89=A9=E5=B1=95=E9=92=A3=E9=87=91?= =?UTF-8?q?=E7=84=8A=E4=BB=B6=E6=9B=B2=E9=9D=A2=E5=8F=8A=E5=AE=98=E6=96=B9?= =?UTF-8?q?=20Motion=20=E7=AE=97=E4=BE=8B=E5=B7=A5=E5=85=B7=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=A6=86=E7=9B=96=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 67 ++++++++++ README.md | 30 ++++- solidworks_mcp/server.py | 3 + solidworks_mcp/sw_core.py | 10 ++ solidworks_mcp/sw_motion.py | 176 +++++++++++++++++++++++++ solidworks_mcp/sw_multibody.py | 59 ++++++++- solidworks_mcp/sw_sheetmetal.py | 177 ++++++++++++++++++++++++++ solidworks_mcp/sw_weldment.py | 169 ++++++++++++++++++++++++ tests/live_expansion_regression.py | 7 +- tests/live_motion_regression.py | 50 ++++++++ tests/live_sheetmetal_regression.py | 59 +++++++++ tests/live_surface_edit_regression.py | 60 +++++++++ tests/live_weldment_regression.py | 60 +++++++++ tests/test_motion.py | 46 +++++++ tests/test_native_features.py | 100 +++++++++++++++ tools/build_api_inventory.py | 92 +++++++++++++ tools/build_coverage_report.py | 78 ++++++++++++ 17 files changed, 1237 insertions(+), 6 deletions(-) create mode 100644 COVERAGE.md create mode 100644 solidworks_mcp/sw_motion.py create mode 100644 solidworks_mcp/sw_sheetmetal.py create mode 100644 solidworks_mcp/sw_weldment.py create mode 100644 tests/live_motion_regression.py create mode 100644 tests/live_sheetmetal_regression.py create mode 100644 tests/live_surface_edit_regression.py create mode 100644 tests/live_weldment_regression.py create mode 100644 tests/test_motion.py create mode 100644 tests/test_native_features.py create mode 100644 tools/build_api_inventory.py create mode 100644 tools/build_coverage_report.py diff --git a/COVERAGE.md b/COVERAGE.md new file mode 100644 index 0000000..8c49804 --- /dev/null +++ b/COVERAGE.md @@ -0,0 +1,67 @@ +# SolidWorks 本体全覆盖目标 + +范围为 SolidWorks 自身的建模、草图、文档、装配体、工程图、钣金、焊件、曲面、 +模具、配置、属性、测量、数据导入导出与原生自动化接口,也包括 SolidWorks +官方自带或随附的功能模块。嘉立创等第三方厂商插件不在范围内。 +Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许可证和接口, +不能因尚未验证或未安装就从覆盖目标中排除。 + +全覆盖目标仍在进行。已有工具数量不能证明全覆盖,类型库成员数量也不是功能覆盖率。 + +## 完成判据 + +1. 原生功能清单中的操作有明确工具、参数、单位、选择规则和结果读取方式。 +2. 不用任意 COM 调用器、宏执行器或仅能返回成功的占位工具充当功能实现。 +3. 创建与修改操作在真实 SolidWorks 文档中执行,读取实际几何、状态或文件验证结果。 +4. 有实机回归、离线边界检查、安装包检查及 MCP 协议验证。 +5. 每个仍未实现或未验证的操作保留为缺口;不能以跳过测试消除缺口。 + +## 需逐项覆盖的原生功能域 + +| 功能域 | 操作范围 | +| --- | --- | +| 文档与文件 | 新建、打开、切换、保存、关闭、模板、导入、导出、单位、引用关系 | +| 草图 | 2D/3D、几何、约束、驱动尺寸、裁剪、延伸、转换、偏移、阵列、块、文字 | +| 实体特征 | 拉伸、旋转、扫描、放样、边界、圆角、倒角、抽壳、拔模、筋、孔、包覆、螺纹 | +| 多实体 | 移动、复制、缩放、组合、分割、删除、保留、镜像、阵列、保存实体 | +| 曲面 | 拉伸、旋转、扫描、放样、边界、平面、填充、偏移、延伸、修剪、解除修剪、缝合、加厚、替换面 | +| 直接编辑 | 移动面、删除面、替换面、导入几何修复 | +| 钣金 | 基体法兰、边线法兰、斜接法兰、折弯、褶边、转折、闭合角、角释放、展开、折叠、转换、成形、展开导出 | +| 焊件与结构系统 | 结构构件、轮廓、构件组、剪裁、延伸、顶端盖、角撑板、焊缝、切割清单、边界框、结构系统 | +| 参考与曲线 | 基准面、轴、点、坐标系、螺旋线、投影曲线、组合曲线、通过点曲线、分割线 | +| 阵列 | 线性、圆周、镜像、曲线驱动、草图驱动、表格驱动、填充、变量阵列 | +| 装配体 | 插入、定位、替换、删除、配置、显示、抑制、虚拟组件、就地编辑、阵列、镜像、子装配体 | +| 配合 | 标准、距离/角度限制、对称、宽度、槽、齿轮、螺杆、齿条、小齿轮、凸轮、铰链、线性耦合、配合控制 | +| 装配体分析与表现 | 干涉、间隙、爆炸、爆炸步骤、剖视、显示状态、运动算例基础动画 | +| 工程图视图 | 图纸、模型/投影/剖面/局部/辅助/断裂/断开的剖视、替代位置、裁剪、视图对齐 | +| 工程图标注 | 尺寸、公差、形位公差、粗糙度、焊接符号、孔标注、中心标记/线、注释、气泡、图块 | +| 工程图表格 | BOM、孔表、修订表、焊件切割清单、通用表、排序、行列与单元格编辑 | +| 配置与数据 | 配置、派生配置、尺寸/特征配置、方程、全局变量、自定义属性、设计表、显示状态 | +| 材料与显示 | 材质库、材质、颜色、外观、纹理、视图、相机、光源、场景、截图 | +| 检查与测量 | 实体/曲面/拓扑、体积、质量、惯量、距离、角度、曲率、截面、几何有效性、重建错误 | +| 模具 | 分型线、分型面、关闭曲面、模具分割、型芯、型腔 | +| 原生数据交互 | STEP/IGES/Parasolid/STL/3MF、DXF/DWG、PDF、工程图与模型输出、原生文档引用 | +| 官方随附模块 | Simulation、Motion、CAM、Routing 及其他官方随附功能,按实际接口、安装与许可证验证 | + +这些类别是执行清单,不是已经完成的声明;细项将结合本机类型库持续展开。 + +## 当前新增实现与验证 + +默认工具共 142 个,仍未全覆盖。当前批次新增 22 个:钣金 6 个、焊件 6 个、 +曲面编辑 3 个、Motion 算例管理 7 个。 +钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 +解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 +Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回。 + +上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; +Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 + +## 原生 API 清点 + +```powershell +python tools/build_api_inventory.py --official-modules --output D:/solidworks-mcp/.development/native-api-inventory.json +``` + +脚本读取本机 `sldworks.tlb`、`swconst.tlb` 和明确列出的官方随附模块类型库, +记录接口、成员签名及特征类型。不实例化插件,不执行清单中的方法; +读取类型库不能证明许可证可用,也不能证明功能已经实现。实际工具清单通过 MCP `list_tools` 核对。 diff --git a/README.md b/README.md index 1407c66..3b55d62 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,14 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -120 tools: sketching with real relations and driving dimensions, the solid +142 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. +覆盖范围包含 SolidWorks 本体与官方自带、随附模块,排除嘉立创等第三方插件。 +当前仍未全覆盖;功能域、完成判据和缺口记录见 [COVERAGE.md](COVERAGE.md)。 + A sibling server, [`autocad-mcp`](https://github.com/limuzi013/autocad-mcp), does the same for AutoCAD. @@ -172,12 +175,35 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `combine_bodies` | 多实体并集、差集与交集;差集的第一个实体为保留的主体。 | | `delete_bodies` | 创建删除/保留实体特征。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | +| `offset_surface` / `knit_surfaces` / `thicken_surface` | 偏移面、缝合曲面和曲面加厚;读取实际曲面或实体结果。 | | `list_surface_bodies` | 查询曲面体及其面积,面积单位为 mm²。 | | `create_helix` | 从圆草图按螺距和圈数生成螺旋线,读取实际螺距、圈数及高度。 | 实体操作沿用 `selection`,例如 `{"selection": {"bodies": [0, 1]}}`。 索引取自 `list_bodies`,几何变化后需重新查询。曲面体索引与实体索引独立, -本批实体编辑工具只接受实体。 +曲面选择使用 `surface_bodies`,索引取自 `list_surface_bodies`。 + +### 钣金与焊件 + +| 工具 | 功能 | +| --- | --- | +| `sheet_metal_base_flange` | 创建基体法兰,设置板厚、折弯半径与 K 因子。 | +| `list_sheet_metal_features` / `get_sheet_metal_parameters` / `set_sheet_metal_parameters` | 读取和修改钣金参数;默认操作原生模板参数,可指定实体钣金特征。 | +| `set_flat_pattern` / `export_flat_pattern` | 展开、折叠与 DXF 导出;导出要求零件已保存。 | +| `create_weldment` | 创建焊件特征。 | +| `list_weldment_profiles` / `get_weldment_profile_configurations` | 查询本机轮廓与规格配置。 | +| `insert_structural_member` | 按草图线段组、轮廓配置和角度创建结构构件。 | +| `list_cut_list` / `update_cut_list` | 读取和更新切割清单,返回实体与解析属性。 | + +### 官方 Motion 算例 + +`list_motion_studies`、`create_motion_study`、`activate_motion_study`、 +`duplicate_motion_study`、`delete_motion_study`、`set_motion_study_type`、 +`set_motion_study_timing`。 + +时间单位为秒。零件与装配体均支持基础算例管理;工具读取实际支持的类型位掩码, +无法使用的 Basic Motion 或 Motion Analysis 会返回失败,不自动加载模块。 +算例管理已验证,动力学求解、载荷、接触、结果曲线与动画输出仍待覆盖。 ### 文档、配置、属性与方程 diff --git a/solidworks_mcp/server.py b/solidworks_mcp/server.py index 8377715..5714e0a 100644 --- a/solidworks_mcp/server.py +++ b/solidworks_mcp/server.py @@ -61,6 +61,9 @@ from . import sw_demo # noqa: F401 from . import sw_manage # noqa: F401 from . import sw_multibody # noqa: F401 +from . import sw_sheetmetal # noqa: F401 +from . import sw_weldment # noqa: F401 +from . import sw_motion # noqa: F401 server = Server("solidworks-mcp") diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index 1ec2a99..6ed0f3b 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -1206,6 +1206,7 @@ def sketch_point_objects(doc: Any, sketch_name: str | None = None) -> list[Any]: "sketch_points": {"type": "array", "items": {"type": "integer"}, "description": "Sketch point indices."}, "sketch_name": {"type": "string", "description": "Which sketch sketch_segments/sketch_points refer to. Defaults to the open sketch."}, "bodies": {"type": "array", "items": {"type": "integer"}, "description": "Solid-body indices."}, + "surface_bodies": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "Surface-body indices from list_surface_bodies; separate from solid-body indices."}, "components": {"type": "array", "items": {"type": "string"}, "description": "Assembly component names."}, "points": { "type": "array", @@ -1409,6 +1410,15 @@ def apply_selection(doc: Any, spec: dict[str, Any] | None, mark: int = 0, append raise RuntimeError(f"Could not select body {index}.") count += 1 + surface_bodies = get_bodies(doc, BODY_SHEET) if spec.get("surface_bodies") else [] + for raw_index in spec.get("surface_bodies", []): + index = int(raw_index) + if not 0 <= index < len(surface_bodies): + raise RuntimeError(f"Surface-body index {index} is out of range (0..{len(surface_bodies) - 1}).") + if not select_body(doc, surface_bodies[index], mark, True): + raise RuntimeError(f"Could not select surface body {index}.") + count += 1 + if spec.get("components"): found = { str(safe(component, "Name2", "")): component diff --git a/solidworks_mcp/sw_motion.py b/solidworks_mcp/sw_motion.py new file mode 100644 index 0000000..5c2e2aa --- /dev/null +++ b/solidworks_mcp/sw_motion.py @@ -0,0 +1,176 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Native MotionManager study management; availability comes from the live study.""" + +import math + +from .sw_core import ( + active_document, as_list, byref_long, document_type, extension, + flag_methods, result, tool, value, +) + +TYPES = {"animation": 1, "basic_motion": 2, "motion_analysis": 4} +NAME = {"type": "string", "minLength": 1} +TYPE = {"type": "string", "enum": list(TYPES), "default": "animation"} + + +def _name(raw): + name = str(raw).strip() + if not name: + raise RuntimeError("Motion study name must not be empty.") + return name + + +def _manager(): + _, doc = active_document() + if document_type(doc) not in (1, 2): + raise RuntimeError("Motion studies require an active part or assembly.") + manager = value(extension(doc), "GetMotionStudyManager") + if manager is None: + raise RuntimeError("The native MotionManager is unavailable for this document.") + return flag_methods(manager, "GetMotionStudy", "ActivateMotionStudy", "DeleteMotionStudy") + + +def _names(manager): + return [str(name) for name in as_list(value(manager, "GetMotionStudyNames"))] + + +def _study(manager, name): + if name not in _names(manager): + raise RuntimeError(f"No motion study named '{name}'. Use list_motion_studies first.") + study = manager.GetMotionStudy(name) + if study is None: + raise RuntimeError(f"MotionManager could not access '{name}'.") + return flag_methods(study, "GetSupportedStudyTypes", "SetDuration", "SetTime") + + +def _supported(study): + mask = byref_long() + if not study.GetSupportedStudyTypes(mask): + raise RuntimeError("MotionManager could not read the supported study types.") + return int(mask.value) + + +def _info(study): + mask = _supported(study) + kind = int(value(study, "StudyType")) + return {"name": str(value(study, "Name")), "type": next((name for name, code in TYPES.items() if code == kind), str(kind)), + "type_code": kind, "active": bool(value(study, "IsActive")), + "duration_sec": float(value(study, "GetDuration")), "time_sec": float(value(study, "GetTime")), + "supported_types": [name for name, code in TYPES.items() if mask & code], "supported_type_mask": mask} + + +def _type(raw): + if raw not in TYPES: + raise RuntimeError(f"Unknown motion study type '{raw}'.") + return TYPES[raw] + + +def _set_type(study, code): + if not _supported(study) & code: + return result(False, "Requested study type is unavailable in the current installed/loaded/licensed session.", study=_info(study)) + study.StudyType = code + info = _info(study) + return result(info["type_code"] == code, "Read back motion study type.", study=info) + + +@tool("list_motion_studies", "Read-only: list native MotionManager studies, timeline times and actually supported types.") +def list_motion_studies(args): + manager = _manager() + names = _names(manager) + studies = [_info(_study(manager, name)) for name in names] + count = int(value(manager, "GetMotionStudyCount")) + return result(count == len(studies), "Read motion studies.", studies=studies, count=count) + + +@tool("create_motion_study", "Create and name a native motion study. Defaults to animation; unsupported simulation types are reported as unavailable.", + {"name": NAME, "type": TYPE}, ["name"]) +def create_motion_study(args): + name = _name(args["name"]) + code = _type(args.get("type", "animation")) + manager = _manager() + if name in _names(manager): + raise RuntimeError(f"Motion study '{name}' already exists.") + study = value(manager, "CreateMotionStudy") + if study is None: + return result(False, "Native motion study creation failed.") + study = flag_methods(study, "GetSupportedStudyTypes", "SetDuration", "SetTime") + study.Name = name + output = _set_type(study, code) + if str(value(study, "Name")) != name or name not in _names(manager): + return result(False, "Created study, but the requested name was not applied.", study=_info(study)) + return output + + +@tool("activate_motion_study", "Activate an existing native motion study and read back its active state.", {"name": NAME}, ["name"]) +def activate_motion_study(args): + manager = _manager() + name = _name(args["name"]) + study = _study(manager, name) + accepted = bool(manager.ActivateMotionStudy(name)) + info = _info(study) + return result(accepted and info["active"], "Read back motion study activation.", study=info) + + +@tool("delete_motion_study", "Delete the named motion study and its animation/simulation data; verify it disappears from MotionManager.", {"name": NAME}, ["name"]) +def delete_motion_study(args): + manager = _manager() + name = _name(args["name"]) + _study(manager, name) + accepted = bool(manager.DeleteMotionStudy(name)) + names = _names(manager) + return result(accepted and name not in names, "Read back motion study deletion.", remaining_names=names) + + +@tool("duplicate_motion_study", "Duplicate an existing motion study, assign a new unique name, and read back timeline/type state.", + {"name": NAME, "new_name": NAME}, ["name", "new_name"]) +def duplicate_motion_study(args): + manager = _manager() + name, new_name = _name(args["name"]), _name(args["new_name"]) + if new_name in _names(manager): + raise RuntimeError(f"Motion study '{new_name}' already exists.") + study = value(_study(manager, name), "Duplicate") + if study is None: + return result(False, "Native motion study duplication failed.") + study = flag_methods(study, "GetSupportedStudyTypes") + study.Name = new_name + info = _info(study) + return result(info["name"] == new_name and new_name in _names(manager), "Read back duplicated motion study.", study=info) + + +@tool("set_motion_study_type", "Set animation, Basic Motion or Motion Analysis only when native MotionManager reports that type is supported.", + {"name": NAME, "type": TYPE}, ["name", "type"]) +def set_motion_study_type(args): + code = _type(args["type"]) + manager = _manager() + return _set_type(_study(manager, _name(args["name"])), code) + + +@tool("set_motion_study_timing", "Set native timeline duration and/or cursor time in seconds, checking bounds before any mutation and reading values back.", + {"name": NAME, "duration_sec": {"type": "number", "exclusiveMinimum": 0}, "time_sec": {"type": "number", "minimum": 0}}, ["name"]) +def set_motion_study_timing(args): + updates = {key: float(args[key]) for key in ("duration_sec", "time_sec") if key in args} + if not updates or any(not math.isfinite(v) or v < 0 or (k == "duration_sec" and v == 0) for k, v in updates.items()): + raise RuntimeError("Provide a finite positive duration and/or a finite nonnegative cursor time.") + manager = _manager() + study = _study(manager, _name(args["name"])) + before = _info(study) + duration = updates.get("duration_sec", before["duration_sec"]) + if updates.get("time_sec", 0) > duration: + raise RuntimeError("Cursor time must not exceed the study duration.") + if not manager.ActivateMotionStudy(before["name"]): + return result(False, "Could not activate the study before setting its timeline.", study=before) + accepted = True + if "duration_sec" in updates: + accepted = bool(study.SetDuration(duration)) + if accepted and "time_sec" in updates: + accepted = bool(study.SetTime(updates["time_sec"])) + info = _info(study) + matches = all(math.isclose(info[k], v, rel_tol=1e-7, abs_tol=1e-7) for k, v in updates.items()) + return result(accepted and matches, "Read back motion study timeline.", study=info) diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index 3b205c0..77f7130 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -16,7 +16,7 @@ import math from .sw_core import ( - BODY_SHEET, dispatch_array, + BODY_SHEET, SELECTION_SCHEMA, dispatch_array, feature_manager, feature_result, flag_methods, get_bodies, nothing, rename_feature, require_part, require_selection, result, select_sketch_for_feature, to_m, to_rad, tool, as_list, value, @@ -216,3 +216,60 @@ def create_helix(args): if not (math.isclose(actual_pitch, pitch, rel_tol=1e-6) and math.isclose(actual_turns, turns, rel_tol=1e-6)): payload.update(ok=False, message="Helix feature exists, but pitch/revolution readback differs.") return payload + + +@tool("offset_surface", "Create an offset surface from selected solid-model faces; distance=0 copies the selected faces. Distance is mm.", + {"selection": SELECTION_SCHEMA, "distance_mm": {"type": "number", "minimum": 0}, "reverse": {"type": "boolean", "default": False}, "name": NAME}, ["selection", "distance_mm"]) +def offset_surface(args): + distance = float(args["distance_mm"]) + if not math.isfinite(distance) or distance < 0: + raise RuntimeError("distance_mm must be finite and nonnegative.") + _, doc = require_part() + require_selection(doc, args["selection"]) + before = _feature_names(doc) + sheets = len(get_bodies(doc, BODY_SHEET)) + flag_methods(doc, "InsertOffsetSurface").InsertOffsetSurface(to_m(distance), bool(args.get("reverse", False))) + payload = _finish(doc, _feature_created_after(doc, before), args, "offset surface") + if payload["ok"] and len(get_bodies(doc, BODY_SHEET)) <= sheets: + payload.update(ok=False, message="Offset surface feature did not produce a surface body.") + return payload + + +@tool("thicken_surface", "Thicken one surface into a solid. Select surface_bodies from list_surface_bodies or its feature; lengths are mm. both applies thickness on each side.", + {"selection": SELECTION_SCHEMA, "thickness_mm": {"type": "number", "exclusiveMinimum": 0}, + "direction": {"type": "string", "enum": ["side_one", "side_two", "both"], "default": "side_one"}, + "merge": {"type": "boolean", "default": True}, "name": NAME}, ["selection", "thickness_mm"]) +def thicken_surface(args): + thickness = _positive(args["thickness_mm"], "thickness_mm") + _, doc = require_part() + count = require_selection(doc, args["selection"], mark=1) + if count != 1: + raise RuntimeError("Select exactly one surface body or surface feature.") + before = len(get_bodies(doc)) + manager = flag_methods(feature_manager(doc), "FeatureBossThicken") + feature = manager.FeatureBossThicken(to_m(thickness), {"side_one": 0, "side_two": 1, "both": 2}[args.get("direction", "side_one")], + 0, False, bool(args.get("merge", True)), True, True) + payload = _finish(doc, feature, args, "surface thickening") + if payload["ok"] and len(get_bodies(doc)) <= before and not args.get("merge", True): + payload.update(ok=False, message="Thickening did not create a separate solid body.") + return payload + + +@tool("knit_surfaces", "Knit at least two selected surface bodies/features, optionally forming a closed solid. Tolerance is mm; re-list surfaces after changes.", + {"selection": SELECTION_SCHEMA, "form_solid": {"type": "boolean", "default": False}, "merge_entities": {"type": "boolean", "default": True}, + "tolerance_mm": {"type": "number", "minimum": 0.0001, "maximum": 0.1, "default": 0.001}, "name": NAME}, ["selection"]) +def knit_surfaces(args): + tolerance = float(args.get("tolerance_mm", 0.001)) + if not math.isfinite(tolerance) or not 0.0001 <= tolerance <= 0.1: + raise RuntimeError("Knit tolerance must be between 0.0001 and 0.1 mm.") + _, doc = require_part() + count = require_selection(doc, args["selection"], mark=1) + if count < 2: + raise RuntimeError("Select at least two surface bodies or surface features.") + before = len(get_bodies(doc)) + manager = flag_methods(feature_manager(doc), "InsertSewRefSurface") + feature = manager.InsertSewRefSurface(False, bool(args.get("form_solid", False)), bool(args.get("merge_entities", True)), to_m(tolerance), to_m(tolerance)) + payload = _finish(doc, feature, args, "surface knit") + if payload["ok"] and args.get("form_solid", False) and len(get_bodies(doc)) <= before: + payload.update(ok=False, message="Knit feature did not form a closed solid.") + return payload diff --git a/solidworks_mcp/sw_sheetmetal.py b/solidworks_mcp/sw_sheetmetal.py new file mode 100644 index 0000000..6eb9364 --- /dev/null +++ b/solidworks_mcp/sw_sheetmetal.py @@ -0,0 +1,177 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Native sheet metal creation, parameters, flattening and DXF export.""" + +from pathlib import Path +import math + +from .sw_core import ( + as_list, clear_selection, empty_variant, extension, feature_manager, feature_property, + feature_result, find_feature, flag_methods, get_bodies, iter_feature_objects, + nothing, rebuild, rename_feature, require_part, result, + select_sketch_for_feature, to_m, to_mm, tool, value, whats_wrong, +) +from .sw_file import validated_output_path +from .sw_multibody import _positive + + +def _sheet_features(doc): + return [f for f in iter_feature_objects(doc) if str(feature_property(f, "GetTypeName2", "")) == "SheetMetal"] + + +def _flat_features(doc): + return [f for f in iter_feature_objects(doc) if str(feature_property(f, "GetTypeName2", "")) == "FlatPattern"] + + +def _suppressed(feature): + flags = as_list(flag_methods(feature, "IsSuppressed2").IsSuppressed2(1, empty_variant())) + if len(flags) != 1: + raise RuntimeError("Could not read feature suppression in the current configuration.") + return bool(flags[0]) + + +def _read_parameters(feature): + definition = value(feature, "GetDefinition") + return {"feature": str(value(feature, "Name")), "thickness_mm": to_mm(float(value(definition, "Thickness"))), + "bend_radius_mm": to_mm(float(value(definition, "BendRadius"))), "k_factor": float(value(definition, "KFactor"))} + + +@tool("sheet_metal_base_flange", "Create a native sheet-metal base flange from a closed or open sketch. Thickness, radius and extrusion depth are mm; K-factor is dimensionless.", + {"sketch_name": {"type": "string"}, "thickness_mm": {"type": "number", "exclusiveMinimum": 0}, + "bend_radius_mm": {"type": "number", "minimum": 0, "default": 1}, "depth_mm": {"type": "number", "exclusiveMinimum": 0, "default": 50}, + "k_factor": {"type": "number", "minimum": 0, "maximum": 1, "default": 0.5}, + "reverse_thickness": {"type": "boolean", "default": False}, "reverse": {"type": "boolean", "default": False}, "name": {"type": "string"}}, ["thickness_mm"]) +def sheet_metal_base_flange(args): + thickness = _positive(args["thickness_mm"], "thickness_mm") + depth = _positive(args.get("depth_mm", 50), "depth_mm") + radius = float(args.get("bend_radius_mm", 1)) + factor = float(args.get("k_factor", 0.5)) + if not math.isfinite(radius) or radius < 0 or not math.isfinite(factor) or not 0 <= factor <= 1: + raise RuntimeError("Radius must be finite and nonnegative; K-factor must be between 0 and 1.") + _, doc = require_part() + sketch = select_sketch_for_feature(doc, args.get("sketch_name")) + manager = flag_methods(feature_manager(doc), "CreateCustomBendAllowance", "InsertSheetMetalBaseFlange2") + allowance = manager.CreateCustomBendAllowance() + allowance.Type = 2 + allowance.KFactor = factor + feature = manager.InsertSheetMetalBaseFlange2(to_m(thickness), bool(args.get("reverse_thickness", False)), to_m(radius), + to_m(depth), to_m(depth), bool(args.get("reverse", False)), 0, 0, 1, allowance, + False, 2, to_m(thickness), to_m(thickness), 0.5, True, False, True, True) + rename_feature(feature, args.get("name")) + payload = feature_result(doc, feature, "sheet-metal base flange", sketch=sketch) + if payload["ok"] and not any(bool(value(b, "IsSheetMetal")) for b in get_bodies(doc)): + payload.update(ok=False, message="Base flange feature did not create a sheet-metal body.") + return payload + + +@tool("list_sheet_metal_features", "Read-only: list native sheet-metal definitions, flat patterns and current suppression states.") +def list_sheet_metal_features(args): + _, doc = require_part() + return result(True, "Read sheet-metal features.", sheet_metal=[_read_parameters(f) for f in _sheet_features(doc)], + flat_patterns=[{"feature": str(value(f, "Name")), "suppressed": _suppressed(f)} for f in _flat_features(doc)]) + + +def _sheet_feature(doc, name): + if not name: + template = value(extension(doc), "GetTemplateSheetMetal") + if template is None: + raise RuntimeError("The active part has no sheet-metal defaults.") + return template + available = _sheet_features(doc) + if name: + available = [f for f in available if str(value(f, "Name")) == name] + if len(available) != 1: + raise RuntimeError("Select an exact sheet-metal feature name from list_sheet_metal_features.") + return available[0] + + +@tool("get_sheet_metal_parameters", "Read-only: read sheet-metal thickness, bend radius and K-factor. Omit feature_name for document defaults, or specify a body definition from list_sheet_metal_features.", {"feature_name": {"type": "string"}}) +def get_sheet_metal_parameters(args): + _, doc = require_part() + return result(True, "Read sheet-metal parameters.", parameters=_read_parameters(_sheet_feature(doc, args.get("feature_name")))) + + +@tool("set_sheet_metal_parameters", "Edit sheet-metal thickness/radius/K-factor and read values back. Omit feature_name to edit document defaults; specify a body definition to override its defaults. Lengths are mm.", + {"feature_name": {"type": "string"}, "thickness_mm": {"type": "number", "exclusiveMinimum": 0}, + "bend_radius_mm": {"type": "number", "minimum": 0}, "k_factor": {"type": "number", "minimum": 0, "maximum": 1}}) +def set_sheet_metal_parameters(args): + changes = {} + for key, member in (("thickness_mm", "Thickness"), ("bend_radius_mm", "BendRadius"), ("k_factor", "KFactor")): + if key not in args: + continue + raw = float(args[key]) + if not math.isfinite(raw) or (raw <= 0 if key == "thickness_mm" else raw < 0) or (key == "k_factor" and raw > 1): + raise RuntimeError(f"Invalid {key}.") + changes[member] = to_m(raw) if key.endswith("_mm") else raw + if not changes: + raise RuntimeError("Specify at least one sheet-metal parameter to change.") + _, doc = require_part() + feature = _sheet_feature(doc, args.get("feature_name")) + definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") + if not definition.AccessSelections(doc, nothing()): + return result(False, "Could not access the sheet-metal definition.") + modified = False + try: + if args.get("feature_name"): + flag_methods(definition, "SetOverrideDefaultParameter2") + if "Thickness" in changes or "BendRadius" in changes: + definition.SetOverrideDefaultParameter2(0, True) + if "KFactor" in changes: + definition.SetOverrideDefaultParameter2(1, True) + for member, raw in changes.items(): + setattr(definition, member, raw) + modified = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(definition, doc, nothing())) + finally: + if not modified: + value(definition, "ReleaseSelectionAccess") + rebuild(doc) + actual = _read_parameters(feature) + correct = all(math.isclose(actual[key], float(args[key]), rel_tol=1e-6, abs_tol=1e-6) for key in ("thickness_mm", "bend_radius_mm", "k_factor") if key in args) + problems = whats_wrong(doc) + return result(modified and correct and not problems, "Updated sheet-metal parameters." if modified and correct else "Sheet-metal parameter readback differs or update failed.", parameters=actual, problems=problems) + + +@tool("set_flat_pattern", "Flatten or refold sheet metal by changing native FlatPattern suppression in the current configuration. Re-list topology afterwards.", + {"flat": {"type": "boolean"}, "feature_name": {"type": "string"}}, ["flat"]) +def set_flat_pattern(args): + _, doc = require_part() + features = _flat_features(doc) + if args.get("feature_name"): + features = [f for f in features if str(value(f, "Name")) == str(args["feature_name"])] + if not features: + return result(False, "No matching FlatPattern feature exists.") + desired = not bool(args["flat"]) + actual = [] + for feature in features: + flag_methods(feature, "SetSuppression2").SetSuppression2(0 if desired else 1, 1, empty_variant()) + rebuild(doc) + actual.append({"feature": str(value(feature, "Name")), "suppressed": _suppressed(feature)}) + clear_selection(doc) + problems = whats_wrong(doc) + return result(all(item["suppressed"] == desired for item in actual) and not problems, "Updated flat-pattern state.", flat_patterns=actual, problems=problems) + + +@tool("export_flat_pattern", "Export a saved sheet-metal part's flat pattern as DXF under the output root. Optionally include bend lines. The source part must already be saved.", + {"path": {"type": "string"}, "bend_lines": {"type": "boolean", "default": True}, "overwrite": {"type": "boolean", "default": False}}, ["path"]) +def export_flat_pattern(args): + _, doc = require_part() + model = str(value(doc, "GetPathName") or "") + if not model or not Path(model).is_file(): + return result(False, "Save the sheet-metal part before exporting its flat pattern.") + if not any(bool(value(body, "IsSheetMetal")) for body in get_bodies(doc)): + return result(False, "The active part has no sheet-metal bodies.") + output = validated_output_path(str(args["path"]), {".dxf"}, bool(args.get("overwrite", False))) + options = 1 | (4 if args.get("bend_lines", True) else 0) + clear_selection(doc) + exported = bool(flag_methods(doc, "ExportToDWG2").ExportToDWG2(str(output), model, 1, True, empty_variant(), False, False, options, empty_variant())) + valid = output.is_file() and output.stat().st_size > 0 + if valid: + text = output.read_text(encoding="ascii", errors="ignore") + valid = "ENTITIES" in text and any(entity in text for entity in ("LINE", "POLYLINE", "ARC", "CIRCLE", "SPLINE")) + return result(exported and valid, "Exported flat-pattern DXF." if exported and valid else "Flat-pattern export did not produce a valid DXF entity section.", path=str(output)) diff --git a/solidworks_mcp/sw_weldment.py b/solidworks_mcp/sw_weldment.py new file mode 100644 index 0000000..587b913 --- /dev/null +++ b/solidworks_mcp/sw_weldment.py @@ -0,0 +1,169 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Native weldments, structural-member groups, profiles and cut lists.""" + +import math +import os +from pathlib import Path + +from .sw_core import ( + as_list, clear_selection, dispatch_array, exit_active_sketch, feature_manager, + feature_property, feature_result, flag_methods, get_bodies, iter_feature_objects, + rebuild, rename_feature, require_part, result, running_app, safe, + sketch_segment_objects, to_rad, tool, value, whats_wrong, +) +from .sw_manage import _property + + +def _walk_features(doc): + seen = set() + pending = list(iter_feature_objects(doc)) + while pending: + feature = pending.pop(0) + key = (str(feature_property(feature, "Name", "")), str(feature_property(feature, "GetTypeName2", ""))) + if key in seen: + continue + seen.add(key) + yield feature + child = safe(feature, "GetFirstSubFeature") + siblings = set() + while child is not None: + child_name = str(feature_property(child, "Name", "")) + if child_name in siblings: + break + siblings.add(child_name) + pending.append(child) + child = safe(child, "GetNextSubFeature") + + +@tool("create_weldment", "Enable native weldment features on the active part; verifies IsWeldment afterwards.") +def create_weldment(args): + _, doc = require_part() + if bool(value(doc, "IsWeldment")): + return result(True, "The active part is already a weldment.") + manager = flag_methods(feature_manager(doc), "InsertWeldmentFeature") + feature = manager.InsertWeldmentFeature() + payload = feature_result(doc, feature, "weldment") + if payload["ok"] and not bool(value(doc, "IsWeldment")): + payload.update(ok=False, message="The part did not become a weldment.") + return payload + + +@tool("list_weldment_profiles", "Read-only: find local native .sldlfp profiles under the supplied root or installed ProgramData profile folders.", + {"root": {"type": "string"}, "query": {"type": "string", "default": ""}, "limit": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}}) +def list_weldment_profiles(args): + limit = int(args.get("limit", 100)) + if not 1 <= limit <= 500: + raise RuntimeError("limit must be between 1 and 500.") + if args.get("root"): + roots = [Path(str(args["root"])).expanduser().resolve()] + if not roots[0].is_dir(): + return result(False, "Profile root is not a directory.") + else: + data = Path(os.environ.get("ProgramData", "C:/ProgramData")) / "SOLIDWORKS" + roots = sorted(data.glob("SOLIDWORKS */weldment profiles"), reverse=True) + query = str(args.get("query", "")).casefold() + paths = sorted({p.resolve() for root in roots for p in root.rglob("*.sldlfp") if p.is_file() and query in str(p).casefold()}) + return result(True, "Read local weldment profiles.", profiles=[{"name": p.stem, "path": str(p)} for p in paths[:limit]], total_matches=len(paths), truncated=len(paths) > limit) + + +def _profile_path(raw): + path = Path(str(raw)).expanduser().resolve() + if path.suffix.lower() != ".sldlfp" or not path.is_file(): + raise RuntimeError("Select an existing .sldlfp weldment profile.") + return path + + +@tool("get_weldment_profile_configurations", "Read-only: list configurations stored in a native weldment profile, including size configurations.", {"path": {"type": "string"}}, ["path"]) +def get_weldment_profile_configurations(args): + path = _profile_path(args["path"]) + app = flag_methods(running_app(), "GetConfigurationNames") + names = [str(n) for n in as_list(app.GetConfigurationNames(str(path)))] + return result(True, "Read profile configurations.", path=str(path), configurations=names) + + +@tool("insert_structural_member", "Create native structural members using a local .sldlfp profile and explicit sketch-segment groups. Angles are degrees; profile configuration selects size.", + {"profile_path": {"type": "string"}, "configuration": {"type": "string", "default": ""}, "name": {"type": "string"}, + "groups": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": { + "sketch_name": {"type": "string"}, "segments": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True}, + "angle_deg": {"type": "number", "default": 0}}, "required": ["sketch_name", "segments"], "additionalProperties": False}}}, ["profile_path", "groups"]) +def insert_structural_member(args): + path = _profile_path(args["profile_path"]) + if not args.get("groups"): + raise RuntimeError("Specify at least one structural-member group.") + _, doc = require_part() + exit_active_sketch(doc) + resolved = [] + for spec in args["groups"]: + segments = sketch_segment_objects(doc, str(spec["sketch_name"])) + indices = spec["segments"] + angle = float(spec.get("angle_deg", 0)) + if not indices or len(indices) != len(set(indices)) or any(not isinstance(i, int) or i < 0 or i >= len(segments) for i in indices): + raise RuntimeError("Structural-member segment indices are invalid; list_sketch_segments first.") + if not math.isfinite(angle): + raise RuntimeError("Group rotation angle must be finite.") + resolved.append(([segments[i] for i in indices], angle)) + clear_selection(doc) + manager = flag_methods(feature_manager(doc), "CreateStructuralMemberGroup", "InsertStructuralWeldment5") + groups = [] + for segments, angle in resolved: + group = manager.CreateStructuralMemberGroup() + group.Segments = dispatch_array(segments) + group.Angle = to_rad(angle) + group.ApplyCornerTreatment = True + group.CornerTreatmentType = 1 + groups.append(group) + before = len(get_bodies(doc)) + feature = manager.InsertStructuralWeldment5(str(path), 1, False, dispatch_array(groups), str(args.get("configuration", ""))) + rename_feature(feature, args.get("name")) + payload = feature_result(doc, feature, "structural member") + if payload["ok"] and len(get_bodies(doc)) <= before: + payload.update(ok=False, message="Structural member did not add a solid body.") + return payload + + +def _cut_list(doc): + entries = [] + for feature in _walk_features(doc): + if str(feature_property(feature, "GetTypeName2", "")) != "CutListFolder": + continue + folder = value(feature, "GetSpecificFeature2") + bodies = as_list(value(folder, "GetBodies")) + if not bodies: + continue + manager = flag_methods(value(feature, "CustomPropertyManager"), "Get6", "GetType2") + entries.append({"name": str(value(feature, "Name")), "body_count": len(bodies), + "bodies": [str(value(b, "Name")) for b in bodies], + "properties": [_property(manager, str(n)) for n in as_list(value(manager, "GetNames"))]}) + return entries + + +@tool("list_cut_list", "Read-only: list weldment/sheet-metal cut-list folders, grouped bodies and resolved custom properties. Call update_cut_list after geometry changes.") +def list_cut_list(args): + _, doc = require_part() + return result(True, "Read cut list.", cut_list=_cut_list(doc)) + + +@tool("update_cut_list", "Generate/update automatic native cut lists for the active weldment or sheet-metal part, then read folder/body grouping back.") +def update_cut_list(args): + _, doc = require_part() + found = False + ok = True + for feature in iter_feature_objects(doc): + if str(feature_property(feature, "GetTypeName2", "")) != "SolidBodyFolder": + continue + folder = flag_methods(value(feature, "GetSpecificFeature2"), "SetAutomaticCutList", "SetAutomaticUpdate", "UpdateCutList") + folder.SetAutomaticCutList(True) + folder.SetAutomaticUpdate(True) + ok = bool(folder.UpdateCutList()) and ok + found = True + rebuild(doc) + problems = whats_wrong(doc) + entries = _cut_list(doc) + return result(found and ok and not problems and bool(entries), "Updated native cut list." if entries else "No cut-list folders were generated.", cut_list=entries, problems=problems) diff --git a/tests/live_expansion_regression.py b/tests/live_expansion_regression.py index 450a3af..dabf900 100644 --- a/tests/live_expansion_regression.py +++ b/tests/live_expansion_regression.py @@ -194,9 +194,10 @@ def main(): test_multibody() test_surfaces_and_helix() test_components() - expected = {t.name for t in server.TOOLS if HANDLERS[t.name].__module__ in - ("solidworks_mcp.sw_manage", "solidworks_mcp.sw_multibody")} | { - "set_component_visibility", "set_component_suppression", "set_component_configuration"} + expected = {t.name for t in server.TOOLS if HANDLERS[t.name].__module__ == "solidworks_mcp.sw_manage"} | { + "scale_bodies", "move_copy_bodies", "combine_bodies", "delete_bodies", "surface_extrude", + "planar_surface", "create_helix", "list_surface_bodies", + "set_component_visibility", "set_component_suppression", "set_component_configuration"} assert expected <= CALLED, expected - CALLED print(f"Expanded tools verified: {len(expected)}; total tools: {len(server.TOOLS)}", flush=True) finally: diff --git a/tests/live_motion_regression.py b/tests/live_motion_regression.py new file mode 100644 index 0000000..662a2ee --- /dev/null +++ b/tests/live_motion_regression.py @@ -0,0 +1,50 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Native MotionManager acceptance on scratch documents, restoring the session.""" + +from live_expansion_regression import call, near +from solidworks_mcp.sw_core import as_list, byref_long, running_app, value + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + for kind in ("part", "assembly"): + call("create_new_document", kind=kind) + baseline = call("list_motion_studies")["count"] + study = call("create_motion_study", name="MCP Timeline")["study"] + assert study["type"] == "animation" and "animation" in study["supported_types"], study + assert call("list_motion_studies")["count"] == baseline + 1 + assert call("activate_motion_study", name="MCP Timeline")["study"]["active"] + call("set_motion_study_type", name="MCP Timeline", type="animation") + timing = call("set_motion_study_timing", name="MCP Timeline", duration_sec=6, time_sec=2)["study"] + near(timing["duration_sec"], 6, "motion duration") + near(timing["time_sec"], 2, "motion cursor") + duplicate = call("duplicate_motion_study", name="MCP Timeline", new_name="MCP Copy")["study"] + near(duplicate["duration_sec"], 6, "duplicated duration") + assert duplicate["type"] == "animation", duplicate + assert call("list_motion_studies")["count"] == baseline + 2 + call("delete_motion_study", name="MCP Copy") + call("delete_motion_study", name="MCP Timeline") + assert call("list_motion_studies")["count"] == baseline + print(f"Native MotionManager lifecycle/type/timeline verified on {kind}.", flush=True) + finally: + titles = [str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))] + for title in titles: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/live_sheetmetal_regression.py b/tests/live_sheetmetal_regression.py new file mode 100644 index 0000000..705d680 --- /dev/null +++ b/tests/live_sheetmetal_regression.py @@ -0,0 +1,59 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Sheet-metal live checks using scratch documents only, restoring the session.""" + +import uuid +from live_expansion_regression import call, near, volume +from solidworks_mcp.sw_core import OUTPUT_ROOT, as_list, byref_long, running_app, value + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="Plate") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=100, y2_mm=50) + call("close_sketch") + call("sheet_metal_base_flange", sketch_name="Plate", thickness_mm=2, bend_radius_mm=1, k_factor=0.5) + near(volume(), 10000, "flat sheet volume") + definitions = call("list_sheet_metal_features") + assert len(definitions["sheet_metal"]) == 1, definitions + parameters = call("get_sheet_metal_parameters")["parameters"] + near(parameters["thickness_mm"], 2, "sheet thickness") + call("set_sheet_metal_parameters", thickness_mm=3, bend_radius_mm=2, k_factor=0.4) + near(volume(), 15000, "updated sheet volume") + parameters = call("get_sheet_metal_parameters")["parameters"] + near(parameters["thickness_mm"], 3, "updated thickness") + near(parameters["bend_radius_mm"], 2, "updated radius") + near(parameters["k_factor"], 0.4, "updated K-factor") + call("set_flat_pattern", flat=True) + assert all(not f["suppressed"] for f in call("list_sheet_metal_features")["flat_patterns"]) + near(volume(), 15000, "flattened sheet volume") + call("set_flat_pattern", flat=False) + assert all(f["suppressed"] for f in call("list_sheet_metal_features")["flat_patterns"]) + folder = OUTPUT_ROOT / ".sheetmetal-tests" / uuid.uuid4().hex + call("save_document", path=str(folder / "plate.SLDPRT")) + call("export_flat_pattern", path=str(folder / "plate.dxf")) + body_feature = call("list_sheet_metal_features")["sheet_metal"][0]["feature"] + call("set_sheet_metal_parameters", feature_name=body_feature, thickness_mm=4, bend_radius_mm=3, k_factor=0.3) + near(volume(), 20000, "body sheet thickness override volume") + print("Sheet-metal creation, parameter edit, flatten/refold and DXF verified.", flush=True) + finally: + for title in [str(value(doc, "GetTitle")) for doc in as_list(value(app, "GetDocuments"))]: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/live_surface_edit_regression.py b/tests/live_surface_edit_regression.py new file mode 100644 index 0000000..64a3a8d --- /dev/null +++ b/tests/live_surface_edit_regression.py @@ -0,0 +1,60 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Offset, knit and thicken tests with surface area and solid-volume readback.""" + +from live_expansion_regression import block, call, near, volume +from solidworks_mcp.sw_core import BODY_SHEET, as_list, byref_long, get_bodies, require_part, running_app, value + + +def rectangle_surface(name, start, end): + call("create_sketch", plane="front", name=name) + call("draw_rectangle", x1_mm=start, y1_mm=0, x2_mm=end, y2_mm=10) + call("close_sketch") + call("planar_surface", sketch_name=name) + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + block() + top = call("list_faces", normal=[0, 0, 1])["faces"] + assert len(top) == 1, top + call("offset_surface", selection={"faces": [top[0]["index"]]}, distance_mm=5) + near(sum(b["area_mm2"] for b in call("list_surface_bodies")["surface_bodies"]), 100, "offset area") + _, doc = require_part() + box = list(value(get_bodies(doc, BODY_SHEET)[0], "GetBodyBox")) + near(abs(box[2] * 1000 - 10), 5, "offset distance") + call("create_new_document", kind="part") + rectangle_surface("First", 0, 10) + rectangle_surface("Second", 10, 20) + assert len(call("list_surface_bodies")["surface_bodies"]) == 2 + call("knit_surfaces", selection={"surface_bodies": [0, 1]}) + bodies = call("list_surface_bodies")["surface_bodies"] + assert len(bodies) == 1, bodies + near(bodies[0]["area_mm2"], 200, "knit area") + call("thicken_surface", selection={"surface_bodies": [0]}, thickness_mm=3) + near(volume(), 600, "thickened volume") + call("create_new_document", kind="part") + rectangle_surface("BothSides", 0, 10) + call("thicken_surface", selection={"surface_bodies": [0]}, thickness_mm=3, direction="both") + near(volume(), 600, "two-sided thickened volume") + print("Offset distance, knit area and thicken volume verified.", flush=True) + finally: + for title in [str(value(doc, "GetTitle")) for doc in as_list(value(app, "GetDocuments"))]: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/live_weldment_regression.py b/tests/live_weldment_regression.py new file mode 100644 index 0000000..e67b8d4 --- /dev/null +++ b/tests/live_weldment_regression.py @@ -0,0 +1,60 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Structural members checked by bounding length, volume ratio and cut-list grouping.""" + +import math +from pathlib import Path +from live_expansion_regression import call, near, volume +from solidworks_mcp.sw_core import as_list, byref_long, running_app, safe, value + + +def member(path, configuration, length): + call("create_new_document", kind="part") + call("create_weldment") + call("create_sketch", plane="front", name="MemberPath") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=length, y2_mm=0) + call("close_sketch") + call("insert_structural_member", profile_path=path, configuration=configuration, + groups=[{"sketch_name": "MemberPath", "segments": [0]}]) + near(call("get_bounding_box")["size_mm"][0], length, "structural member length") + return volume() + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + profiles = call("list_weldment_profiles", query="pipe", limit=100)["profiles"] + assert profiles, "No fixture profile available; select another installed native profile." + path = next(p["path"] for p in profiles if Path(p["path"]).name.lower() == "pipe.sldlfp") + names = call("get_weldment_profile_configurations", path=path)["configurations"] + assert names, "Profile contains no configurations." + first = member(path, names[0], 100) + assert first > 0 + if names[0] == "21.3 x 2.3": + near(first, math.pi / 4 * (21.3**2 - (21.3 - 2 * 2.3)**2) * 100, "pipe analytic volume") + call("update_cut_list") + cut_list = call("list_cut_list")["cut_list"] + assert sum(entry["body_count"] for entry in cut_list) == 1, cut_list + second = member(path, names[0], 200) + near(second, 2 * first, "structural member volume ratio") + print("Structural profile, member length/volume and cut-list grouping verified.", flush=True) + finally: + titles = [str(safe(doc, "GetTitle", "")) for doc in as_list(value(app, "GetDocuments"))] + for title in titles: + if title and title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/test_motion.py b/tests/test_motion.py new file mode 100644 index 0000000..87adcb2 --- /dev/null +++ b/tests/test_motion.py @@ -0,0 +1,46 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from solidworks_mcp import sw_motion + + +class MotionBoundaryTests(unittest.TestCase): + def test_invalid_timeline_is_rejected_before_com(self): + for args in ({"duration_sec": 0}, {"time_sec": -1}, {"time_sec": float("nan")}, {}): + with self.subTest(args=args), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.set_motion_study_timing({"name": "study", **args}) + manager.assert_not_called() + + def test_out_of_bounds_cursor_cannot_change_duration(self): + with patch.object(sw_motion, "_manager") as manager, patch.object(sw_motion, "_study") as study, \ + patch.object(sw_motion, "_info", return_value={"duration_sec": 5}): + with self.assertRaises(RuntimeError): + sw_motion.set_motion_study_timing({"name": "study", "duration_sec": 6, "time_sec": 7}) + manager.return_value.ActivateMotionStudy.assert_not_called() + study.return_value.SetDuration.assert_not_called() + + def test_unavailable_solver_is_not_assigned(self): + study = SimpleNamespace(StudyType=1) + with patch.object(sw_motion, "_supported", return_value=1), patch.object(sw_motion, "_info", return_value={}): + self.assertFalse(sw_motion._set_type(study, 4)["ok"]) + self.assertEqual(study.StudyType, 1) + + def test_supported_type_mask_reads_byref_output(self): + def supported(mask): + mask.value = 3 + return True + self.assertEqual(sw_motion._supported(SimpleNamespace(GetSupportedStudyTypes=supported)), 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_native_features.py b/tests/test_native_features.py new file mode 100644 index 0000000..e2307cd --- /dev/null +++ b/tests/test_native_features.py @@ -0,0 +1,100 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Native feature boundary checks; geometry acceptance stays in live tests.""" + +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import Mock, patch + +from solidworks_mcp import sw_core, sw_multibody, sw_sheetmetal, sw_weldment + + +class SheetMetalBoundaryTests(unittest.TestCase): + def test_invalid_sheet_parameters_are_rejected_before_com(self): + for args in ({"thickness_mm": 0}, {"thickness_mm": 1, "bend_radius_mm": -1}, + {"thickness_mm": 1, "k_factor": 1.1}, {"thickness_mm": 1, "k_factor": float("nan")}): + with self.subTest(args=args), patch.object(sw_sheetmetal, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_sheetmetal.sheet_metal_base_flange(args) + require.assert_not_called() + + def test_empty_parameter_update_cannot_roll_back_the_model(self): + with patch.object(sw_sheetmetal, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_sheetmetal.set_sheet_metal_parameters({}) + require.assert_not_called() + + def test_parameter_edit_uses_native_template_defaults(self): + template = object() + ext = SimpleNamespace(GetTemplateSheetMetal=lambda: template) + with patch.object(sw_sheetmetal, "extension", return_value=ext): + self.assertIs(sw_sheetmetal._sheet_feature(object(), None), template) + + def test_export_refuses_an_unsaved_part_before_creating_a_file(self): + doc = SimpleNamespace(GetPathName=lambda: "") + with patch.object(sw_sheetmetal, "require_part", return_value=(None, doc)), \ + patch.object(sw_sheetmetal, "validated_output_path") as output: + self.assertFalse(sw_sheetmetal.export_flat_pattern({"path": "part.dxf"})["ok"]) + output.assert_not_called() + + +class SurfaceBoundaryTests(unittest.TestCase): + def test_knit_tolerance_is_validated_before_selection(self): + for tolerance in (0, 1, float("nan")): + with self.subTest(tolerance=tolerance), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.knit_surfaces({"selection": {"surface_bodies": [0, 1]}, "tolerance_mm": tolerance}) + require.assert_not_called() + + def test_surface_indices_resolve_from_the_sheet_body_list(self): + sheet = object() + doc = object() + with patch.object(sw_core, "clear_selection"), \ + patch.object(sw_core, "get_bodies", return_value=[sheet]) as bodies, \ + patch.object(sw_core, "select_body", return_value=True) as select: + self.assertEqual(sw_core.apply_selection(doc, {"surface_bodies": [0]}, mark=1), 1) + bodies.assert_called_once_with(doc, sw_core.BODY_SHEET) + select.assert_called_once_with(doc, sheet, 1, True) + + def test_out_of_range_surface_indices_cannot_select_a_solid_body(self): + with patch.object(sw_core, "clear_selection"), \ + patch.object(sw_core, "get_bodies", return_value=[]) as bodies, \ + patch.object(sw_core, "select_body") as select: + with self.assertRaises(RuntimeError): + sw_core.apply_selection(object(), {"surface_bodies": [0]}) + select.assert_not_called() + + +class WeldmentBoundaryTests(unittest.TestCase): + def test_only_existing_native_profile_files_are_accepted(self): + with tempfile.TemporaryDirectory() as folder: + invalid = Path(folder) / "profile.step" + invalid.write_text("fixture", encoding="utf-8") + with self.assertRaises(RuntimeError): + sw_weldment._profile_path(invalid) + with self.assertRaises(RuntimeError): + sw_weldment._profile_path(Path(folder) / "missing.sldlfp") + + def test_invalid_group_index_is_rejected_before_feature_creation(self): + with tempfile.TemporaryDirectory() as folder: + profile = Path(folder) / "profile.sldlfp" + profile.write_bytes(b"fixture") + with patch.object(sw_weldment, "require_part", return_value=(None, object())), \ + patch.object(sw_weldment, "exit_active_sketch"), \ + patch.object(sw_weldment, "sketch_segment_objects", return_value=[object()]), \ + patch.object(sw_weldment, "feature_manager") as manager: + with self.assertRaises(RuntimeError): + sw_weldment.insert_structural_member({"profile_path": str(profile), "groups": [{"sketch_name": "Path", "segments": [1]}]}) + manager.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_api_inventory.py b/tools/build_api_inventory.py new file mode 100644 index 0000000..d7aeedc --- /dev/null +++ b/tools/build_api_inventory.py @@ -0,0 +1,92 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Inventory native SOLIDWORKS APIs without loading any add-in or invoking members.""" + +import argparse +import json +from pathlib import Path + +import pythoncom +from tlb_probe import find_library + + +def read_interfaces(library): + interfaces = [] + for index in range(library.GetTypeInfoCount()): + info = library.GetTypeInfo(index) + attr = info.GetTypeAttr() + name = library.GetDocumentation(index)[0] + members = [] + for slot in range(attr.cFuncs): + descriptor = info.GetFuncDesc(slot) + names = info.GetNames(descriptor.memid) + if not names or names[0] in {"QueryInterface", "AddRef", "Release", "GetTypeInfoCount", "GetTypeInfo", "GetIDsOfNames", "Invoke"}: + continue + members.append({"name": names[0], "parameters": list(names[1:]), "parameter_count": len(descriptor.args), + "invocation_kind": descriptor.invkind, "flags": descriptor.wFuncFlags}) + if members: + interfaces.append({"name": name, "members": members}) + return interfaces + + +def inventory(include_official=False): + path = find_library("sldworks.tlb") + interfaces = read_interfaces(pythoncom.LoadTypeLib(str(path))) + constants = pythoncom.LoadTypeLib(str(find_library("swconst.tlb"))) + feature_types = [] + for index in range(constants.GetTypeInfoCount()): + if constants.GetDocumentation(index)[0] != "swFeatureNameID_e": + continue + info = constants.GetTypeInfo(index) + for slot in range(info.GetTypeAttr().cVars): + descriptor = info.GetVarDesc(slot) + feature_types.append({"name": info.GetNames(descriptor.memid)[0], "value": descriptor.value}) + modules = [] + if include_official: + for name, relative in ( + ("Motion", "swmotionstudy.tlb"), ("Simulation", "Simulation/cosworks.dll"), + ("Routing", "SWRoutingLib.tlb"), ("Costing", "sldcostingapi.tlb"), + ("DimXpert", "swdimxpert.tlb"), ("Inspection", "swinspectionAddIn.tlb"), + ("Sustainability", "sustainability.tlb"), ("DFMXpress", "DFMXpress/dfmaddin.tlb"), + ("ScanTo3D", "swscanto3d.tlb"), ("CAM", "CAMWorks/SWCAM.tlb"), + ): + target = path.parent / relative + entry = {"module": name, "library": str(target)} + if not target.is_file(): + entry.update(status="library_not_found", note="该候选路径未找到类型库,不等同于模块未安装。") + else: + try: + extra = read_interfaces(pythoncom.LoadTypeLib(str(target))) + entry.update(status="inventoried", interface_count=len(extra), + member_count=sum(len(i["members"]) for i in extra), interfaces=extra) + except Exception as exc: + entry.update(status="unreadable_library", error=str(exc)) + modules.append(entry) + return {"scope": "SOLIDWORKS 核心与明确列出的官方随附类型库;第三方插件排除", "library": str(path), + "interface_count": len(interfaces), "member_count": sum(len(i["members"]) for i in interfaces), + "feature_types": feature_types, "interfaces": interfaces, "official_modules": modules, + "note": "API 清单用于发现缺口,不是功能覆盖率,也不表示这些 API 已封装或验证。"} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--official-modules", action="store_true", help="Also inventory known official module type libraries; never load add-ins.") + args = parser.parse_args() + payload = inventory(args.official_modules) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps({k: payload[k] for k in ("interface_count", "member_count")}, ensure_ascii=False)) + print(f"原生特征类型数:{len(payload['feature_types'])}") + for module in payload["official_modules"]: + print(json.dumps({k: v for k, v in module.items() if k not in {"interfaces", "library"}}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py new file mode 100644 index 0000000..7b0b6b2 --- /dev/null +++ b/tools/build_coverage_report.py @@ -0,0 +1,78 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Join native feature types to actual registered tools; keep unimplemented types visible.""" + +import argparse +import json +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from solidworks_mcp import server + + +FEATURE_TOOLS = { + "swFmChamfer": ["chamfer"], "swFmFillet": ["fillet"], "swFmDraft": ["draft"], + "swFmCirPattern": ["circular_pattern"], "swFmLPattern": ["linear_pattern"], + "swFmMirrorPattern": ["mirror_feature"], "swFmShell": ["shell"], + "swFmBlend": ["loft"], "swFmBlendCut": ["loft"], "swFmExtrusion": ["boss_extrude"], + "swFmBoss": ["boss_extrude"], "swFmCut": ["cut_extrude"], "swFmRevolution": ["revolve"], + "swFmRevCut": ["revolve"], "swFmSweep": ["sweep"], "swFmSweepCut": ["sweep"], + "swFmThicken": ["thicken_surface"], "swFmSheetMetal": ["sheet_metal_base_flange", "get_sheet_metal_parameters", "set_sheet_metal_parameters"], + "swFmBaseFlange": ["sheet_metal_base_flange"], "swFmFlatPattern": ["set_flat_pattern", "export_flat_pattern"], + "swFmCenterMark": ["insert_center_marks"], "swFmDrSheet": ["add_sheet", "activate_sheet"], + "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], + "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], + "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "planar_surface", "offset_surface", "knit_surfaces"], + "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], +} + +MODULE_TOOLS = {"Motion": ["list_motion_studies", "create_motion_study", "activate_motion_study", + "duplicate_motion_study", "delete_motion_study", "set_motion_study_type", + "set_motion_study_timing"]} + + +def report(api): + names = {tool.name for tool in server.TOOLS} + unknown = {name for tools in FEATURE_TOOLS.values() for name in tools} - names + if unknown: + raise RuntimeError(f"Coverage mapping names tools that do not exist: {sorted(unknown)}") + features = [{**feature, "tools": FEATURE_TOOLS.get(feature["name"], []), + "status": "implemented_partial" if feature["name"] in FEATURE_TOOLS else "unmapped"} + for feature in api["feature_types"]] + return { + "objective": "覆盖 SolidWorks 原生及官方随附功能;不包含嘉立创等第三方插件", + "goal_complete": False, + "note": "已关联工具只代表该特征类别存在部分实现;不等于所有参数、子操作、版本或官方模块已完成。", + "registered_tool_count": len(names), + "feature_types": features, + "official_modules": [{k: v for k, v in module.items() if k != "interfaces"} | + {"implementation_status": "implemented_partial" if module["module"] in MODULE_TOOLS else "pending", + "tools": MODULE_TOOLS.get(module["module"], [])} + for module in api.get("official_modules", [])], + "tools": [tool.model_dump(mode="json", exclude_none=True) for tool in server.TOOLS], + "completion_gates": {"all_required_operations_implemented": False, "all_required_operations_live_verified": False, + "official_modules_verified": False, "installation_and_protocol_verified_for_final_release": False}, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--inventory", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + payload = report(json.loads(args.inventory.read_text(encoding="utf-8"))) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"tools={payload['registered_tool_count']}, feature_types={len(payload['feature_types'])}, goal_complete=False") + + +if __name__ == "__main__": + main() From 1e513a7516b659213f4928d00f427b35e6e4ffdc Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:56:48 +0800 Subject: [PATCH 07/31] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=8F=82=E8=80=83=E7=82=B9=E5=9D=90=E6=A0=87?= =?UTF-8?q?=E7=B3=BB=E5=92=8C=E6=9B=B2=E7=BA=BF=E5=B7=A5=E5=85=B7=E5=B9=B6?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=8E=9F=E7=94=9F=E5=87=A0=E4=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 10 +- README.md | 23 ++- solidworks_mcp/server.py | 1 + solidworks_mcp/sw_geometry.py | 292 ++++++++++++++++++++++++++++++ tests/live_geometry_regression.py | 147 +++++++++++++++ tests/test_geometry.py | 87 +++++++++ tools/build_coverage_report.py | 2 + tools/verify_install.py | 95 ++++++++++ 8 files changed, 655 insertions(+), 2 deletions(-) create mode 100644 solidworks_mcp/sw_geometry.py create mode 100644 tests/live_geometry_regression.py create mode 100644 tests/test_geometry.py create mode 100644 tools/verify_install.py diff --git a/COVERAGE.md b/COVERAGE.md index 8c49804..f2bb76a 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,12 +47,20 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 142 个,仍未全覆盖。当前批次新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 153 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回。 +随后新增 11 个直接编辑、参考点、坐标系及 XYZ/组合曲线工具。 +移动面的偏移、平移、旋转,删除面与修补孔,以及替换面均通过实际体积或面积检查; +参考点与坐标系读回实际坐标和矩阵,XYZ 曲线创建/修改读回实际通过点。 +组合曲线使用原生选择标记 1,曲线点数按实际坐标数组长度转换。 +删除面的填充、相切填充等模式仍保留为未验证细项,不能据此宣称直接编辑全覆盖。 +参考点的圆心、中点、面中心、距离/百分比/均匀分布、草图点、交点和投影到模型面已验证。 +投影到基准面在本机 `InsertReferencePoint` 实测返回空,仍是待补齐项;未从目标中排除。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index 3b55d62..a43f783 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -142 tools: sketching with real relations and driving dimensions, the solid +153 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -134,6 +134,16 @@ late-bound Python cannot QueryInterface a `Face2` to `IEntity`. `list_reference_planes`, `create_plane` (offset / angle / midplane / three points / parallel-through-point), `create_axis`. +`create_reference_points` / `list_reference_points` 支持圆心、直线中点、面中心、 +距离/百分比分布、均匀分布、交点、投影及草图点,读回模型坐标。 +`create_coordinate_system` / `list_coordinate_systems` 创建与查询坐标系, +原点单位为 mm,输入角度为度;数值坐标系创建需要 SolidWorks 2022+。 +参考点投影当前支持模型面,基准面投影仍是覆盖缺口。 + +`create_curve_through_points` / `get_curve_points` / `set_curve_points` 创建、 +读取和修改通过 XYZ 点的原生参考曲线,输入和输出坐标均为 mm。 +`composite_curve` 将相连的草图、边或参考曲线生成组合曲线。 + ### Sketching `create_sketch` (on a plane **or a model face**), `edit_sketch`, `close_sketch`, `list_sketches`, `list_sketch_segments`, `get_sketch_status`. @@ -161,6 +171,17 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `check_errors` | What SOLIDWORKS thinks is wrong — see below. | | `set_view` | Named view plus zoom-to-fit. | +### 直接编辑 + +| 工具 | 功能 | +| --- | --- | +| `move_faces` | 面偏移、按 XYZ 平移或绕指定原点旋转;长度为 mm、角度为度。 | +| `delete_faces` | 删除面,可选择修补、填充或相切填充;仅删除可能将实体变成曲面。 | +| `replace_faces` | 用指定曲面替换模型面,目标面与替换曲面分别使用原生选择标记。 | + +拓扑改变后重新查询面和边。删除与修补、替换面已通过实机体积/面积检查; +删除面的填充及相切填充模式仍待专项验证。 + ### Assemblies `list_components`, `insert_component`, `add_mate`, `list_mates`, `set_component_fixed`, `set_component_visibility`, `set_component_suppression`, diff --git a/solidworks_mcp/server.py b/solidworks_mcp/server.py index 5714e0a..3f2a44c 100644 --- a/solidworks_mcp/server.py +++ b/solidworks_mcp/server.py @@ -64,6 +64,7 @@ from . import sw_sheetmetal # noqa: F401 from . import sw_weldment # noqa: F401 from . import sw_motion # noqa: F401 +from . import sw_geometry # noqa: F401 server = Server("solidworks-mcp") diff --git a/solidworks_mcp/sw_geometry.py b/solidworks_mcp/sw_geometry.py new file mode 100644 index 0000000..2393f96 --- /dev/null +++ b/solidworks_mcp/sw_geometry.py @@ -0,0 +1,292 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Direct editing, reference points, coordinate systems and native XYZ curves.""" + +import math + +from .sw_core import ( + SELECTION_SCHEMA, active_document, as_list, document_type, double_array, + clear_selection, exit_active_sketch, extension, feature_manager, feature_property, + find_feature, flag_methods, iter_feature_objects, nothing, require_part, + require_selection, result, to_m, to_mm, to_rad, to_deg, tool, value, +) +from .sw_feature import _feature_created_after, _feature_names +from .sw_multibody import _finish + +NAME = {"type": "string"} +VECTOR = {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3} +POINTS = {"type": "array", "items": VECTOR, "minItems": 2} +DELETE_OPTIONS = {"delete": 0, "patch": 1, "fill": 2, "fill_tangent": 3} +POINT_MODES = {"arc_center": 3, "edge_midpoint": 2, "face_center": 4, "along_distance": 2, "along_percentage": 2, "evenly": 2, + "projection": 5, "intersection": 6, "sketch_point": 7} + + +def _vector(raw, label): + if not isinstance(raw, (list, tuple)) or len(raw) != 3: + raise RuntimeError(f"{label} requires exactly three finite coordinates.") + values = [float(number) for number in raw] + if not all(math.isfinite(number) for number in values): + raise RuntimeError(f"{label} requires exactly three finite coordinates.") + return values + + +def _model(): + _, doc = active_document() + if document_type(doc) not in (1, 2): + raise RuntimeError("Reference geometry requires a part or assembly.") + exit_active_sketch(doc) + return doc + + +def _faces_only(selection): + if not selection or set(selection) - {"faces", "feature_faces", "points"}: + raise RuntimeError("Select faces from list_faces, feature_faces, or FACE point picks.") + if any(p.get("type", "FACE") != "FACE" for p in selection.get("points", [])): + raise RuntimeError("Only FACE point picks are accepted for direct editing.") + + +@tool("move_faces", "Create a native Move Face feature: offset faces, translate by XYZ mm, or rotate by XYZ degrees about an origin in mm. Re-list topology afterwards.", + {"selection": SELECTION_SCHEMA, "mode": {"type": "string", "enum": ["offset", "translate", "rotate"]}, + "distance_mm": {"type": "number"}, "translation_mm": VECTOR, "rotation_deg": VECTOR, "origin_mm": VECTOR, + "reverse": {"type": "boolean", "default": False}, "name": NAME}, ["selection", "mode"]) +def move_faces(args): + _faces_only(args["selection"]) + mode = args["mode"] + translation, rotation = nothing(), nothing() + distance = 0.0 + if mode == "offset": + distance = float(args["distance_mm"]) + if not math.isfinite(distance) or distance == 0: + raise RuntimeError("Offset distance must be finite and nonzero.") + elif mode == "translate": + delta = _vector(args["translation_mm"], "translation_mm") + if not any(delta): + raise RuntimeError("Translation must move the faces.") + translation = double_array([to_m(n) for n in delta]) + elif mode == "rotate": + angles = _vector(args["rotation_deg"], "rotation_deg") + origin = _vector(args.get("origin_mm", [0, 0, 0]), "origin_mm") + if not any(angles): + raise RuntimeError("Rotation must move the faces.") + rotation = double_array([to_m(n) for n in origin] + [to_rad(n) for n in angles]) + else: + raise RuntimeError("Unknown Move Face mode.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["selection"], mark=1) + manager = flag_methods(feature_manager(doc), "InsertMoveFace3") + feature = manager.InsertMoveFace3({"offset": 0, "translate": 1, "rotate": 2}[mode], bool(args.get("reverse", False)), + 0.0, to_m(distance), translation, rotation, 0, 0.0) + payload = _finish(doc, feature, args, "face movement") + if payload["ok"]: + definition = value(feature, "GetDefinition") + payload["data"]["move_type"] = int(value(definition, "MoveType")) + payload["data"]["distance_mm"] = to_mm(float(value(definition, "Distance"))) + payload["data"]["translation_mm"] = [to_mm(n) for n in as_list(value(definition, "TriadTranslationParameters"))] + parameters = as_list(value(definition, "TriadRotationParameters")) + payload["data"]["rotation_origin_mm"] = [to_mm(n) for n in parameters[:3]] + payload["data"]["rotation_deg"] = [to_deg(n) for n in parameters[3:6]] + return payload + + +@tool("delete_faces", "Create a native Delete Face feature, optionally patching or filling the removed faces. delete may convert a solid into a surface body.", + {"selection": SELECTION_SCHEMA, "mode": {"type": "string", "enum": list(DELETE_OPTIONS), "default": "delete"}, "name": NAME}, ["selection"]) +def delete_faces(args): + _faces_only(args["selection"]) + mode = args.get("mode", "delete") + if mode not in DELETE_OPTIONS: + raise RuntimeError("Unknown Delete Face mode.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["selection"]) + before = _feature_names(doc) + accepted = bool(flag_methods(extension(doc), "InsertDeleteFace").InsertDeleteFace(DELETE_OPTIONS[mode])) + payload = _finish(doc, _feature_created_after(doc, before), args, "face deletion") + if not accepted: + payload.update(ok=False, message="Native Delete Face was not accepted.") + return payload + + +@tool("replace_faces", "Replace selected model faces with selected replacement surfaces. faces_to_replace uses face indices; replacement can use surface_bodies or faces. Re-list topology afterwards.", + {"faces_to_replace": SELECTION_SCHEMA, "replacement": SELECTION_SCHEMA, "name": NAME}, ["faces_to_replace", "replacement"]) +def replace_faces(args): + _faces_only(args["faces_to_replace"]) + if not args["replacement"] or set(args["replacement"]) - {"surface_bodies", "faces", "features", "points"}: + raise RuntimeError("Select replacement surfaces or faces.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["faces_to_replace"], mark=1) + require_selection(doc, args["replacement"], mark=2, append=True) + before = _feature_names(doc) + flag_methods(doc, "InsertFeatureReplaceFace").InsertFeatureReplaceFace() + return _finish(doc, _feature_created_after(doc, before), args, "face replacement") + + +def _point_info(feature): + point = value(value(feature, "GetSpecificFeature2"), "GetRefPoint") + xyz = list(value(point, "ArrayData")) + return {"name": str(value(feature, "Name")), "position_mm": [to_mm(n) for n in xyz[:3]]} + + +@tool("list_reference_points", "Read-only: list native reference points and their model coordinates in mm.") +def list_reference_points(args): + _, doc = active_document() + return result(True, "Read reference points.", points=[_point_info(f) for f in iter_feature_objects(doc) + if str(feature_property(f, "GetTypeName2", "")) == "RefPoint"]) + + +@tool("create_reference_points", "Create native reference points at arc centers, edge midpoints, face centers, along a curve, by projection onto a model face/intersection, or on a sketch point. Percentage uses 0..100; distance is mm. Along-curve modes support count points. Reference-plane projection remains unavailable in this implementation.", + {"selection": SELECTION_SCHEMA, "mode": {"type": "string", "enum": list(POINT_MODES)}, + "distance_mm": {"type": "number", "minimum": 0}, "percentage": {"type": "number", "minimum": 0, "maximum": 100}, + "count": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 1}, "name": NAME}, ["selection", "mode"]) +def create_reference_points(args): + mode = args["mode"] + if mode not in POINT_MODES: + raise RuntimeError("Unknown reference point mode.") + if mode == "projection" and args["selection"].get("planes"): + raise RuntimeError("Reference-plane projection remains a coverage gap; native model-face projection is supported.") + count = args.get("count", 1) + if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= 1000 or (mode not in {"evenly", "along_distance", "along_percentage"} and count != 1): + raise RuntimeError("count must be 1, except along-curve modes allow 1..1000 points.") + distance = float(args.get("distance_mm", 0)) + percent = float(args.get("percentage", 50)) + if not math.isfinite(distance) or distance < 0 or not math.isfinite(percent) or not 0 <= percent <= 100: + raise RuntimeError("Point distance must be finite/nonnegative; percentage must be 0..100.") + doc = _model() + require_selection(doc, args["selection"]) + manager = flag_methods(feature_manager(doc), "InsertReferencePoint") + created = as_list(manager.InsertReferencePoint(POINT_MODES[mode], {"along_distance": 0, "along_percentage": 1, "edge_midpoint": 1, "evenly": 2}.get(mode, 0), + 50.0 if mode == "edge_midpoint" else percent if mode == "along_percentage" else to_m(distance), count)) + if not created: + return result(False, "No reference points were created.") + payloads = [_finish(doc, f, {"name": f"{args['name']}_{i+1}" if args.get("name") and count > 1 else args.get("name")}, "reference point") + for i, f in enumerate(created)] + points = [_point_info(f) for f in created] + return result(len(points) == count and all(p["ok"] for p in payloads), "Read created reference points.", points=points, features=payloads) + + +def _coordinate_info(feature): + transform = value(value(feature, "GetDefinition"), "Transform") + matrix = list(value(transform, "ArrayData")) + return {"name": str(value(feature, "Name")), "origin_mm": [to_mm(n) for n in matrix[9:12]], + "rotation_matrix": matrix[:9], "transform": matrix} + + +@tool("list_coordinate_systems", "Read-only: list native coordinate systems with origin in mm and rotation matrices.") +def list_coordinate_systems(args): + _, doc = active_document() + return result(True, "Read coordinate systems.", coordinate_systems=[_coordinate_info(f) for f in iter_feature_objects(doc) + if str(feature_property(f, "GetTypeName2", "")) == "CoordSys"]) + + +@tool("create_coordinate_system", "Create a coordinate system from XYZ location in mm and XYZ orientation in degrees relative to the global system. Requires SOLIDWORKS 2022+ numerical-coordinate API.", + {"origin_mm": VECTOR, "rotation_deg": VECTOR, "name": NAME}, ["origin_mm"]) +def create_coordinate_system(args): + origin = _vector(args["origin_mm"], "origin_mm") + rotation = _vector(args.get("rotation_deg", [0, 0, 0]), "rotation_deg") + doc = _model() + clear_selection(doc) + manager = flag_methods(feature_manager(doc), "CreateCoordinateSystemUsingNumericalValues") + feature = manager.CreateCoordinateSystemUsingNumericalValues(True, *[to_m(n) for n in origin], True, *[to_rad(n) for n in rotation]) + payload = _finish(doc, feature, args, "coordinate system") + if payload["ok"]: + payload["data"]["coordinate_system"] = _coordinate_info(feature) + return payload + + +def _curve_points(raw): + if not isinstance(raw, (list, tuple)) or len(raw) < 2: + raise RuntimeError("Provide at least two distinct curve points.") + points = [_vector(point, "points_mm") for point in raw] + if any(points[i] == points[i - 1] for i in range(1, len(points))): + raise RuntimeError("Consecutive curve points must be distinct.") + return points + + +def _curve_feature(doc, name): + feature = find_feature(doc, name) + if feature is None or str(feature_property(feature, "GetTypeName2", "")) != "CurveInFile": + raise RuntimeError("Specify an existing XYZ through-points curve feature.") + return feature + + +def _curve_info(feature): + definition = value(feature, "GetDefinition") + flat = as_list(value(definition, "PointArray")) + # Despite its name, GetPointCount returns the number of coordinate doubles. + coordinate_count = int(value(definition, "GetPointCount")) + if len(flat) != coordinate_count or coordinate_count < 6 or coordinate_count % 3: + raise RuntimeError("Native curve point data has an unexpected shape.") + return {"name": str(value(feature, "Name")), "point_count": coordinate_count // 3, + "points_mm": [[to_mm(n) for n in flat[i:i+3]] for i in range(0, len(flat), 3)]} + + +@tool("get_curve_points", "Read-only: read actual ordered through-points of an XYZ reference curve feature in mm.", {"name": NAME}, ["name"]) +def get_curve_points(args): + _, doc = require_part() + return result(True, "Read native curve points.", curve=_curve_info(_curve_feature(doc, args["name"]))) + + +@tool("create_curve_through_points", "Create a native XYZ reference curve through ordered model-space points in mm without an intermediate file.", + {"points_mm": POINTS, "name": NAME}, ["points_mm"]) +def create_curve_through_points(args): + points = _curve_points(args["points_mm"]) + _, doc = require_part() + exit_active_sketch(doc) + clear_selection(doc) + before = _feature_names(doc) + flag_methods(doc, "InsertCurveFileBegin", "InsertCurveFilePoint", "InsertCurveFileEnd") + doc.InsertCurveFileBegin() + accepted = True + try: + for point in points: + accepted = bool(doc.InsertCurveFilePoint(*[to_m(n) for n in point])) and accepted + finally: + accepted = bool(doc.InsertCurveFileEnd()) and accepted + feature = _feature_created_after(doc, before) + payload = _finish(doc, feature, args, "XYZ through-points curve") + if payload["ok"]: + payload["data"]["curve"] = _curve_info(feature) + actual = payload["data"]["curve"]["points_mm"] + matches = len(actual) == len(points) and all(math.isclose(a, b, abs_tol=1e-6) for p, q in zip(actual, points) for a, b in zip(p, q)) + if not accepted or not matches: + payload.update(ok=False, message="Native curve creation or point readback failed.") + return payload + + +@tool("set_curve_points", "Replace the ordered points of an existing XYZ curve. Coordinates are model-space mm; dependent features rebuild.", + {"name": NAME, "points_mm": POINTS}, ["name", "points_mm"]) +def set_curve_points(args): + points = _curve_points(args["points_mm"]) + _, doc = require_part() + exit_active_sketch(doc) + feature = _curve_feature(doc, args["name"]) + definition = value(feature, "GetDefinition") + definition.PointArray = double_array([to_m(n) for point in points for n in point]) + accepted = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(definition, doc, nothing())) + payload = _finish(doc, feature, {}, "XYZ curve edit") + actual = _curve_info(feature) + payload["data"]["curve"] = actual + if not accepted or len(actual["points_mm"]) != len(points) or not all(math.isclose(a, b, abs_tol=1e-6) for p, q in zip(actual["points_mm"], points) for a, b in zip(p, q)): + payload.update(ok=False, message="Native curve edit or point readback failed.") + return payload + + +@tool("composite_curve", "Create a native composite reference curve from connected edges, sketches or reference curves. Select an ordered connected chain.", + {"selection": SELECTION_SCHEMA, "name": NAME}, ["selection"]) +def composite_curve(args): + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["selection"], mark=1) + before = _feature_names(doc) + accepted = bool(flag_methods(doc, "InsertCompositeCurve").InsertCompositeCurve()) + payload = _finish(doc, _feature_created_after(doc, before), args, "composite curve") + if not accepted: + payload.update(ok=False, message="Native composite curve creation failed.") + return payload diff --git a/tests/live_geometry_regression.py b/tests/live_geometry_regression.py new file mode 100644 index 0000000..332a961 --- /dev/null +++ b/tests/live_geometry_regression.py @@ -0,0 +1,147 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Direct-edit volumes and native reference geometry readback on scratch parts.""" + +import math + +from live_expansion_regression import block, call, near, volume +from solidworks_mcp.sw_core import as_list, byref_long, running_app, value + + +def top_face(): + faces = call("list_faces", normal=[0, 0, 1])["faces"] + assert len(faces) == 1, faces + return faces[0]["index"] + + +def test_direct_edit(): + block() + call("move_faces", selection={"faces": [top_face()]}, mode="offset", distance_mm=5) + near(volume(), 1500, "offset face volume") + call("move_faces", selection={"faces": [top_face()]}, mode="translate", translation_mm=[0, 0, 5]) + near(volume(), 2000, "translated face volume") + block() + call("move_faces", selection={"faces": [top_face()]}, mode="rotate", rotation_deg=[30, 0, 0], origin_mm=[5, 5, 10]) + near(volume(), 1000, "rotated top face volume") + tilted = call("list_faces", normal=[0, -0.5, math.sqrt(3)/2])["faces"] + assert len(tilted) == 1, tilted + block() + call("delete_faces", selection={"faces": [top_face()]}) + sheets = call("list_surface_bodies")["surface_bodies"] + assert len(sheets) == 1 and sheets[0]["face_count"] == 5, sheets + near(sheets[0]["area_mm2"], 500, "open cube surface area") + block() + call("create_sketch", plane="front", name="Hole") + call("draw_circle", x_mm=5, y_mm=5, radius_mm=1) + call("close_sketch") + call("cut_extrude", sketch_name="Hole", end_condition="through_all_both") + near(volume(), 1000 - math.pi*10, "hole volume") + cylindrical = call("list_faces", surface_type="cylinder")["faces"] + assert len(cylindrical) == 1, cylindrical + call("delete_faces", selection={"faces": [cylindrical[0]["index"]]}, mode="patch") + near(volume(), 1000, "healed cylindrical face volume") + block() + call("offset_surface", selection={"faces": [top_face()]}, distance_mm=5) + call("replace_faces", faces_to_replace={"faces": [top_face()]}, replacement={"surface_bodies": [0]}) + near(volume(), 1500, "replacement face volume") + + +def test_reference_geometry(): + block() + face = top_face() + point = call("create_reference_points", mode="face_center", selection={"faces": [face]}, name="TopCenter")["points"][0] + for a, b in zip(point["position_mm"], [5, 5, 10]): + near(a, b, "face center reference point") + edge = next(e for e in call("list_edges", curve_type="line")["edges"] + if e["start_mm"][0] != e["end_mm"][0] and abs(e["start_mm"][1]) < 1e-6 and abs(e["start_mm"][2]) < 1e-6) + center = call("create_reference_points", mode="edge_midpoint", selection={"edges": [edge["index"]]})["points"][0] + near(center["position_mm"][0], 5, "edge center") + distributed = call("create_reference_points", mode="evenly", count=3, selection={"edges": [edge["index"]]}, name="EdgePoints")["points"] + coords = sorted(p["position_mm"][0] for p in distributed) + for a, b in zip(coords, [2.5, 5, 7.5]): + near(a, b, "evenly distributed point") + assert len(call("list_reference_points")["points"]) == 5 + cs = call("create_coordinate_system", origin_mm=[2, 3, 4], rotation_deg=[0, 0, 90], name="LocalAxes")["coordinate_system"] + for a, b in zip(cs["origin_mm"], [2, 3, 4]): + near(a, b, "coordinate origin") + for a, b in zip(cs["rotation_matrix"][:3], [0, 1, 0]): + near(a, b, "coordinate X orientation") + assert len(call("list_coordinate_systems")["coordinate_systems"]) == 1 + distances = call("create_reference_points", mode="along_distance", distance_mm=2, count=2, + selection={"edges": [edge["index"]]})["points"] + distance_x = sorted(p["position_mm"][0] for p in distances) + # Edge orientation can be reversed; successive positions must be 2 mm apart. + near(distance_x[1] - distance_x[0], 2, "distance point spacing") + percentages = call("create_reference_points", mode="along_percentage", percentage=20, count=2, + selection={"edges": [edge["index"]]})["points"] + percentage_x = sorted(p["position_mm"][0] for p in percentages) + near(percentage_x[1] - percentage_x[0], 2, "percentage point spacing") + call("create_sketch", plane="front", name="CirclePoint") + call("draw_circle", x_mm=3, y_mm=4, radius_mm=2) + call("close_sketch") + center = call("create_reference_points", mode="arc_center", selection={"sketch_name": "CirclePoint", "sketch_segments": [0]})["points"][0] + for a, b in zip(center["position_mm"], [3, 4, 0]): + near(a, b, "arc center") + call("create_sketch", plane="front", name="StandalonePoint") + call("draw_point", x_mm=7, y_mm=8) + call("close_sketch") + point = call("create_reference_points", mode="sketch_point", selection={"sketch_name": "StandalonePoint", "sketch_points": [0]})["points"][0] + for a, b in zip(point["position_mm"], [7, 8, 0]): + near(a, b, "sketch reference point") + call("create_sketch", plane="front", name="Crossing") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + call("draw_line", x1_mm=0, y1_mm=10, x2_mm=10, y2_mm=0) + call("close_sketch") + intersection = call("create_reference_points", mode="intersection", selection={"sketch_name": "Crossing", "sketch_segments": [0, 1]})["points"][0] + for a, b in zip(intersection["position_mm"], [5, 5, 0]): + near(a, b, "intersection reference point") + vertex = next(v for v in call("list_vertices")["vertices"] if v["point_mm"] == [10, 10, 10]) + bottom = call("list_faces", normal=[0, 0, -1])["faces"][0]["index"] + projected = call("create_reference_points", mode="projection", selection={"vertices": [vertex["index"]], "faces": [bottom]})["points"][0] + for a, b in zip(projected["position_mm"], [10, 10, 0]): + near(a, b, "projected reference point") + + +def test_curves(): + call("create_new_document", kind="part") + points = [[0, 0, 0], [10, 5, 2], [20, 0, 4], [30, 5, 6]] + call("create_curve_through_points", points_mm=points, name="XYZPath") + assert call("get_curve_points", name="XYZPath")["curve"]["points_mm"] == points + edited = [[0, 0, 0], [20, 10, 4], [40, 0, 8]] + call("set_curve_points", name="XYZPath", points_mm=edited) + assert call("get_curve_points", name="XYZPath")["curve"]["points_mm"] == edited + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="Chain") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=0) + call("draw_line", x1_mm=10, y1_mm=0, x2_mm=20, y2_mm=10) + call("close_sketch") + call("composite_curve", selection={"sketches": ["Chain"]}, name="CompositePath") + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + test_direct_edit() + test_reference_geometry() + test_curves() + print("Direct editing, reference points, coordinate transform and XYZ/composite curves verified.", flush=True) + finally: + titles = [str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))] + for title in titles: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..20734e2 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,87 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from solidworks_mcp import sw_geometry + + +class GeometryBoundaryTests(unittest.TestCase): + def test_face_edit_rejects_nonfaces_before_com(self): + for selection in ({"edges": [0]}, {"bodies": [0]}, {"points": [{"type": "EDGE"}]}, {}): + with self.subTest(selection=selection), patch.object(sw_geometry, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_geometry.move_faces({"selection": selection, "mode": "offset", "distance_mm": 1}) + require.assert_not_called() + + def test_move_vectors_are_validated_before_com(self): + for args in ({"mode": "offset", "distance_mm": float("nan")}, {"mode": "offset", "distance_mm": 0}, + {"mode": "translate", "translation_mm": [0, 0, 0]}, + {"mode": "rotate", "rotation_deg": [1, 2]}, + {"mode": "rotate", "rotation_deg": [1, 0, 0], "origin_mm": [0, float("inf"), 0]}): + with self.subTest(args=args), patch.object(sw_geometry, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_geometry.move_faces({"selection": {"faces": [0]}, **args}) + require.assert_not_called() + + def test_curve_rejects_duplicate_or_invalid_points_before_com(self): + for points in ([[0, 0, 0]], [[0, 0, 0], [0, 0, 0]], [[0, 0, 0], [1, 2]], [[0, 0, 0], [0, 1, float("nan")]]): + with self.subTest(points=points), patch.object(sw_geometry, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_geometry.create_curve_through_points({"points_mm": points}) + require.assert_not_called() + + def test_point_count_and_distribution_are_validated_before_com(self): + for args in ({"mode": "arc_center", "count": 2}, {"mode": "evenly", "count": True}, + {"mode": "evenly", "count": 1001}, {"mode": "along_percentage", "percentage": 101}): + with self.subTest(args=args), patch.object(sw_geometry, "_model") as model: + with self.assertRaises(RuntimeError): + sw_geometry.create_reference_points({"selection": {"edges": [0]}, **args}) + model.assert_not_called() + + def test_unimplemented_reference_plane_projection_is_explicit(self): + with patch.object(sw_geometry, "_model") as model: + with self.assertRaisesRegex(RuntimeError, "coverage gap"): + sw_geometry.create_reference_points({"mode": "projection", "selection": {"planes": ["front"], "vertices": [0]}}) + model.assert_not_called() + + def test_curve_readback_cannot_hide_a_point_count_mismatch(self): + definition = SimpleNamespace(PointArray=[0, 0, 0], GetPointCount=lambda: 2) + feature = SimpleNamespace(GetDefinition=lambda: definition) + with self.assertRaises(RuntimeError): + sw_geometry._curve_info(feature) + + def test_native_point_count_counts_coordinates_instead_of_points(self): + definition = SimpleNamespace(PointArray=[0, 0, 0, 0.01, 0.02, 0.03], GetPointCount=lambda: 6) + feature = SimpleNamespace(GetDefinition=lambda: definition, Name="Curve") + info = sw_geometry._curve_info(feature) + self.assertEqual(info["point_count"], 2) + self.assertEqual(info["points_mm"], [[0, 0, 0], [10, 20, 30]]) + + def test_replacement_cannot_use_an_unrelated_selection(self): + with patch.object(sw_geometry, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_geometry.replace_faces({"faces_to_replace": {"faces": [0]}, "replacement": {"edges": [0]}}) + require.assert_not_called() + + def test_composite_curve_uses_the_native_selection_mark(self): + doc = SimpleNamespace(InsertCompositeCurve=lambda: False) + with patch.object(sw_geometry, "require_part", return_value=(None, doc)), \ + patch.object(sw_geometry, "exit_active_sketch"), \ + patch.object(sw_geometry, "require_selection") as selection, \ + patch.object(sw_geometry, "_feature_names", return_value=set()), \ + patch.object(sw_geometry, "_feature_created_after", return_value=None), \ + patch.object(sw_geometry, "_finish", return_value={"ok": False}): + self.assertFalse(sw_geometry.composite_curve({"selection": {"edges": [0, 1]}})["ok"]) + selection.assert_called_once_with(doc, {"edges": [0, 1]}, mark=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 7b0b6b2..66f8800 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -32,6 +32,8 @@ "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "planar_surface", "offset_surface", "knit_surfaces"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], + "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], + "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], } MODULE_TOOLS = {"Motion": ["list_motion_studies", "create_motion_study", "activate_motion_study", diff --git a/tools/verify_install.py b/tools/verify_install.py new file mode 100644 index 0000000..3890a8e --- /dev/null +++ b/tools/verify_install.py @@ -0,0 +1,95 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Verify an isolated wheel install, offline tests and actual MCP stdio discovery. + +SOLIDWORKS must already be running. --scratch exercises geometry calls on a new +part and closes it afterwards; default calls only read the active document. +""" + +import argparse +import asyncio +import json +import os +from pathlib import Path +import sys +import unittest + + +async def protocol(install, expected, catalog, scratch): + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + env = dict(os.environ, PYTHONPATH=str(install)) + parameters = StdioServerParameters(command=sys.executable, args=["-m", "solidworks_mcp.server"], + env=env, cwd=str(install.parent)) + async with stdio_client(parameters) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + response = await session.list_tools() + if len(response.tools) != expected or len({tool.name for tool in response.tools}) != expected: + raise RuntimeError("MCP tool count or uniqueness differs from the expected release.") + async def call(name, arguments=None): + output = await session.call_tool(name, arguments or {}) + payload = json.loads(output.content[0].text) + if not payload["ok"]: + raise RuntimeError(f"Installed MCP call failed: {name}: {payload}") + return payload.get("data", {}) + initial = await call("list_open_documents") + original = initial["active_title"] + title = None + try: + if scratch: + title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] + points = [[0, 0, 0], [10, 5, 2], [20, 0, 4]] + await call("create_curve_through_points", {"name": "InstalledCurve", "points_mm": points}) + actual = await call("get_curve_points", {"name": "InstalledCurve"}) + if actual["curve"]["points_mm"] != points: + raise RuntimeError("Installed MCP curve coordinate readback differs.") + await call("create_coordinate_system", {"name": "InstalledCS", "origin_mm": [2, 3, 4]}) + await call("list_reference_points") + systems = await call("list_coordinate_systems") + if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: + raise RuntimeError("Installed MCP coordinate-system origin readback differs.") + finally: + if title: + await call("close_document", {"name": title, "discard_changes": True}) + if scratch and original: + await call("activate_document", {"name": original}) + if catalog: + catalog.parent.mkdir(parents=True, exist_ok=True) + catalog.write_text(json.dumps([tool.model_dump(mode="json") for tool in response.tools], + ensure_ascii=False, indent=2), encoding="utf-8") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--install", type=Path, required=True) + parser.add_argument("--tool-count", type=int, required=True) + parser.add_argument("--catalog", type=Path) + parser.add_argument("--scratch", action="store_true", help="Verify geometry calls on a new temporary part, then close it.") + args = parser.parse_args() + install = args.install.resolve() + sys.path.insert(0, str(install)) + from solidworks_mcp import server + + if not Path(server.__file__).resolve().is_relative_to(install): + raise RuntimeError("Imported source checkout instead of the isolated installation.") + if len(server.TOOLS) != args.tool_count: + raise RuntimeError("Installed tool count differs from the expected release.") + repo = Path(__file__).resolve().parents[1] + suite = unittest.defaultTestLoader.discover(str(repo / "tests"), pattern="test_*.py") + checks = unittest.TextTestRunner(verbosity=1).run(suite) + if not checks.wasSuccessful(): + raise RuntimeError("Installed-package offline checks failed.") + asyncio.run(protocol(install, args.tool_count, args.catalog, args.scratch)) + print(f"Installed wheel: {checks.testsRun} offline checks and MCP stdio discovery/native calls passed; {args.tool_count} tools.") + + +if __name__ == "__main__": + main() From 92f2eca3db0c0fd169447adc80f63402529f668c Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:04:41 +0800 Subject: [PATCH 08/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=97=8B=E8=BD=AC?= =?UTF-8?q?=E6=9B=B2=E9=9D=A2=E6=94=BE=E6=A0=B7=E6=9B=B2=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E5=AE=9E=E4=BD=93=E6=9B=B2=E9=9D=A2=E5=88=87?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 7 +- README.md | 2 +- solidworks_mcp/sw_multibody.py | 96 ++++++++++++++++++- tests/live_surface_construction_regression.py | 76 +++++++++++++++ tests/test_surface_construction.py | 40 ++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 10 ++ 7 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 tests/live_surface_construction_regression.py create mode 100644 tests/test_surface_construction.py diff --git a/COVERAGE.md b/COVERAGE.md index f2bb76a..56c197b 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 153 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 156 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -61,6 +61,11 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 参考点的圆心、中点、面中心、距离/百分比/均匀分布、草图点、交点和投影到模型面已验证。 投影到基准面在本机 `InsertReferencePoint` 实测返回空,仍是待补齐项;未从目标中排除。 +新增旋转曲面、放样曲面和曲面切除 3 个工具。旋转曲面的单向、中面、双向模式 +以圆柱侧面积验证;双圆截面放样也以圆柱侧面积验证。曲面切除验证两个方向, +以及限定一个实体时另一个实体的体积保留。放样的导引线、闭合、多种端部相切条件, +曲面切除的保留片区参数仍未专项验证。曲面延伸、修剪和解除修剪仍待实现。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index a43f783..45c1259 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -153 tools: sketching with real relations and driving dimensions, the solid +156 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index 77f7130..a2e127b 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -16,7 +16,7 @@ import math from .sw_core import ( - BODY_SHEET, SELECTION_SCHEMA, dispatch_array, + BODY_SHEET, SELECTION_SCHEMA, dispatch_array, byref_long, clear_selection, exit_active_sketch, feature_manager, feature_result, flag_methods, get_bodies, nothing, rename_feature, require_part, require_selection, result, select_sketch_for_feature, to_m, to_rad, tool, as_list, value, @@ -273,3 +273,97 @@ def knit_surfaces(args): if payload["ok"] and args.get("form_solid", False) and len(get_bodies(doc)) <= before: payload.update(ok=False, message="Knit feature did not form a closed solid.") return payload + + +@tool("surface_revolve", "Revolve an open 2D sketch into a reference surface. A sketch centerline or axis_selection supplies the axis. Angles are degrees; supports one direction, midplane or two directions.", + {"sketch_name": NAME, "axis_selection": SELECTION_SCHEMA, "angle_deg": {"type": "number", "exclusiveMinimum": 0, "maximum": 360, "default": 360}, + "angle2_deg": {"type": "number", "minimum": 0, "maximum": 360, "default": 0}, + "mode": {"type": "string", "enum": ["one_direction", "midplane", "two_directions"], "default": "one_direction"}, + "reverse": {"type": "boolean", "default": False}, "name": NAME}) +def surface_revolve(args): + angle = _positive(args.get("angle_deg", 360), "angle_deg") + second = float(args.get("angle2_deg", 0)) + mode = args.get("mode", "one_direction") + if mode not in {"one_direction", "midplane", "two_directions"} or angle > 360 or not math.isfinite(second) or not 0 <= second <= 360: + raise RuntimeError("Invalid surface revolve mode or angle.") + if (mode == "two_directions" and (second <= 0 or angle + second > 360)) or (mode != "two_directions" and second != 0): + raise RuntimeError("Two directions requires positive angle2_deg and a total angle <=360; other modes require angle2_deg=0.") + _, doc = require_part() + sketch = select_sketch_for_feature(doc, args.get("sketch_name")) + if args.get("axis_selection"): + require_selection(doc, args["axis_selection"], mark=4, append=True) + before = len(get_bodies(doc, BODY_SHEET)) + manager = flag_methods(feature_manager(doc), "InsertRevolvedRefSurface") + feature = manager.InsertRevolvedRefSurface(to_rad(angle), bool(args.get("reverse", False)), to_rad(second), + {"one_direction": 0, "midplane": 1, "two_directions": 2}[mode]) + payload = _finish(doc, feature, args, "revolved surface", sketch=sketch) + if payload["ok"] and len(get_bodies(doc, BODY_SHEET)) <= before: + payload.update(ok=False, message="Surface revolve did not produce a surface body.") + return payload + + +@tool("surface_loft", "Loft an ordered set of open or closed sketches into a surface. Supports guide curves, centerline and none/normal/vector/adjacent-face end tangency. A closed loft requires at least three profiles.", + {"profile_sketches": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "uniqueItems": True}, + "guide_selection": SELECTION_SCHEMA, "centerline_selection": SELECTION_SCHEMA, + "start_tangency": {"type": "string", "enum": ["none", "normal", "vector", "adjacent_faces"], "default": "none"}, + "end_tangency": {"type": "string", "enum": ["none", "normal", "vector", "adjacent_faces"], "default": "none"}, + "start_vector_selection": SELECTION_SCHEMA, "end_vector_selection": SELECTION_SCHEMA, + "closed": {"type": "boolean", "default": False}, "keep_tangency": {"type": "boolean", "default": True}, + "force_non_rational": {"type": "boolean", "default": False}, "tolerance_factor": {"type": "number", "exclusiveMinimum": 0, "default": 1}, "name": NAME}, ["profile_sketches"]) +def surface_loft(args): + profiles = args["profile_sketches"] + closed = bool(args.get("closed", False)) + if not isinstance(profiles, list) or len(profiles) < (3 if closed else 2) or any(not isinstance(p, str) or not p.strip() for p in profiles) or len(set(profiles)) != len(profiles): + raise RuntimeError("Provide distinct ordered profile sketches; closed lofts require at least three.") + tolerance = _positive(args.get("tolerance_factor", 1), "tolerance_factor") + tangencies = {"none": 0, "normal": 1, "vector": 2, "adjacent_faces": 3} + start, end = args.get("start_tangency", "none"), args.get("end_tangency", "none") + if start not in tangencies or end not in tangencies: + raise RuntimeError("Invalid loft tangency mode.") + for side, kind in (("start", start), ("end", end)): + if kind == "vector" and not args.get(f"{side}_vector_selection"): + raise RuntimeError(f"{side} vector tangency requires {side}_vector_selection.") + _, doc = require_part() + exit_active_sketch(doc) + clear_selection(doc) + for index, sketch in enumerate(profiles): + require_selection(doc, {"sketches": [sketch]}, mark=1, append=index > 0) + for key, mark in (("guide_selection", 2), ("centerline_selection", 4), ("start_vector_selection", 8), ("end_vector_selection", 32)): + if args.get(key): + require_selection(doc, args[key], mark=mark, append=True) + before = _feature_names(doc) + count = len(get_bodies(doc, BODY_SHEET)) + flag_methods(doc, "InsertLoftRefSurface2").InsertLoftRefSurface2(closed, bool(args.get("keep_tangency", True)), + bool(args.get("force_non_rational", False)), tolerance, tangencies[start], tangencies[end]) + payload = _finish(doc, _feature_created_after(doc, before), args, "lofted surface", profiles=profiles) + if payload["ok"] and len(get_bodies(doc, BODY_SHEET)) <= count: + payload.update(ok=False, message="Surface loft did not produce a surface body.") + return payload + + +@tool("cut_with_surface", "Cut solid bodies with one selected reference plane or surface. Optional body_indices limits scope to existing solid bodies; flip changes the removed side. Re-list topology afterwards.", + {"selection": SELECTION_SCHEMA, "flip": {"type": "boolean", "default": False}, + "body_indices": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True}, + "keep_piece_index": {"type": "integer", "minimum": -1, "default": -1}, "name": NAME}, ["selection"]) +def cut_with_surface(args): + indices = args.get("body_indices") + if indices is not None and (not isinstance(indices, list) or not indices or any(not isinstance(i, int) or isinstance(i, bool) or i < 0 for i in indices) or len(set(indices)) != len(indices)): + raise RuntimeError("body_indices must contain distinct nonnegative solid-body indices.") + piece = args.get("keep_piece_index", -1) + if not isinstance(piece, int) or isinstance(piece, bool) or piece < -1: + raise RuntimeError("keep_piece_index must be an integer >=-1.") + _, doc = require_part() + bodies = get_bodies(doc) + if not bodies or (indices is not None and any(i >= len(bodies) for i in indices)): + raise RuntimeError("No matching solid bodies exist. Re-list bodies before selecting cut scope.") + exit_active_sketch(doc) + if require_selection(doc, args["selection"]) != 1: + raise RuntimeError("Select exactly one cutting plane or surface.") + errors = byref_long() + manager = flag_methods(feature_manager(doc), "InsertCutSurface") + scope = dispatch_array([bodies[i] for i in indices]) if indices is not None else nothing() + feature = manager.InsertCutSurface(bool(args.get("flip", False)), piece, True, indices is None, scope, errors) + payload = _finish(doc, feature, args, "surface cut", error_code=int(errors.value)) + if int(errors.value): + payload.update(ok=False, message="Native surface cut reported an error.") + return payload diff --git a/tests/live_surface_construction_regression.py b/tests/live_surface_construction_regression.py new file mode 100644 index 0000000..099efea --- /dev/null +++ b/tests/live_surface_construction_regression.py @@ -0,0 +1,76 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Surface construction areas and scoped surface-cut volumes on scratch parts.""" + +import math +from live_expansion_regression import block, call, near, volume +from solidworks_mcp.sw_core import as_list, byref_long, running_app, value + + +def area(): + return sum(body["area_mm2"] for body in call("list_surface_bodies")["surface_bodies"]) + + +def revolve(angle, mode="one_direction", second=0): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="RevolveProfile") + call("draw_line", x1_mm=5, y1_mm=0, x2_mm=5, y2_mm=10) + call("draw_centerline", x1_mm=0, y1_mm=0, x2_mm=0, y2_mm=10) + call("close_sketch") + call("surface_revolve", sketch_name="RevolveProfile", angle_deg=angle, mode=mode, angle2_deg=second) + near(area(), 2 * math.pi * 5 * 10 * (angle + second) / 360, "revolved surface area") + + +def loft(): + call("create_new_document", kind="part") + call("create_plane", mode="offset", selection={"planes": ["front"]}, distance_mm=10, name="UpperPlane") + for plane, name in (("front", "Lower"), ("UpperPlane", "Upper")): + call("create_sketch", plane=plane, name=name) + call("draw_circle", x_mm=0, y_mm=0, radius_mm=5) + call("close_sketch") + call("surface_loft", profile_sketches=["Lower", "Upper"]) + near(area(), 2 * math.pi * 5 * 10, "lofted cylinder area") + + +def cut(flip=False, scoped=False): + block() + if scoped: + call("move_copy_bodies", selection={"bodies": [0]}, copy=True, copies=1, dx_mm=15) + near(volume(), 2000, "surface-cut initial two-body volume") + call("create_plane", mode="offset", selection={"planes": ["front"]}, distance_mm=5, name="CutPlane") + arguments = {"body_indices": [0]} if scoped else {} + call("cut_with_surface", selection={"planes": ["CutPlane"]}, flip=flip, **arguments) + near(volume(), 1500 if scoped else 500, "scoped surface-cut volume") + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + revolve(360) + revolve(180, "midplane") + revolve(90, "two_directions", 90) + loft() + cut() + cut(flip=True) + cut(scoped=True) + print("Surface revolve/loft areas and both-direction/scoped surface-cut volumes verified.", flush=True) + finally: + titles = [str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))] + for title in titles: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/test_surface_construction.py b/tests/test_surface_construction.py new file mode 100644 index 0000000..e0246d7 --- /dev/null +++ b/tests/test_surface_construction.py @@ -0,0 +1,40 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +import unittest +from unittest.mock import patch +from solidworks_mcp import sw_multibody + + +class SurfaceConstructionBoundaries(unittest.TestCase): + def test_revolve_cannot_silently_discard_the_second_angle(self): + for args in ({"angle_deg": 361}, {"angle_deg": float("nan")}, {"angle2_deg": 10}, + {"mode": "two_directions", "angle_deg": 300, "angle2_deg": 90}): + with self.subTest(args=args), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.surface_revolve(args) + require.assert_not_called() + + def test_loft_rejects_invalid_profiles_and_missing_vectors_before_com(self): + for args in ({"profile_sketches": ["A", "A"]}, {"profile_sketches": ["A", "B"], "closed": True}, + {"profile_sketches": ["A", "B"], "start_tangency": "vector"}): + with self.subTest(args=args), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.surface_loft(args) + require.assert_not_called() + + def test_cut_scope_is_validated_before_any_selection(self): + for indices in ([], [0, 0], [-1], [True]): + with self.subTest(indices=indices), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.cut_with_surface({"selection": {"planes": ["Plane"]}, "body_indices": indices}) + require.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 66f8800..a7a363b 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "planar_surface", "offset_surface", "knit_surfaces"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 3890a8e..260e047 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -15,6 +15,7 @@ import argparse import asyncio import json +import math import os from pathlib import Path import sys @@ -52,6 +53,15 @@ async def call(name, arguments=None): if actual["curve"]["points_mm"] != points: raise RuntimeError("Installed MCP curve coordinate readback differs.") await call("create_coordinate_system", {"name": "InstalledCS", "origin_mm": [2, 3, 4]}) + await call("create_sketch", {"plane": "front", "name": "InstalledRevolveProfile"}) + await call("draw_line", {"x1_mm": 5, "y1_mm": 0, "x2_mm": 5, "y2_mm": 10}) + await call("draw_centerline", {"x1_mm": 0, "y1_mm": 0, "x2_mm": 0, "y2_mm": 10}) + await call("close_sketch") + await call("surface_revolve", {"sketch_name": "InstalledRevolveProfile"}) + surfaces = await call("list_surface_bodies") + area = sum(body["area_mm2"] for body in surfaces["surface_bodies"]) + if not math.isclose(area, 2 * math.pi * 5 * 10, rel_tol=1e-7): + raise RuntimeError("Installed MCP revolved surface area differs.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From bb4c6f733383b3f49495cb62fcbc8291d6ef26d2 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:15:20 +0800 Subject: [PATCH 09/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=9B=B2=E9=9D=A2?= =?UTF-8?q?=E6=8B=93=E6=89=91=E9=80=89=E6=8B=A9=E5=BB=B6=E4=BC=B8=E4=B8=8E?= =?UTF-8?q?=E8=A7=A3=E9=99=A4=E4=BF=AE=E5=89=AA=E5=B9=B6=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?=E7=99=BE=E5=88=86=E6=AF=94=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 12 ++- README.md | 2 +- solidworks_mcp/sw_core.py | 29 ++++-- solidworks_mcp/sw_inspect.py | 16 ++- solidworks_mcp/sw_multibody.py | 72 ++++++++++++- tests/live_surface_boundary_regression.py | 118 ++++++++++++++++++++++ tests/test_surface_edit.py | 84 +++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 20 ++++ 9 files changed, 338 insertions(+), 17 deletions(-) create mode 100644 tests/live_surface_boundary_regression.py create mode 100644 tests/test_surface_edit.py diff --git a/COVERAGE.md b/COVERAGE.md index 56c197b..48cf0f3 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 156 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 158 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -64,7 +64,15 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 新增旋转曲面、放样曲面和曲面切除 3 个工具。旋转曲面的单向、中面、双向模式 以圆柱侧面积验证;双圆截面放样也以圆柱侧面积验证。曲面切除验证两个方向, 以及限定一个实体时另一个实体的体积保留。放样的导引线、闭合、多种端部相切条件, -曲面切除的保留片区参数仍未专项验证。曲面延伸、修剪和解除修剪仍待实现。 +曲面切除的保留片区参数仍未专项验证。 + +新增曲面延伸和解除修剪 2 个工具,复用拓扑查询增加 `body_type=surface`, +选择使用独立的 `surface_faces`、`surface_edges` 索引。实体查询默认行为保持不变。 +延伸验证单边、全部面边界、线性/同曲面延伸、到指定点和到实体面的面积。 +解除修剪验证内孔填补、外边界恢复、合并/独立曲面、反向保留区域, +以及按自然边界的百分比扩展边、连接端点填补缺角。扩展百分比直接传入原生接口, +不能按毫米换算。测试以面积、边类型和端点核验,独立曲面不依赖实体返回顺序。 +曲面修剪、复杂非平面延伸及解除修剪的其他组合仍保留为缺口。 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index 45c1259..f60beb2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -156 tools: sketching with real relations and driving dimensions, the solid +158 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index 6ed0f3b..c732960 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -801,7 +801,8 @@ def component_bodies(component: Any, body_type: int = BODY_SOLID) -> list[Any]: except Exception: pass try: - return as_list(safe(component, "GetBody")) + bodies = as_list(safe(component, "GetBody")) + return bodies if body_type == BODY_ALL else [body for body in bodies if safe(body, "GetType") == body_type] except Exception: return [] @@ -882,7 +883,7 @@ def _face_point(face: Any, matrix: Sequence[float] | None = None) -> list[float] return _point_on(face, safe(face, "GetBox"), matrix) -def iter_face_objects(doc: Any) -> list[tuple[Any, list[float] | None]]: +def iter_face_objects(doc: Any, body_type: int = BODY_SOLID) -> list[tuple[Any, list[float] | None]]: """Every face as (object, component transform), in list_faces index order. Selecting a face needs the COM object, and a point on it only if selecting @@ -895,16 +896,16 @@ def iter_face_objects(doc: Any) -> list[tuple[Any, list[float] | None]]: """ return [ (face, matrix) - for body, _, matrix in iter_body_context(doc) + for body, _, matrix in iter_body_context(doc, body_type) for face in as_list(safe(body, "GetFaces")) ] -def iter_edge_objects(doc: Any) -> list[tuple[Any, list[float] | None]]: +def iter_edge_objects(doc: Any, body_type: int = BODY_SOLID) -> list[tuple[Any, list[float] | None]]: """Every edge as (object, component transform), in list_edges index order.""" return [ (edge, matrix) - for body, _, matrix in iter_body_context(doc) + for body, _, matrix in iter_body_context(doc, body_type) for edge in as_list(safe(body, "GetEdges")) ] @@ -957,10 +958,10 @@ def rotate_vector(vector: Sequence[float], matrix: Sequence[float] | None) -> li ] -def enumerate_faces(doc: Any) -> list[dict[str, Any]]: +def enumerate_faces(doc: Any, body_type: int = BODY_SOLID) -> list[dict[str, Any]]: faces: list[dict[str, Any]] = [] index = 0 - for body_index, (body, body_name, matrix) in enumerate(iter_body_context(doc)): + for body_index, (body, body_name, matrix) in enumerate(iter_body_context(doc, body_type)): for face in as_list(safe(body, "GetFaces")): entry: dict[str, Any] = {"index": index, "body_index": body_index, "body": body_name, "_obj": face} point = _point_on(face, safe(face, "GetBox"), matrix) @@ -1022,10 +1023,10 @@ def _edge_endpoints(edge: Any, matrix: Sequence[float] | None = None) -> dict[st return endpoints -def enumerate_edges(doc: Any) -> list[dict[str, Any]]: +def enumerate_edges(doc: Any, body_type: int = BODY_SOLID) -> list[dict[str, Any]]: edges: list[dict[str, Any]] = [] index = 0 - for body_index, (body, body_name, matrix) in enumerate(iter_body_context(doc)): + for body_index, (body, body_name, matrix) in enumerate(iter_body_context(doc, body_type)): for edge in as_list(safe(body, "GetEdges")): entry: dict[str, Any] = {"index": index, "body_index": body_index, "body": body_name, "_obj": edge} point = _edge_point(edge, matrix) @@ -1174,6 +1175,8 @@ def sketch_point_objects(doc: Any, sketch_name: str | None = None) -> list[Any]: "properties": { "faces": {"type": "array", "items": {"type": "integer"}, "description": "Face indices from list_faces."}, "edges": {"type": "array", "items": {"type": "integer"}, "description": "Edge indices from list_edges."}, + "surface_faces": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "Face indices from list_faces(body_type=surface), separate from solid faces."}, + "surface_edges": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "Edge indices from list_edges(body_type=surface), separate from solid edges."}, "vertices": {"type": "array", "items": {"type": "integer"}, "description": "Vertex indices from list_vertices."}, "face_edges": { "type": "array", @@ -1318,6 +1321,14 @@ def apply_selection(doc: Any, spec: dict[str, Any] | None, mark: int = 0, append count += _select_indexed_objects( doc, iter_edge_objects(doc), spec["edges"], SELECT_TYPE_EDGE, mark, "Edge", _edge_point ) + if spec.get("surface_faces"): + count += _select_indexed_objects( + doc, iter_face_objects(doc, BODY_SHEET), spec["surface_faces"], SELECT_TYPE_FACE, mark, "Surface face", _face_point + ) + if spec.get("surface_edges"): + count += _select_indexed_objects( + doc, iter_edge_objects(doc, BODY_SHEET), spec["surface_edges"], SELECT_TYPE_EDGE, mark, "Surface edge", _edge_point + ) if spec.get("vertices"): # Vertices keep the measured enumeration: it deduplicates by coordinate, # which means reading each point anyway. diff --git a/solidworks_mcp/sw_inspect.py b/solidworks_mcp/sw_inspect.py index 3ada64e..776f89b 100644 --- a/solidworks_mcp/sw_inspect.py +++ b/solidworks_mcp/sw_inspect.py @@ -28,6 +28,7 @@ from typing import Any from .sw_core import ( + BODY_SHEET, active_document, apply_selection, apply_transform, @@ -87,6 +88,7 @@ def _strip_internal(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: "Read-only: list the faces of the active part with surface type, area, a point on the face, and " "the index that selection.faces takes. Re-list after any geometry change, because indices move.", { + "body_type": {"type": "string", "enum": ["solid", "surface"], "default": "solid", "description": "Surface results use selection.surface_faces; solid results use selection.faces."}, "surface_type": { "type": "string", "enum": ["plane", "cylinder", "cone", "sphere", "torus", "bsurface"], @@ -104,7 +106,10 @@ def _strip_internal(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: ) def list_faces(args: dict[str, Any]) -> dict[str, Any]: _, doc = active_document() - faces = enumerate_faces(doc) + kind = args.get("body_type", "solid") + if kind not in {"solid", "surface"}: + raise RuntimeError("body_type must be solid or surface.") + faces = enumerate_faces(doc, BODY_SHEET) if kind == "surface" else enumerate_faces(doc) total = len(faces) wanted_type = args.get("surface_type") @@ -129,6 +134,7 @@ def list_faces(args: dict[str, Any]) -> dict[str, Any]: f"Read {len(faces)} of {total} faces.", faces=_strip_internal(faces), total_faces=total, + body_type=kind, ) @@ -137,6 +143,7 @@ def list_faces(args: dict[str, Any]) -> dict[str, Any]: "Read-only: list the edges of the active part with curve type, length, and the index that " "selection.edges takes. Re-list after any geometry change.", { + "body_type": {"type": "string", "enum": ["solid", "surface"], "default": "solid", "description": "Surface results use selection.surface_edges; solid results use selection.edges."}, "curve_type": { "type": "string", "enum": ["line", "circle", "ellipse", "bcurve"], @@ -148,7 +155,10 @@ def list_faces(args: dict[str, Any]) -> dict[str, Any]: ) def list_edges(args: dict[str, Any]) -> dict[str, Any]: _, doc = active_document() - edges = enumerate_edges(doc) + kind = args.get("body_type", "solid") + if kind not in {"solid", "surface"}: + raise RuntimeError("body_type must be solid or surface.") + edges = enumerate_edges(doc, BODY_SHEET) if kind == "surface" else enumerate_edges(doc) total = len(edges) wanted = args.get("curve_type") @@ -159,7 +169,7 @@ def list_edges(args: dict[str, Any]) -> dict[str, Any]: if args.get("max_length_mm") is not None: edges = [e for e in edges if e.get("length_mm", 1e18) <= float(args["max_length_mm"])] - return result(True, f"Read {len(edges)} of {total} edges.", edges=_strip_internal(edges), total_edges=total) + return result(True, f"Read {len(edges)} of {total} edges.", edges=_strip_internal(edges), total_edges=total, body_type=kind) @tool( diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index a2e127b..4124857 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -16,7 +16,7 @@ import math from .sw_core import ( - BODY_SHEET, SELECTION_SCHEMA, dispatch_array, byref_long, clear_selection, exit_active_sketch, + BODY_SHEET, SELECTION_SCHEMA, dispatch_array, byref_long, clear_selection, exit_active_sketch, call_versioned, feature_manager, feature_result, flag_methods, get_bodies, nothing, rename_feature, require_part, require_selection, result, select_sketch_for_feature, to_m, to_rad, tool, as_list, value, @@ -367,3 +367,73 @@ def cut_with_surface(args): if int(errors.value): payload.update(ok=False, message="Native surface cut reported an error.") return payload + + +def _surface_boundary_selection(selection): + if not isinstance(selection, dict) or not selection or set(selection) - {"surface_faces", "surface_edges"}: + raise RuntimeError("Select surface_faces or surface_edges from topology listings with body_type=surface.") + if not any(selection.values()): + raise RuntimeError("Select at least one surface face or edge.") + for indices in selection.values(): + if not isinstance(indices, list) or any(not isinstance(i, int) or isinstance(i, bool) or i < 0 for i in indices) or len(set(indices)) != len(indices): + raise RuntimeError("Surface topology indices must be distinct nonnegative integers.") + + +@tool("extend_surface", "Extend selected surface faces (all boundary edges) or individual surface edges. Distance is mm; up_to_point requires a selected point and up_to_surface requires a solid face. Re-list surface topology afterwards.", + {"selection": SELECTION_SCHEMA, "linear": {"type": "boolean", "default": False}, + "end_condition": {"type": "string", "enum": ["distance", "up_to_point", "up_to_surface"], "default": "distance"}, + "distance_mm": {"type": "number", "exclusiveMinimum": 0}, "target_selection": SELECTION_SCHEMA, "name": NAME}, ["selection"]) +def extend_surface(args): + _surface_boundary_selection(args["selection"]) + mode = args.get("end_condition", "distance") + if mode not in {"distance", "up_to_point", "up_to_surface"}: + raise RuntimeError("Invalid surface extension end condition.") + distance = _positive(args.get("distance_mm", 0), "distance_mm") if mode == "distance" else 0 + target = args.get("target_selection") + if mode != "distance" and not target: + raise RuntimeError("A target_selection is required for this end condition.") + if mode != "distance" and float(args.get("distance_mm", 0)) != 0: + raise RuntimeError("distance_mm applies only to distance extension.") + if mode == "distance" and target: + raise RuntimeError("Distance extension does not accept target_selection.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["selection"]) + if target and require_selection(doc, target, append=True) != 1: + raise RuntimeError("Select exactly one target point or solid face.") + before = _feature_names(doc) + flag_methods(doc, "InsertExtendSurface").InsertExtendSurface(bool(args.get("linear", False)), + {"distance": 0, "up_to_point": 1, "up_to_surface": 2}[mode], to_m(distance)) + return _finish(doc, _feature_created_after(doc, before), args, "surface extension") + + +@tool("untrim_surface", "Restore trimmed surface faces or selected edges. Faces support all/internal/external boundaries; selected edges support extending by a percentage of their natural boundary or connecting endpoints. trim_opposite_side requires merge=false and SolidWorks 2024+.", + {"selection": SELECTION_SCHEMA, + "face_mode": {"type": "string", "enum": ["all", "internal", "external"], "default": "all"}, + "edge_mode": {"type": "string", "enum": ["extend", "connect_endpoints"], "default": "extend"}, + "extend_percent": {"type": "number", "minimum": 0, "default": 0, "description": "Percentage of the natural boundary; only for surface_edges with edge_mode=extend."}, + "merge": {"type": "boolean", "default": True}, "trim_opposite_side": {"type": "boolean", "default": False}, "name": NAME}, ["selection"]) +def untrim_surface(args): + _surface_boundary_selection(args["selection"]) + faces = {"all": 0, "internal": 1, "external": 2} + edges = {"extend": 2, "connect_endpoints": 1} + face_mode, edge_mode = args.get("face_mode", "all"), args.get("edge_mode", "extend") + distance = float(args.get("extend_percent", 0)) + if face_mode not in faces or edge_mode not in edges or not math.isfinite(distance) or distance < 0: + raise RuntimeError("Invalid untrim mode or extension percentage.") + if distance and (not args["selection"].get("surface_edges") or edge_mode != "extend"): + raise RuntimeError("extend_percent applies only to selected edges in extend mode.") + if edge_mode == "connect_endpoints" and len(args["selection"].get("surface_edges", [])) < 2: + raise RuntimeError("Connect endpoints requires at least two selected surface edges.") + merge, opposite = bool(args.get("merge", True)), bool(args.get("trim_opposite_side", False)) + if merge and opposite: + raise RuntimeError("trim_opposite_side requires merge=false.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["selection"]) + manager = flag_methods(feature_manager(doc), "InsertUntrimSurface2", "InsertUntrimSurface") + parameters = (faces[face_mode], edges[edge_mode], distance, merge) + candidates = [("InsertUntrimSurface2", parameters + (opposite,))] + if not opposite: + candidates.append(("InsertUntrimSurface", parameters)) + return _finish(doc, call_versioned(manager, *candidates), args, "surface untrim") diff --git a/tests/live_surface_boundary_regression.py b/tests/live_surface_boundary_regression.py new file mode 100644 index 0000000..3720efb --- /dev/null +++ b/tests/live_surface_boundary_regression.py @@ -0,0 +1,118 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +"""Surface boundary selection and editing on new disposable parts.""" + +import math +from live_expansion_regression import call, near +from live_surface_construction_regression import area +from solidworks_mcp.sw_core import as_list, byref_long, running_app, value + + +def plate(hole=False): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="PlateProfile") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + if hole: + call("draw_circle", x_mm=5, y_mm=5, radius_mm=1) + call("close_sketch") + call("planar_surface", sketch_name="PlateProfile") + near(area(), 100 - (math.pi if hole else 0), "initial plate area") + faces = call("list_faces", body_type="surface")["faces"] + edges = call("list_edges", body_type="surface")["edges"] + assert len(faces) == 1 and len(edges) == (5 if hole else 4) + assert call("list_faces")["total_faces"] == 0 + return faces, edges + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + for linear in (False, True): + plate() + call("extend_surface", selection={"surface_edges": [0]}, distance_mm=5, linear=linear) + near(area(), 150, "one edge extension") + plate() + call("extend_surface", selection={"surface_faces": [0]}, distance_mm=5) + near(area(), 400, "all boundary extension") + plate(hole=True) + call("untrim_surface", selection={"surface_faces": [0]}, face_mode="internal") + near(area(), 100, "internal hole restoration") + _, edges = plate() + top = next(e["index"] for e in edges if abs(e["start_mm"][1] - 10) < 1e-6 and abs(e["end_mm"][1] - 10) < 1e-6) + call("create_sketch", plane="front", name="TargetPoint") + call("draw_point", x_mm=5, y_mm=15) + call("close_sketch") + call("extend_surface", selection={"surface_edges": [top]}, end_condition="up_to_point", + target_selection={"sketch_points": [0], "sketch_name": "TargetPoint"}) + near(area(), 150, "extension to point") + _, edges = plate() + top = next(e["index"] for e in edges if abs(e["start_mm"][1] - 10) < 1e-6 and abs(e["end_mm"][1] - 10) < 1e-6) + call("create_sketch", plane="front", name="StopBodyProfile") + call("draw_rectangle", x1_mm=0, y1_mm=15, x2_mm=10, y2_mm=20) + call("close_sketch") + call("boss_extrude", sketch_name="StopBodyProfile", depth_mm=5) + face = call("list_faces", normal=[0, -1, 0])["faces"][0]["index"] + call("extend_surface", selection={"surface_edges": [top]}, end_condition="up_to_surface", + target_selection={"faces": [face]}) + near(area(), 150, "extension to solid face") + for merge, opposite in ((True, False), (False, False), (False, True)): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="CircleProfile") + call("draw_circle", x_mm=0, y_mm=0, radius_mm=3) + call("close_sketch") + call("planar_surface", sketch_name="CircleProfile") + near(area(), math.pi * 9, "circular patch") + call("untrim_surface", selection={"surface_faces": [0]}, face_mode="external", + merge=merge, trim_opposite_side=opposite) + bodies = call("list_surface_bodies")["surface_bodies"] + edges = call("list_edges", body_type="surface")["edges"] + assert len(bodies) == (1 if merge else 2) + lines = [edge for edge in edges if edge["curve_type"] == "line"] + assert len(lines) == 4 + new_index = lines[0]["body_index"] + assert all(edge["body_index"] == new_index for edge in lines) + corners = [edge[key] for edge in lines for key in ("start_mm", "end_mm")] + rectangle = ((max(p[0] for p in corners) - min(p[0] for p in corners)) * + (max(p[1] for p in corners) - min(p[1] for p in corners))) + assert rectangle > math.pi * 9 + near(bodies[new_index]["area_mm2"], rectangle - (math.pi * 9 if opposite else 0), "external untrim region") + if not merge: + near(bodies[1 - new_index]["area_mm2"], math.pi * 9, "original circular patch preserved") + plate() + call("untrim_surface", selection={"surface_edges": [0]}, extend_percent=20) + near(area(), 120, "edge untrim percentage") + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="NotchedProfile") + points = [(0, 0), (10, 0), (10, 5), (5, 5), (5, 10), (0, 10)] + for first, second in zip(points, points[1:] + points[:1]): + call("draw_line", x1_mm=first[0], y1_mm=first[1], x2_mm=second[0], y2_mm=second[1]) + call("close_sketch") + call("planar_surface", sketch_name="NotchedProfile") + near(area(), 75, "notched surface") + edges = call("list_edges", body_type="surface")["edges"] + notch = [e["index"] for e in edges if all(p[0] >= 5 and p[1] >= 5 for p in (e["start_mm"], e["end_mm"]))] + assert len(notch) == 2 + call("untrim_surface", selection={"surface_edges": notch}, edge_mode="connect_endpoints") + near(area(), 87.5, "connected notch triangular fill") + print("Surface extension, face/edge untrim, percentage and opposite-region geometry verified.", flush=True) + finally: + titles = [str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))] + for title in titles: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/test_surface_edit.py b/tests/test_surface_edit.py new file mode 100644 index 0000000..c20b7e0 --- /dev/null +++ b/tests/test_surface_edit.py @@ -0,0 +1,84 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +import unittest +from types import SimpleNamespace +from unittest.mock import patch +from solidworks_mcp import sw_core, sw_multibody + + +class SurfaceTopologyContract(unittest.TestCase): + def test_component_fallback_does_not_leak_solid_bodies_into_surface_indices(self): + body = SimpleNamespace(GetType=lambda: sw_core.BODY_SOLID) + component = SimpleNamespace(GetBody=lambda: body) + self.assertEqual(sw_core.component_bodies(component), [body]) + self.assertEqual(sw_core.component_bodies(component, sw_core.BODY_SHEET), []) + self.assertEqual(sw_core.component_bodies(component, sw_core.BODY_ALL), [body]) + + def test_surface_indices_are_separate_and_match_measurement_order(self): + solid = SimpleNamespace(GetFaces=lambda: ["solid-face"], GetEdges=lambda: ["solid-edge"]) + sheets = [SimpleNamespace(GetFaces=lambda: ["sheet-face-a", "sheet-face-b"], GetEdges=lambda: ["sheet-edge-a"]), + SimpleNamespace(GetFaces=lambda: ["sheet-face-c"], GetEdges=lambda: ["sheet-edge-b", "sheet-edge-c"])] + def contexts(doc, kind=sw_core.BODY_SOLID): + return [(body, "body", None) for body in (sheets if kind == sw_core.BODY_SHEET else [solid])] + with patch.object(sw_core, "iter_body_context", side_effect=contexts): + self.assertEqual([f["_obj"] for f in sw_core.enumerate_faces(None)], ["solid-face"]) + self.assertEqual([e["_obj"] for e in sw_core.enumerate_edges(None)], ["solid-edge"]) + for measured, walk in ((sw_core.enumerate_faces, sw_core.iter_face_objects), + (sw_core.enumerate_edges, sw_core.iter_edge_objects)): + self.assertEqual([e["_obj"] for e in measured(None, sw_core.BODY_SHEET)], + [obj for obj, _ in walk(None, sw_core.BODY_SHEET)]) + with patch.object(sw_core, "clear_selection"), patch.object(sw_core, "select_object", return_value=True) as select: + self.assertEqual(sw_core.apply_selection(None, {"surface_faces": [2], "surface_edges": [1]}, mark=4), 2) + self.assertEqual([c.args[1] for c in select.call_args_list], ["sheet-face-c", "sheet-edge-b"]) + self.assertTrue(all(c.args[2:] == (4, True) for c in select.call_args_list)) + + def test_invalid_boundaries_fail_before_com(self): + for selection in ({}, {"faces": [0]}, {"surface_edges": [-1]}, {"surface_faces": [True]}, + {"surface_edges": [0, 0]}, {"surface_faces": []}): + for function in (sw_multibody.extend_surface, sw_multibody.untrim_surface): + with self.subTest(selection=selection, function=function.__name__), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + function({"selection": selection, "distance_mm": 1}) + require.assert_not_called() + + def test_invalid_end_conditions_and_untrim_options_fail_before_com(self): + cases = [(sw_multibody.extend_surface, {"distance_mm": float("nan")}), + (sw_multibody.extend_surface, {"end_condition": "up_to_point"}), + (sw_multibody.extend_surface, {"end_condition": "distance", "distance_mm": 1, "target_selection": {"faces": [0]}}), + (sw_multibody.untrim_surface, {"extend_percent": -1}), + (sw_multibody.untrim_surface, {"extend_percent": 20}), + (sw_multibody.untrim_surface, {"trim_opposite_side": True})] + for function, options in cases: + with self.subTest(options=options), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + function({"selection": {"surface_faces": [0]}, **options}) + require.assert_not_called() + + def test_old_untrim_api_does_not_discard_trim_opposite_side(self): + from unittest.mock import Mock + old = Mock(return_value="feature") + manager = SimpleNamespace(InsertUntrimSurface=old) + with patch.object(sw_multibody, "require_part", return_value=(None, object())), \ + patch.object(sw_multibody, "exit_active_sketch"), patch.object(sw_multibody, "require_selection"), \ + patch.object(sw_multibody, "feature_manager", return_value=manager), \ + patch.object(sw_multibody, "_finish", return_value={"ok": True}): + sw_multibody.untrim_surface({"selection": {"surface_faces": [0]}, "merge": False}) + old.assert_called_once_with(0, 2, 0.0, False) + old.reset_mock() + sw_multibody.untrim_surface({"selection": {"surface_edges": [0]}, "extend_percent": 20}) + old.assert_called_once_with(0, 2, 20.0, True) + old.reset_mock() + with self.assertRaises(RuntimeError): + sw_multibody.untrim_surface({"selection": {"surface_faces": [0]}, "merge": False, "trim_opposite_side": True}) + old.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index a7a363b..8315365 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 260e047..47505cf 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -62,6 +62,26 @@ async def call(name, arguments=None): area = sum(body["area_mm2"] for body in surfaces["surface_bodies"]) if not math.isclose(area, 2 * math.pi * 5 * 10, rel_tol=1e-7): raise RuntimeError("Installed MCP revolved surface area differs.") + edges = await call("list_edges", {"body_type": "surface"}) + if len(edges["edges"]) != 2: + raise RuntimeError("Installed MCP surface-edge enumeration differs.") + await call("extend_surface", {"selection": {"surface_edges": [edges["edges"][0]["index"]]}, "distance_mm": 5}) + surfaces = await call("list_surface_bodies") + actual_area = sum(body["area_mm2"] for body in surfaces["surface_bodies"]) + if not math.isclose(actual_area, 2 * math.pi * 5 * 15, rel_tol=1e-6, abs_tol=0.05): + raise RuntimeError(f"Installed MCP surface extension area differs: {actual_area}; edges={edges['edges']}.") + await call("create_sketch", {"plane": "front", "name": "InstalledHoleProfile"}) + await call("draw_rectangle", {"x1_mm": 20, "y1_mm": 20, "x2_mm": 30, "y2_mm": 30}) + await call("draw_circle", {"x_mm": 25, "y_mm": 25, "radius_mm": 1}) + await call("close_sketch") + await call("planar_surface", {"sketch_name": "InstalledHoleProfile"}) + faces = await call("list_faces", {"body_type": "surface", "surface_type": "plane"}) + if len(faces["faces"]) != 1: + raise RuntimeError("Installed MCP surface-face enumeration differs.") + await call("untrim_surface", {"selection": {"surface_faces": [faces["faces"][0]["index"]]}, "face_mode": "internal"}) + faces = await call("list_faces", {"body_type": "surface", "surface_type": "plane"}) + if not math.isclose(faces["faces"][0]["area_mm2"], 100, rel_tol=1e-7): + raise RuntimeError("Installed MCP internal untrim area differs.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From 435def2cd38500e5c2ab679c87e406ca9fb11ef5 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:33:06 +0800 Subject: [PATCH 10/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=A1=AB=E5=85=85?= =?UTF-8?q?=E5=92=8C=E7=9B=B4=E7=BA=B9=E6=9B=B2=E9=9D=A2=E5=B9=B6=E6=A0=B8?= =?UTF-8?q?=E9=AA=8C=E5=8E=9F=E7=94=9F=E8=BF=9E=E7=BB=AD=E6=80=A7=E6=96=B9?= =?UTF-8?q?=E5=90=91=E4=B8=8E=E9=94=A5=E8=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 15 ++- README.md | 2 +- solidworks_mcp/sw_core.py | 8 ++ solidworks_mcp/sw_multibody.py | 168 ++++++++++++++++++++++++- tests/live_surface_patch_regression.py | 150 ++++++++++++++++++++++ tests/test_surface_patch.py | 92 ++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 18 +++ 8 files changed, 450 insertions(+), 5 deletions(-) create mode 100644 tests/live_surface_patch_regression.py create mode 100644 tests/test_surface_patch.py diff --git a/COVERAGE.md b/COVERAGE.md index 48cf0f3..3945536 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 158 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 160 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -74,6 +74,19 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 不能按毫米换算。测试以面积、边类型和端点核验,独立曲面不依赖实体返回顺序。 曲面修剪、复杂非平面延伸及解除修剪的其他组合仍保留为缺口。 +新增填充曲面与直纹曲面 2 个工具。填充验证草图边界、内孔、接触与相切控制, +合并与独立曲面,以及补面封闭立方体后的实际体积;控制参数读取原生特征定义核验。 +内约束曲线、关闭优化后的分辨率读回也已验证。 +直纹验证相切、法向、沿向量扫描、锥度及垂直向量模式,以面积、面法向和特征参数核验。 +垂直向量的反向、替换参考面及修剪缝合也已通过端点、面法向或原生选项读回核验。 +锥角在创建接口未按输入生效时,使用原生特征定义修改,读回确认后才返回成功。 +本机填充曲率控制传入 `swCurvature=2` 后,原生 `GetCurvatureControl` 返回 `-1`, +`SetCurvatureControl` 也返回失败;多个选择标记下结果相同。工具保留该参数并明确 +返回未确认状态,不能以生成了一个补面当作曲率连续性成功,该细项仍未完成。 +多组混合连续性、复杂非平面补面、反向选项、直纹连接面的删除和其他组合仍待专项验证。 +法向直纹曲面的反向参数在新旧创建接口实测均未改变方向,修改原生距离也未改变方向; +该请求会返回未确认状态并保留已创建特征的信息,仍是待补齐项。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index f60beb2..e0ed8e9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -158 tools: sketching with real relations and driving dimensions, the solid +160 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index c732960..8ec14eb 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -450,10 +450,18 @@ def dispatch_array(values: Sequence[Any]) -> Any: return win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, tuple(values)) +def integer_array(values: Sequence[int]) -> Any: + return win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_I4, tuple(int(v) for v in values)) + + def byref_long(initial: int = 0) -> Any: return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, initial) +def byref_variant() -> Any: + return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None) + + def as_list(com_array: Any) -> list[Any]: """Normalise the several shapes SOLIDWORKS uses for 'array or nothing'.""" if com_array is None: diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index 4124857..6007f42 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -16,10 +16,10 @@ import math from .sw_core import ( - BODY_SHEET, SELECTION_SCHEMA, dispatch_array, byref_long, clear_selection, exit_active_sketch, call_versioned, + BODY_SHEET, BODY_SOLID, SELECTION_SCHEMA, dispatch_array, integer_array, byref_long, byref_variant, clear_selection, exit_active_sketch, call_versioned, feature_manager, feature_result, flag_methods, get_bodies, nothing, rename_feature, require_part, require_selection, result, - select_sketch_for_feature, to_m, to_rad, tool, as_list, value, + select_sketch_for_feature, to_m, to_rad, tool, as_list, value, safe, ) from .sw_feature import _feature_created_after, _feature_names @@ -437,3 +437,167 @@ def untrim_surface(args): if not opposite: candidates.append(("InsertUntrimSurface", parameters)) return _finish(doc, call_versioned(manager, *candidates), args, "surface untrim") + + +def _selected_objects(doc, selection, mark=0): + require_selection(doc, selection, mark=mark) + manager = flag_methods(doc.SelectionManager, "GetSelectedObjectCount2", "GetSelectedObject6") + return [manager.GetSelectedObject6(i, mark) for i in range(1, manager.GetSelectedObjectCount2(mark) + 1)] + + +def _curve_selection(selection): + if not isinstance(selection, dict) or not selection or set(selection) - {"edges", "surface_edges", "sketches"} or not any(selection.values()): + raise RuntimeError("Select boundary edges or whole sketches.") + for key, entries in selection.items(): + if not isinstance(entries, list) or len(set(entries)) != len(entries): + raise RuntimeError("Boundary selection lists must contain distinct entries.") + if key != "sketches" and any(not isinstance(i, int) or isinstance(i, bool) or i < 0 for i in entries): + raise RuntimeError("Boundary edge indices must be nonnegative integers.") + if key == "sketches" and any(not isinstance(i, str) or not i.strip() for i in entries): + raise RuntimeError("Boundary sketches require nonempty names.") + + +@tool("fill_surface", "Create a filled surface from edge/sketch boundary groups with requested continuity and native control readback. Sketches support contact only. Curvature is forwarded but returns failure if the native API cannot confirm it. Supports direction faces, internal constraints, optimize, merge and try-to-form-solid.", + {"boundaries": {"type": "array", "minItems": 1, "items": {"type": "object", "properties": { + "selection": SELECTION_SCHEMA, "continuity": {"type": "string", "enum": ["contact", "tangent", "curvature"], "default": "contact"}, + "direction_face_selection": SELECTION_SCHEMA}, "required": ["selection"]}}, + "constraint_selection": SELECTION_SCHEMA, "resolution": {"type": "integer", "minimum": 1, "maximum": 3, "default": 2}, + "optimize": {"type": "boolean", "default": True}, "merge": {"type": "boolean", "default": False}, + "try_form_solid": {"type": "boolean", "default": False}, "reverse_direction": {"type": "boolean", "default": False}, + "reverse_surface": {"type": "boolean", "default": False}, "name": NAME}, ["boundaries"]) +def fill_surface(args): + groups = args["boundaries"] + resolution = args.get("resolution", 2) + controls = {"contact": 0, "tangent": 1, "curvature": 2} + if not isinstance(groups, list) or not groups or not isinstance(resolution, int) or isinstance(resolution, bool) or not 1 <= resolution <= 3: + raise RuntimeError("Provide boundary groups and resolution 1, 2 or 3.") + seen = set() + for group in groups: + if not isinstance(group, dict) or "selection" not in group: + raise RuntimeError("Each boundary group needs a selection.") + _curve_selection(group["selection"]) + for key, entries in group["selection"].items(): + for entry in entries: + identity = (key, entry) + if identity in seen: + raise RuntimeError("A boundary entity cannot appear in multiple groups.") + seen.add(identity) + continuity = group.get("continuity", "contact") + if continuity not in controls or (continuity != "contact" and group["selection"].get("sketches")): + raise RuntimeError("Invalid continuity; sketch boundaries support contact only.") + if args.get("constraint_selection"): + _curve_selection(args["constraint_selection"]) + _, doc = require_part() + exit_active_sketch(doc) + boundaries, types, faces = [], [], [] + for group in groups: + objects = _selected_objects(doc, group["selection"]) + direction = None + if group.get("direction_face_selection"): + selected = _selected_objects(doc, group["direction_face_selection"]) + if len(selected) != 1: + raise RuntimeError("Select exactly one direction face per boundary group.") + direction = selected[0] + boundaries.extend(objects) + types.extend([controls[group.get("continuity", "contact")]] * len(objects)) + faces.extend([direction] * len(objects)) + constraints = _selected_objects(doc, args["constraint_selection"]) if args.get("constraint_selection") else [] + clear_selection(doc) + for group in groups: + mark = {"contact": 257, "tangent": 513, "curvature": 1}[group.get("continuity", "contact")] + require_selection(doc, group["selection"], mark=mark, append=True) + if constraints: + require_selection(doc, args["constraint_selection"], mark=4, append=True) + options = sum(flag for key, flag, default in (("optimize", 1, True), ("try_form_solid", 2, False), + ("merge", 4, False), ("reverse_direction", 8, False), ("reverse_surface", 16, False)) if args.get(key, default)) + manager = flag_methods(feature_manager(doc), "InsertFillSurface2") + feature = manager.InsertFillSurface2(resolution, options, dispatch_array(boundaries), integer_array(types), + dispatch_array(faces) if any(face is not None for face in faces) else nothing(), + dispatch_array(constraints) if constraints else nothing()) + payload = _finish(doc, feature, args, "filled surface") + if payload["ok"]: + data = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess", "GetPatchBoundary", "GetCurvatureControl") + if not data.AccessSelections(doc, nothing()): + payload.update(ok=False, message="Filled surface was created, but native continuity could not be read.") + else: + try: + entities = as_list(data.GetPatchBoundary(byref_variant())) + actual = [int(data.GetCurvatureControl(entity)) for entity in entities] + payload["data"]["native_continuity_controls"] = actual + if sorted(actual) != sorted(types): + payload.update(ok=False, message="Filled surface was created, but native continuity does not confirm the requested controls.") + finally: + data.ReleaseSelectionAccess() + if payload["ok"] and args.get("try_form_solid", False): + filled_faces = as_list(safe(feature, "GetFaces")) + if not any(safe(safe(face, "GetBody"), "GetType") == BODY_SOLID for face in filled_faces): + payload.update(ok=False, message="Filled surface did not produce a face on a solid body.") + return payload + + +@tool("ruled_surface", "Create a ruled surface along solid or surface edges. Supports tangent, normal, tapered-to-vector, perpendicular-to-vector and sweep modes. Length is mm, taper angle is degrees; sweep can use a numeric direction vector. Alternate face uses native selection mark 6. Normal-mode pull reversal remains unconfirmed and returns failure after creation.", + {"selection": SELECTION_SCHEMA, "mode": {"type": "string", "enum": ["tangent", "normal", "tapered", "perpendicular", "sweep"], "default": "tangent"}, + "length_mm": {"type": "number", "exclusiveMinimum": 0}, "angle_deg": {"type": "number", "default": 0}, + "direction_selection": SELECTION_SCHEMA, "direction_vector": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, + "alternate_face": {"type": "boolean", "default": False}, "flip_pull_direction": {"type": "boolean", "default": False}, + "flip_direction": {"type": "boolean", "default": False}, "trim_and_knit": {"type": "boolean", "default": False}, + "remove_connecting_surfaces": {"type": "boolean", "default": False}, "name": NAME}, ["selection", "length_mm"]) +def ruled_surface(args): + _curve_selection(args["selection"]) + if args["selection"].get("sketches"): + raise RuntimeError("Ruled surfaces require model edges, not sketch boundaries.") + modes = {"tangent": 0, "normal": 1, "tapered": 2, "perpendicular": 3, "sweep": 4} + mode = args.get("mode", "tangent") + length = _positive(args["length_mm"], "length_mm") + angle = float(args.get("angle_deg", 0)) + if mode not in modes or not math.isfinite(angle) or abs(angle) >= 90 or (mode != "tapered" and angle != 0): + raise RuntimeError("Invalid ruled mode or taper angle; taper angle must be between -90 and 90 degrees.") + direction = args.get("direction_selection") + vector = args.get("direction_vector") + if vector is not None: + if mode != "sweep" or direction or not isinstance(vector, list) or len(vector) != 3: + raise RuntimeError("Numeric direction_vector is supported only by sweep and cannot be combined with direction_selection.") + vector = [float(v) for v in vector] + magnitude = math.sqrt(sum(v * v for v in vector)) + if not math.isfinite(magnitude) or magnitude == 0: + raise RuntimeError("Direction vector must be finite and nonzero.") + vector = [v / magnitude for v in vector] + if mode in {"tapered", "perpendicular", "sweep"} and not direction and vector is None: + raise RuntimeError("This ruled mode requires a direction_selection or a sweep direction_vector.") + if mode in {"tangent", "normal"} and direction: + raise RuntimeError("Tangent and normal modes do not accept direction_selection.") + if args.get("flip_pull_direction", False) and mode not in {"normal", "tapered"}: + raise RuntimeError("flip_pull_direction applies only to normal and tapered modes.") + if args.get("flip_direction", False) and mode != "perpendicular": + raise RuntimeError("flip_direction applies only to perpendicular mode.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, args["selection"], mark=6 if args.get("alternate_face", False) else 4) + if direction and require_selection(doc, direction, mark=1, append=True) != 1: + raise RuntimeError("Select exactly one direction reference.") + manager = flag_methods(feature_manager(doc), "InsertRuledSurfaceFromEdge2", "InsertRuledSurfaceFromEdge") + parameters = (modes[mode], to_m(length), bool(args.get("flip_pull_direction", False)), bool(args.get("flip_direction", False)), + bool(args.get("trim_and_knit", False)), to_rad(angle), vector is not None, *(vector or [0, 0, 0])) + candidates = [("InsertRuledSurfaceFromEdge2", parameters + (bool(args.get("remove_connecting_surfaces", False)),))] + if not args.get("remove_connecting_surfaces", False): + candidates.append(("InsertRuledSurfaceFromEdge", parameters)) + feature = call_versioned(manager, *candidates) + angle_applied = True + if feature is not None and mode == "tapered": + data = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") + if not data.AccessSelections(doc, nothing()): + angle_applied = False + else: + try: + if not math.isclose(float(value(data, "Angle")), to_rad(angle), abs_tol=1e-8): + data.Angle = to_rad(angle) + angle_applied = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(data, doc, nothing())) + finally: + data.ReleaseSelectionAccess() + angle_applied = angle_applied and math.isclose(float(value(value(feature, "GetDefinition"), "Angle")), to_rad(angle), abs_tol=1e-8) + payload = _finish(doc, feature, args, "ruled surface") + if not angle_applied: + payload.update(ok=False, message="Ruled surface was created, but the requested taper angle could not be confirmed.") + if feature is not None and mode == "normal" and args.get("flip_pull_direction", False): + payload.update(ok=False, message="Ruled surface was created, but normal-mode pull reversal is not confirmed by this implementation.") + return payload diff --git a/tests/live_surface_patch_regression.py b/tests/live_surface_patch_regression.py new file mode 100644 index 0000000..9efb38b --- /dev/null +++ b/tests/live_surface_patch_regression.py @@ -0,0 +1,150 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Filled and ruled surface geometry checks on disposable parts.""" + +import math +import pythoncom +import win32com.client +from live_expansion_regression import block, call, near, volume +from live_surface_boundary_regression import plate +from live_surface_construction_regression import area +from solidworks_mcp.sw_core import as_list, byref_long, running_app, value, find_feature, flag_methods, require_part, nothing, HANDLERS + + +def fill_controls(continuity, constraints=0): + _, doc = require_part() + feature = find_feature(doc, "TestFill") + data = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess", "GetPatchBoundary", "GetCurvatureControl") + assert data.AccessSelections(doc, nothing()) + try: + types = win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None) + entities = as_list(data.GetPatchBoundary(types)) + assert entities + expected = {"contact": 0, "tangent": 1, "curvature": 2}[continuity] + actual = [data.GetCurvatureControl(entity) for entity in entities] + assert all(control == expected for control in actual), (continuity, expected, actual) + assert value(data, "GetConstraintCurvesCount") == constraints + finally: + data.ReleaseSelectionAccess() + + +def sketch_fill(constraint=False): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="FillProfile") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + call("close_sketch") + options = {} + if constraint: + call("create_sketch", plane="front", name="FillConstraint") + call("draw_line", x1_mm=0, y1_mm=5, x2_mm=10, y2_mm=5) + call("close_sketch") + options = {"constraint_selection": {"sketches": ["FillConstraint"]}, "optimize": False, "resolution": 3} + call("fill_surface", boundaries=[{"selection": {"sketches": ["FillProfile"]}}], name="TestFill", **options) + near(area(), 100, "sketch filled surface area") + fill_controls("contact", 1 if constraint else 0) + if constraint: + _, doc = require_part() + data = value(find_feature(doc, "TestFill"), "GetDefinition") + assert not value(data, "OptimizeSurface") and value(data, "ResolutionControl") == 3 + + +def hole_fill(continuity, merge): + _, edges = plate(hole=True) + hole = next(e["index"] for e in edges if e["curve_type"] == "circle") + arguments = dict(boundaries=[{"selection": {"surface_edges": [hole]}, "continuity": continuity, + "direction_face_selection": {"surface_faces": [0]}}], merge=merge, name="TestFill") + if continuity == "curvature": + payload = HANDLERS["fill_surface"](arguments) + assert not payload["ok"] and payload["data"]["native_continuity_controls"] == [-1], payload + print("Verified native curvature-readback gap; not reported as success.", flush=True) + else: + call("fill_surface", **arguments) + near(area(), 100, "filled hole total area") + assert len(call("list_surface_bodies")["surface_bodies"]) == (1 if merge else 2) + if continuity != "curvature": + fill_controls(continuity) + + +def close_cube(): + block() + top = call("list_faces", normal=[0, 0, 1])["faces"][0]["index"] + call("delete_faces", selection={"faces": [top]}, mode="delete") + edges = call("list_edges", body_type="surface")["edges"] + rim = [e["index"] for e in edges if all(abs(e[key][2] - 10) < 1e-6 for key in ("start_mm", "end_mm"))] + assert len(rim) == 4 + call("fill_surface", boundaries=[{"selection": {"surface_edges": rim}}], merge=True, try_form_solid=True) + near(volume(), 1000, "filled cube closure volume") + + +def ruled(mode, **options): + plate() + arguments = {"direction_vector": [0, 0, 1]} if mode == "sweep" else {} + if mode in {"tapered", "perpendicular"}: + arguments = {"direction_selection": {"planes": ["front"]}} + if mode == "tapered": + arguments["angle_deg"] = 30 + call("ruled_surface", selection={"surface_edges": [0]}, mode=mode, length_mm=5, name="TestRuled", **arguments, **options) + bodies = call("list_surface_bodies")["surface_bodies"] + near(area(), 150, "ruled patch total area") + faces = call("list_faces", body_type="surface")["faces"] + patch = next(face for face in faces if abs(face["area_mm2"] - 50) < 1e-4) + near(abs(patch["normal"][2]), 0.5 if mode == "tapered" else (1 if mode in {"tangent", "perpendicular"} else 0), "ruled patch inclination") + _, doc = require_part() + data = value(find_feature(doc, "TestRuled"), "GetDefinition") + assert value(data, "Type") == {"tangent": 1, "normal": 2, "tapered": 3, "perpendicular": 4, "sweep": 5}[mode] + near(value(data, "Distance") * 1000, 5, "ruled length readback") + if mode == "tapered": + near(value(data, "Angle") * 180 / math.pi, 30, "ruled angle readback") + assert bool(value(data, "TrimAndKnit")) == bool(options.get("trim_and_knit", False)) + edges = call("list_edges", body_type="surface")["edges"] + corners = [edge[key] for edge in edges if edge["body_index"] == patch["body_index"] for key in ("start_mm", "end_mm")] + return [(min(p[i] for p in corners), max(p[i] for p in corners)) for i in range(3)] + + +def run(): + app = running_app() + initial = {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + try: + sketch_fill() + sketch_fill(constraint=True) + for continuity in ("contact", "tangent", "curvature"): + hole_fill(continuity, True) + hole_fill("contact", False) + close_cube() + bounds = {mode: ruled(mode) for mode in ("tangent", "normal", "sweep", "tapered", "perpendicular")} + plate() + payload = HANDLERS["ruled_surface"]({"selection": {"surface_edges": [0]}, "mode": "normal", + "length_mm": 5, "flip_pull_direction": True}) + assert not payload["ok"] and "not confirmed" in payload["message"], payload + near(area(), 150, "normal reversal gap does not report successful direction change") + flipped = ruled("perpendicular", flip_direction=True) + assert any(abs(a-b) > 1 for pair, other in zip(bounds["perpendicular"], flipped) for a,b in zip(pair, other)) + ruled("normal", trim_and_knit=True) + normals = [] + for alternate in (False, True): + block() + call("ruled_surface", selection={"edges": [0]}, mode="normal", length_mm=5, alternate_face=alternate) + near(area(), 50, "solid-edge ruled area") + face = call("list_faces", body_type="surface")["faces"][0] + normals.append(face["normal"]) + near(sum(a*b for a,b in zip(*normals)), 0, "alternate ruled face orthogonality") + print("Filled and ruled surface geometry verified.", flush=True) + finally: + titles = [str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))] + for title in titles: + if title not in initial: + app.CloseDoc(title) + if original: + app.ActivateDoc3(original, False, 0, byref_long()) + assert {str(value(d, "GetTitle")) for d in as_list(value(app, "GetDocuments"))} == initial + + +if __name__ == "__main__": + run() diff --git a/tests/test_surface_patch.py b/tests/test_surface_patch.py new file mode 100644 index 0000000..a876da4 --- /dev/null +++ b/tests/test_surface_patch.py @@ -0,0 +1,92 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch +from solidworks_mcp import sw_multibody + + +class SurfacePatchContracts(unittest.TestCase): + def test_invalid_fill_boundaries_fail_before_com(self): + cases = [{"boundaries": []}, {"boundaries": [{"selection": {"faces": [0]}}]}, + {"boundaries": [{"selection": {"sketches": ["Profile"]}, "continuity": "tangent"}]}, + {"boundaries": [{"selection": {"edges": [0]}}, {"selection": {"edges": [0]}}]}, + {"boundaries": [{"selection": {"edges": [0]}}], "resolution": True}] + for args in cases: + with self.subTest(args=args), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.fill_surface(args) + require.assert_not_called() + + def test_invalid_ruled_arguments_cannot_silently_discard_a_direction(self): + cases = [{"length_mm": float("inf")}, {"mode": "tapered"}, {"mode": "perpendicular"}, + {"mode": "sweep", "direction_vector": [0, 0, 0]}, {"direction_vector": [0, 0, 1]}, + {"flip_pull_direction": True}, {"mode": "normal", "flip_direction": True}, + {"mode": "tapered", "direction_selection": {"planes": ["front"]}, "angle_deg": 90}] + for options in cases: + with self.subTest(options=options), patch.object(sw_multibody, "require_part") as require: + with self.assertRaises(RuntimeError): + sw_multibody.ruled_surface({"selection": {"edges": [0]}, "length_mm": 5, **options}) + require.assert_not_called() + + def test_ruled_numeric_vector_is_unitless_and_length_is_metres(self): + insert = Mock(return_value="feature") + manager = SimpleNamespace(InsertRuledSurfaceFromEdge2=insert) + with patch.object(sw_multibody, "require_part", return_value=(None, object())), \ + patch.object(sw_multibody, "exit_active_sketch"), patch.object(sw_multibody, "require_selection") as selection, \ + patch.object(sw_multibody, "feature_manager", return_value=manager), \ + patch.object(sw_multibody, "_finish", return_value={"ok": True}): + sw_multibody.ruled_surface({"selection": {"surface_edges": [0]}, "mode": "sweep", "length_mm": 5, + "direction_vector": [0, 0, 10], "alternate_face": True}) + insert.assert_called_once_with(4, 0.005, False, False, False, 0.0, True, 0.0, 0.0, 1.0, False) + self.assertEqual(selection.call_args.kwargs["mark"], 6) + + def test_legacy_ruled_api_does_not_drop_remove_connecting_surfaces(self): + old = Mock(return_value="feature") + manager = SimpleNamespace(InsertRuledSurfaceFromEdge=old) + with patch.object(sw_multibody, "require_part", return_value=(None, object())), \ + patch.object(sw_multibody, "exit_active_sketch"), patch.object(sw_multibody, "require_selection"), \ + patch.object(sw_multibody, "feature_manager", return_value=manager), \ + patch.object(sw_multibody, "_finish", return_value={"ok": True}): + sw_multibody.ruled_surface({"selection": {"edges": [0]}, "length_mm": 5}) + self.assertEqual(old.call_count, 1) + old.reset_mock() + with self.assertRaises(RuntimeError): + sw_multibody.ruled_surface({"selection": {"edges": [0]}, "length_mm": 5, "remove_connecting_surfaces": True}) + old.assert_not_called() + + def test_unconfirmed_normal_reversal_does_not_report_success(self): + manager = SimpleNamespace(InsertRuledSurfaceFromEdge2=Mock(return_value="feature")) + with patch.object(sw_multibody, "require_part", return_value=(None, object())), \ + patch.object(sw_multibody, "exit_active_sketch"), patch.object(sw_multibody, "require_selection"), \ + patch.object(sw_multibody, "feature_manager", return_value=manager), \ + patch.object(sw_multibody, "_finish", return_value={"ok": True, "data": {"feature": "NativeFeature"}}): + output = sw_multibody.ruled_surface({"selection": {"surface_edges": [0]}, "mode": "normal", + "length_mm": 5, "flip_pull_direction": True}) + self.assertFalse(output["ok"]) + self.assertEqual(output["data"]["feature"], "NativeFeature") + + def test_fill_does_not_report_success_for_unconfirmed_native_continuity(self): + data = SimpleNamespace(AccessSelections=lambda *args: True, ReleaseSelectionAccess=Mock(), + GetPatchBoundary=lambda *args: [object()], GetCurvatureControl=lambda obj: -1) + feature = SimpleNamespace(GetDefinition=lambda: data) + manager = SimpleNamespace(InsertFillSurface2=Mock(return_value=feature)) + with patch.object(sw_multibody, "require_part", return_value=(None, object())), \ + patch.object(sw_multibody, "exit_active_sketch"), patch.object(sw_multibody, "clear_selection"), \ + patch.object(sw_multibody, "require_selection"), patch.object(sw_multibody, "_selected_objects", return_value=[object()]), \ + patch.object(sw_multibody, "get_bodies", return_value=[]), patch.object(sw_multibody, "feature_manager", return_value=manager), \ + patch.object(sw_multibody, "_finish", return_value={"ok": True, "data": {}}): + output = sw_multibody.fill_surface({"boundaries": [{"selection": {"edges": [0]}, "continuity": "curvature"}]}) + self.assertFalse(output["ok"]) + self.assertEqual(output["data"]["native_continuity_controls"], [-1]) + data.ReleaseSelectionAccess.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 8315365..1b565cc 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 47505cf..4080f33 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -82,6 +82,24 @@ async def call(name, arguments=None): faces = await call("list_faces", {"body_type": "surface", "surface_type": "plane"}) if not math.isclose(faces["faces"][0]["area_mm2"], 100, rel_tol=1e-7): raise RuntimeError("Installed MCP internal untrim area differs.") + await call("create_sketch", {"plane": "front", "name": "InstalledFillProfile"}) + await call("draw_rectangle", {"x1_mm": 60, "y1_mm": 60, "x2_mm": 70, "y2_mm": 70}) + await call("close_sketch") + filled = await call("fill_surface", {"boundaries": [{"selection": {"sketches": ["InstalledFillProfile"]}}]}) + if filled["native_continuity_controls"] != [0]: + raise RuntimeError("Installed MCP filled-surface continuity readback differs.") + faces = await call("list_faces", {"body_type": "surface", "surface_type": "plane"}) + filled_face = next(face for face in faces["faces"] if face["point_mm"][0] >= 50) + if not math.isclose(filled_face["area_mm2"], 100, rel_tol=1e-7): + raise RuntimeError("Installed MCP filled-surface area differs.") + edges = await call("list_edges", {"body_type": "surface"}) + boundary = next(edge["index"] for edge in edges["edges"] if edge["body_index"] == filled_face["body_index"]) + await call("ruled_surface", {"selection": {"surface_edges": [boundary]}, "mode": "tapered", + "length_mm": 5, "angle_deg": 30, "direction_selection": {"planes": ["front"]}}) + faces = await call("list_faces", {"body_type": "surface"}) + patch = next(face for face in faces["faces"] if math.isclose(face["area_mm2"], 50, rel_tol=1e-7)) + if not math.isclose(abs(patch["normal"][2]), 0.5, abs_tol=1e-6): + raise RuntimeError("Installed MCP ruled-surface taper inclination differs.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From db5e8e1cfc650613fe7c780ca847db500e423d9f Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:40:58 +0800 Subject: [PATCH 11/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E6=80=A7=E5=88=A0=E9=99=A4=E6=9B=B2=E9=9D=A2=E5=AD=94=E5=B9=B6?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=96=B0=E6=97=A7=E5=8E=9F=E7=94=9F=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 7 ++- README.md | 3 +- solidworks_mcp/sw_multibody.py | 22 +++++++++ tests/live_surface_holes_regression.py | 63 ++++++++++++++++++++++++++ tests/test_surface_holes.py | 43 ++++++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 18 ++++++++ 7 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 tests/live_surface_holes_regression.py create mode 100644 tests/test_surface_holes.py diff --git a/COVERAGE.md b/COVERAGE.md index 3945536..7238cbf 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 160 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 161 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -87,6 +87,11 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 法向直纹曲面的反向参数在新旧创建接口实测均未改变方向,修改原生距离也未改变方向; 该请求会返回未确认状态并保留已创建特征的信息,仍是待补齐项。 +新增 `delete_surface_holes`,按 `surface_edges` 选择孔边界。三孔平面实测只补选中孔, +以剩余圆边周长计算剩余孔面积,再同时补其余两孔恢复 200 mm²;外边界、 +曲面体数量及非实体状态均保持正确。新接口与强制旧接口路径均通过本机验证。 +旧版本软件尚未实测,网格 BREP 不属于此接口支持范围。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index e0ed8e9..5821187 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -160 tools: sketching with real relations and driving dimensions, the solid +161 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -195,6 +195,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `move_copy_bodies` | 实体平移、旋转或复制;平移和旋转分两次调用,长度为 mm、角度为度。 | | `combine_bodies` | 多实体并集、差集与交集;差集的第一个实体为保留的主体。 | | `delete_bodies` | 创建删除/保留实体特征。 | +| `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | | `offset_surface` / `knit_surfaces` / `thicken_surface` | 偏移面、缝合曲面和曲面加厚;读取实际曲面或实体结果。 | | `list_surface_bodies` | 查询曲面体及其面积,面积单位为 mm²。 | diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index 6007f42..69cfecb 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -379,6 +379,28 @@ def _surface_boundary_selection(selection): raise RuntimeError("Surface topology indices must be distinct nonnegative integers.") +@tool("delete_surface_holes", "Remove selected hole boundaries from surface bodies. Select surface_edges from list_edges(body_type=surface); unselected holes are preserved. Re-list topology afterwards.", + {"selection": SELECTION_SCHEMA, "name": NAME}, ["selection"]) +def delete_surface_holes(args): + selection = args["selection"] + _surface_boundary_selection(selection) + if set(selection) != {"surface_edges"}: + raise RuntimeError("Surface hole removal accepts only surface_edges.") + _, doc = require_part() + exit_active_sketch(doc) + require_selection(doc, selection) + manager = flag_methods(feature_manager(doc), "InsertDeleteHoleForSurface") + try: + create = manager.InsertDeleteHoleForSurface + except AttributeError: + before = _feature_names(doc) + flag_methods(doc, "InsertDeleteHole").InsertDeleteHole() + feature = _feature_created_after(doc, before) + else: + feature = create() + return _finish(doc, feature, args, "surface hole removal") + + @tool("extend_surface", "Extend selected surface faces (all boundary edges) or individual surface edges. Distance is mm; up_to_point requires a selected point and up_to_surface requires a solid face. Re-list surface topology afterwards.", {"selection": SELECTION_SCHEMA, "linear": {"type": "boolean", "default": False}, "end_condition": {"type": "string", "enum": ["distance", "up_to_point", "up_to_surface"], "default": "distance"}, diff --git a/tests/live_surface_holes_regression.py b/tests/live_surface_holes_regression.py new file mode 100644 index 0000000..af39815 --- /dev/null +++ b/tests/live_surface_holes_regression.py @@ -0,0 +1,63 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +"""Verify selective and multiple surface-hole removal on disposable parts.""" +import math +from types import SimpleNamespace +from unittest.mock import patch +from live_expansion_regression import call, near +from live_surface_construction_regression import area +from solidworks_mcp import sw_multibody +from solidworks_mcp.sw_core import running_app + + +def run(): + app = running_app() + original = call("list_open_documents")["active_title"] + scratch = [] + try: + for legacy in (False, True): + title = call("create_new_document", kind="part")["document"]["title"] + scratch.append(title) + call("create_sketch", plane="front", name="ThreeHoles") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=20, y2_mm=10) + for x, radius in ((4, 1), (10, 2), (16, 1)): + call("draw_circle", x_mm=x, y_mm=5, radius_mm=radius) + call("close_sketch") + call("planar_surface", sketch_name="ThreeHoles") + near(area(), 200 - 6 * math.pi, "three-hole initial area") + edges = call("list_edges", body_type="surface")["edges"] + holes = [e["index"] for e in edges if e["curve_type"] == "circle"] + assert len(holes) == 3 + with patch.object(sw_multibody, "feature_manager", return_value=SimpleNamespace()) if legacy else patch.object(sw_multibody, "feature_manager", wraps=sw_multibody.feature_manager): + call("delete_surface_holes", selection={"surface_edges": [holes[0]]}, name="FirstHoleRemoved") + remaining = call("list_edges", body_type="surface")["edges"] + remaining_holes = [e["index"] for e in remaining if e["curve_type"] == "circle"] + assert len(remaining_holes) == 2 and len(remaining) == 6 + # Read the remaining circular edge lengths instead of assuming index order. + expected = 200 - sum((e["length_mm"] / (2 * math.pi)) ** 2 * math.pi + for e in remaining if e["curve_type"] == "circle") + near(area(), expected, "unselected holes preserved") + call("delete_surface_holes", selection={"surface_edges": remaining_holes}, name="RemainingHolesRemoved") + near(area(), 200, "multiple-hole removal area") + assert len(call("list_edges", body_type="surface")["edges"]) == 4 + assert len(call("list_surface_bodies")["surface_bodies"]) == 1 + assert call("list_faces")["total_faces"] == 0 + print("Selective/multiple surface-hole removal and legacy native path verified.", flush=True) + finally: + open_titles = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in open_titles: + call("close_document", name=title, discard_changes=True) + if original and original in open_titles: + call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_surface_holes.py b/tests/test_surface_holes.py new file mode 100644 index 0000000..464984c --- /dev/null +++ b/tests/test_surface_holes.py @@ -0,0 +1,43 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch +from solidworks_mcp import sw_multibody as surfaces + + +class SurfaceHoleContract(unittest.TestCase): + def test_invalid_selection_rejected_before_com(self): + for selection in ({}, {"edges": [0]}, {"surface_faces": [0]}, + {"surface_edges": [True]}, {"surface_edges": [0, 0]}): + with self.subTest(selection=selection), patch.object(surfaces, "require_part") as require: + with self.assertRaises(RuntimeError): + surfaces.delete_surface_holes({"selection": selection}) + require.assert_not_called() + + def test_version_dispatch_preserves_real_failures(self): + for modern in (True, False): + legacy = Mock() + create = Mock(return_value="feature") + manager = SimpleNamespace(**({"InsertDeleteHoleForSurface": create} if modern else {})) + doc = SimpleNamespace(InsertDeleteHole=legacy) + with patch.object(surfaces, "require_part", return_value=(None, doc)), \ + patch.object(surfaces, "exit_active_sketch"), patch.object(surfaces, "require_selection"), \ + patch.object(surfaces, "feature_manager", return_value=manager), \ + patch.object(surfaces, "_feature_names", return_value=["before"]), \ + patch.object(surfaces, "_feature_created_after", return_value="feature"), \ + patch.object(surfaces, "_finish", return_value={"ok": True}): + surfaces.delete_surface_holes({"selection": {"surface_edges": [0]}}) + self.assertEqual(legacy.call_count, 0 if modern else 1) + if modern: + create.side_effect = RuntimeError("native failure") + with self.assertRaisesRegex(RuntimeError, "native failure"): + surfaces.delete_surface_holes({"selection": {"surface_edges": [0]}}) + legacy.assert_not_called() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 1b565cc..b5f7b0e 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 4080f33..1628ed7 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -100,6 +100,24 @@ async def call(name, arguments=None): patch = next(face for face in faces["faces"] if math.isclose(face["area_mm2"], 50, rel_tol=1e-7)) if not math.isclose(abs(patch["normal"][2]), 0.5, abs_tol=1e-6): raise RuntimeError("Installed MCP ruled-surface taper inclination differs.") + await call("create_sketch", {"plane": "front", "name": "InstalledSelectiveHoles"}) + await call("draw_rectangle", {"x1_mm": 90, "y1_mm": 90, "x2_mm": 100, "y2_mm": 100}) + for x in (93, 97): + await call("draw_circle", {"x_mm": x, "y_mm": 95, "radius_mm": 1}) + await call("close_sketch") + await call("planar_surface", {"sketch_name": "InstalledSelectiveHoles"}) + faces = (await call("list_faces", {"body_type": "surface"}))["faces"] + hole_face = next(face for face in faces if face["point_mm"][0] >= 90) + edges = (await call("list_edges", {"body_type": "surface"}))["edges"] + holes = [edge["index"] for edge in edges + if edge["body_index"] == hole_face["body_index"] and edge["curve_type"] == "circle"] + if len(holes) != 2: + raise RuntimeError("Installed MCP hole fixture topology differs.") + await call("delete_surface_holes", {"selection": {"surface_edges": [holes[0]]}}) + faces = (await call("list_faces", {"body_type": "surface"}))["faces"] + hole_face = next(face for face in faces if face["point_mm"][0] >= 90) + if not math.isclose(hole_face["area_mm2"], 100 - math.pi, rel_tol=1e-7): + raise RuntimeError("Installed MCP selective hole removal changed an unselected hole.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From cc0157d7b06091a71ca3efa03512f573a5bd0643 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:02:16 +0800 Subject: [PATCH 12/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E6=9B=B2=E9=9D=A2=E5=B9=B6=E6=A0=B8=E9=AA=8C=E6=89=AD=E8=BD=AC?= =?UTF-8?q?=E5=AF=BC=E5=BC=95=E4=B8=8E=E6=89=AB=E6=8F=8F=E6=96=B9=E5=90=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 11 +- README.md | 3 +- solidworks_mcp/sw_multibody.py | 111 +++++++++++++++++++ tests/live_surface_sweep_regression.py | 148 +++++++++++++++++++++++++ tests/test_surface_sweep.py | 90 +++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 15 +++ 7 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 tests/live_surface_sweep_regression.py create mode 100644 tests/test_surface_sweep.py diff --git a/COVERAGE.md b/COVERAGE.md index 7238cbf..0b929d9 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 161 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 162 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -92,6 +92,15 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 曲面体数量及非实体状态均保持正确。新接口与强制旧接口路径均通过本机验证。 旧版本软件尚未实测,网格 BREP 不属于此接口支持范围。 +新增 `surface_sweep`,验证开口/闭合轮廓、数值圆截面、保持法向、单/双导引线, +第一/第二/双向扫描与反向扭转,以及相切、平滑、合并选项的原生读回。 +第一方向的反向采用带符号角度并修改原生定义;第二方向使用独立的角度通道, +同时读取原生参数、边界和中间截面坐标核验几何。扭转曲面使用原生导数独立积分 +与解析面积比较;GetArea 为近似值,双向过渡曲面不假定为两个独立螺旋面。 +截面点到预期扭转方向的偏差要求小于 0.01 mm,面积积分沿用 0.05 mm² 容差。 +现代定义接口要求 SW 2018+,仅 SW 2026 SP3.2 已实测。曲线/模型边作为路径与轮廓、 +复杂弯曲导引、弯曲路径上的法向控制、端部相切、方向向量、最小扭转、法向恒定同时扭转及旧版创建接口仍有缺口。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index 5821187..91daf9b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -161 tools: sketching with real relations and driving dimensions, the solid +162 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -195,6 +195,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `move_copy_bodies` | 实体平移、旋转或复制;平移和旋转分两次调用,长度为 mm、角度为度。 | | `combine_bodies` | 多实体并集、差集与交集;差集的第一个实体为保留的主体。 | | `delete_bodies` | 创建删除/保留实体特征。 | +| `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | | `offset_surface` / `knit_surfaces` / `thicken_surface` | 偏移面、缝合曲面和曲面加厚;读取实际曲面或实体结果。 | diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index 69cfecb..3386149 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -467,6 +467,117 @@ def _selected_objects(doc, selection, mark=0): return [manager.GetSelectedObject6(i, mark) for i in range(1, manager.GetSelectedObjectCount2(mark) + 1)] +@tool("surface_sweep", "Sweep an open/closed sketch profile or a numeric circular profile along a path sketch. Supports guide sketches, follow-path/keep-normal/constant-twist control and one/both directions. Angles are degrees and diameter is mm. Requires the modern surface-sweep definition API (SOLIDWORKS 2018+). Native settings are read back before confirming success.", + {"profile_sketch": NAME, "path_sketch": NAME, + "circular_diameter_mm": {"type": "number", "exclusiveMinimum": 0}, + "guide_sketches": {"type": "array", "items": NAME, "uniqueItems": True}, + "twist_control": {"type": "string", "enum": ["follow_path", "keep_normal", "constant_twist", "first_guide", "two_guides"], "default": "follow_path"}, + "twist_angle_deg": NUM, "second_twist_angle_deg": NUM, + "reverse_twist": {"type": "boolean", "default": False}, "reverse_second_twist": {"type": "boolean", "default": False}, + "direction": {"type": "string", "enum": ["first", "both", "second"], "default": "first"}, + "keep_tangency": {"type": "boolean", "default": False}, + "advanced_smoothing": {"type": "boolean", "default": False}, + "merge_smooth_faces": {"type": "boolean", "default": False}, "name": NAME}, ["path_sketch"]) +def surface_sweep(args): + profile, path = args.get("profile_sketch"), args["path_sketch"] + circular = "circular_diameter_mm" in args + diameter = _positive(args["circular_diameter_mm"], "circular_diameter_mm") if circular else 0 + if not isinstance(path, str) or not path.strip() or (circular and profile) or (not circular and (not isinstance(profile, str) or not profile.strip())): + raise RuntimeError("Provide a path sketch and exactly one sketch or circular profile.") + guides = args.get("guide_sketches", []) + if not isinstance(guides, list) or any(not isinstance(g, str) or not g.strip() for g in guides) or len(set(guides)) != len(guides): + raise RuntimeError("Guide sketches must be distinct nonempty names.") + if profile == path or any(g in (profile, path) for g in guides): + raise RuntimeError("Profile, path and guide sketches must be distinct.") + controls = {"follow_path": 0, "keep_normal": 1, "constant_twist": 8, "first_guide": 2, "two_guides": 3} + directions = {"first": 0, "both": 1, "second": 2} + mode, direction = args.get("twist_control", "follow_path"), args.get("direction", "first") + angle, second = float(args.get("twist_angle_deg", 0)), float(args.get("second_twist_angle_deg", 0)) + reverse, reverse_second = bool(args.get("reverse_twist", False)), bool(args.get("reverse_second_twist", False)) + if mode not in controls or direction not in directions or not all(math.isfinite(a) and a >= 0 for a in (angle, second)): + raise RuntimeError("Invalid sweep control, direction or nonnegative twist angle.") + if (angle or second or reverse or reverse_second) and mode != "constant_twist": + raise RuntimeError("Twist angles and reversals require constant_twist control.") + if (second or reverse_second) and direction != "both": + raise RuntimeError("Second twist settings require bidirectional sweeping.") + if (direction == "both" and guides) or (mode == "first_guide" and len(guides) < 1) or (mode == "two_guides" and len(guides) < 2): + raise RuntimeError("Guide control requires at least one/two guide sketches; bidirectional sweeps do not support guides.") + if circular and (guides or mode != "follow_path" or direction != "first"): + raise RuntimeError("Circular profiles support follow_path control without guides or bidirectional options.") + _, doc = require_part() + exit_active_sketch(doc) + clear_selection(doc) + if profile: + require_selection(doc, {"sketches": [profile]}, mark=1, append=True) + require_selection(doc, {"sketches": [path]}, mark=4, append=True) + if guides: + require_selection(doc, {"sketches": guides}, mark=2, append=True) + manager = flag_methods(feature_manager(doc), "CreateDefinition", "CreateFeature") + data = manager.CreateDefinition(62) # swFmRefSurface initializes ISweepFeatureData. + if data is None: + raise RuntimeError("This SOLIDWORKS build cannot create a modern surface-sweep definition.") + data = flag_methods(data, "SetTwistAngle", "SetD2TwistAngle") + settings = {"TwistControlType": controls[mode], "CircularProfile": circular, + "Direction": directions[direction], "MaintainTangency": bool(args.get("keep_tangency", False)), + "AdvancedSmoothing": bool(args.get("advanced_smoothing", False)), + "MergeSmoothFaces": bool(args.get("merge_smooth_faces", False))} + if circular: + settings["CircularProfileDiameter"] = to_m(diameter) + if mode == "constant_twist": + settings["AlignWithEndFaces"] = False + if direction in {"both", "second"}: + settings["D2ReverseTwistDir"] = reverse_second if direction == "both" else reverse + for key, expected in settings.items(): + setattr(data, key, expected) + def set_twist(definition): + definition.SetTwistAngle(0 if direction == "second" else to_rad(-angle if reverse else angle)) + if direction in {"both", "second"}: + definition.SetD2TwistAngle(to_rad(second if direction == "both" else angle)) + definition.D2ReverseTwistDir = reverse_second if direction == "both" else reverse + if mode == "constant_twist": + set_twist(data) + feature = manager.CreateFeature(data) + edit_applied = True + if feature is not None and (reverse or reverse_second): + # Negative twist is stored by CreateFeature but initially yields positive + # geometry on the tested build. ModifyDefinition rebuilds the signed twist. + edit = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "SetTwistAngle", "SetD2TwistAngle", "ReleaseSelectionAccess") + if edit.AccessSelections(doc, nothing()): + try: + set_twist(edit) + edit_applied = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(edit, doc, nothing())) + finally: + edit.ReleaseSelectionAccess() + else: + edit_applied = False + payload = _finish(doc, feature, args, "surface sweep") + if not edit_applied: + payload.update(ok=False, message="Surface sweep was created, but signed twist geometry could not be rebuilt.") + if payload["ok"]: + actual = value(feature, "GetDefinition") + readback = {key: value(actual, key) for key in settings} + readback["guide_count"] = int(value(actual, "GetGuideCurvesCount")) + settings["guide_count"] = len(guides) + if mode == "constant_twist": + readback["twist_angle_deg"] = math.degrees(float(value(actual, "GetD2TwistAngle" if direction == "second" else "GetTwistAngle"))) + settings["twist_angle_deg"] = angle if direction == "second" else -angle if reverse else angle + if direction == "both": + readback["second_twist_angle_deg"] = math.degrees(float(value(actual, "GetD2TwistAngle"))) + settings["second_twist_angle_deg"] = second + payload["data"]["native_settings"] = readback + # Direction is inapplicable (-1) for circular profiles and endpoint profiles. + # Only the default first direction can accept that native sentinel. + if direction == "first" and readback["Direction"] == -1: + settings.pop("Direction") + mismatched = [key for key, expected in settings.items() + if not math.isclose(float(readback[key]), float(expected), abs_tol=1e-8)] + if mismatched: + payload.update(ok=False, message=f"Surface sweep was created, but native settings differ: {', '.join(mismatched)}.") + elif not as_list(safe(feature, "GetFaces")) or any(safe(safe(face, "GetBody"), "GetType") != BODY_SHEET for face in as_list(safe(feature, "GetFaces"))): + payload.update(ok=False, message="Surface sweep did not produce faces on a surface body.") + return payload + + def _curve_selection(selection): if not isinstance(selection, dict) or not selection or set(selection) - {"edges", "surface_edges", "sketches"} or not any(selection.values()): raise RuntimeError("Select boundary edges or whole sketches.") diff --git a/tests/live_surface_sweep_regression.py b/tests/live_surface_sweep_regression.py new file mode 100644 index 0000000..fe869f7 --- /dev/null +++ b/tests/live_surface_sweep_regression.py @@ -0,0 +1,148 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +"""Sweep surface area, twist and direction checks on disposable parts.""" +import math +from live_expansion_regression import call, near +from live_surface_construction_regression import area +from solidworks_mcp.sw_core import BODY_SHEET, get_bodies, require_part, value, as_list, flag_methods + + +def twisted_area(angle): + k = math.radians(angle) / 10 + return 100 if k == 0 else 10 * (5 * math.sqrt(1 + (5 * k) ** 2) + math.asinh(5 * k) / k) + + +def integrated_area(): + # Independently integrate the native surface Jacobian; GetArea is approximate + # on twisted spline surfaces. These fixtures have rectangular UV domains. + _, doc = require_part() + total = 0 + n = 24 + for body in get_bodies(doc, BODY_SHEET): + for face in as_list(value(body, "GetFaces")): + u0, u1, v0, v1 = value(face, "GetUVBounds") + surface = flag_methods(value(face, "GetSurface"), "Evaluate") + partial = 0 + for i in range(n + 1): + wi = 1 if i in (0, n) else 4 if i % 2 else 2 + for j in range(n + 1): + wj = 1 if j in (0, n) else 4 if j % 2 else 2 + sample = surface.Evaluate(u0 + (u1 - u0) * i / n, v0 + (v1 - v0) * j / n, 1, 1) + du, dv = sample[3:6], sample[6:9] + cross = [du[1] * dv[2] - du[2] * dv[1], du[2] * dv[0] - du[0] * dv[2], du[0] * dv[1] - du[1] * dv[0]] + partial += wi * wj * math.sqrt(sum(x * x for x in cross)) + total += partial * (u1 - u0) * (v1 - v0) / (9 * n * n) * 1e6 + return total + + +def verify_twist_coordinates(options): + _, doc = require_part() + for body in get_bodies(doc, BODY_SHEET): + for face in as_list(value(body, "GetFaces")): + u0, u1, v0, v1 = value(face, "GetUVBounds") + surface = flag_methods(value(face, "GetSurface"), "Evaluate") + samples = (0, 0.5, 1) if options.get("direction") == "both" else (0, 0.25, 0.5, 0.75, 1) + along_v = abs(surface.Evaluate(u1, v1, 0, 0)[2] - surface.Evaluate(u1, v0, 0, 0)[2]) > 0.005 + def point(t): + return surface.Evaluate(u1 if along_v else u0 + t * (u1 - u0), v0 + t * (v1 - v0) if along_v else v1, 0, 0)[:3] + start, end = point(0), point(1) + for t in samples: + x, y, z = point(t) + assert math.isclose(math.hypot(x, y), 0.005, abs_tol=1e-6) + if options.get("direction") == "both": + angle = options.get("twist_angle_deg", 0) if z >= 0 else options.get("second_twist_angle_deg", 0) + reverse = options.get("reverse_twist", False) if z >= 0 else options.get("reverse_second_twist", False) + else: + angle, reverse = options.get("twist_angle_deg", 0), options.get("reverse_twist", False) + fraction = abs(z) / 0.01 if options.get("direction") in ("both", "second") else (z - start[2]) / (end[2] - start[2]) + origin = 0 if options.get("direction") in ("both", "second") else math.atan2(start[1], start[0]) + expected = origin + math.radians(angle) * fraction * (-1 if reverse else 1) + delta = math.atan2(y, x) - expected + error = abs(math.atan2(math.sin(2 * delta), math.cos(2 * delta)) / 2) + # Compare positional deviation in mm; sweep splines approximate + # constant twist between sections rather than exact helicoids. + assert abs(math.sin(error)) * math.hypot(x, y) * 1000 < 0.01, (x, y, z, expected, error) + + +def run(cases=None): + original = call("list_open_documents")["active_title"] + scratch = [] + try: + cases = cases or [{}, {"twist_control": "keep_normal"}, + {"twist_control": "constant_twist", "twist_angle_deg": 90}, + {"twist_control": "constant_twist", "twist_angle_deg": 90, "reverse_twist": True}, + {"direction": "both", "twist_control": "constant_twist", "twist_angle_deg": 45, "second_twist_angle_deg": 90}, + {"direction": "both", "twist_control": "constant_twist", "twist_angle_deg": 45, "second_twist_angle_deg": 45, + "reverse_twist": True, "reverse_second_twist": True}, + {"direction": "second"}, {"direction": "second", "twist_control": "constant_twist", "twist_angle_deg": 45, "reverse_twist": True}, + {"interior": True, "direction": "first"}, + {"interior": True, "direction": "first", "twist_control": "constant_twist", "twist_angle_deg": 45, "reverse_twist": True}, + {"circular_diameter_mm": 10}, {"closed": True}, + {"closed": True, "keep_tangency": True, "advanced_smoothing": True, "merge_smooth_faces": True}, + {"twist_control": "first_guide"}, {"twist_control": "two_guides"}, + {"twist_control": "first_guide", "extra_guide": True}] + for options in cases: + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + closed = options.pop("closed", False) + interior = options.pop("interior", False) or options.get("direction") in ("both", "second") + extra_guide = options.pop("extra_guide", False) + if "circular_diameter_mm" not in options: + call("create_sketch", plane="front", name="Profile") + if closed: + call("draw_circle", x_mm=0, y_mm=0, radius_mm=5) + else: + call("draw_line", x1_mm=-5, y1_mm=0, x2_mm=5, y2_mm=0) + call("close_sketch") + options["profile_sketch"] = "Profile" + call("create_sketch", plane="right", name="Path") + call("draw_line", x1_mm=10 if interior else 0, y1_mm=0, x2_mm=-10, y2_mm=0) + call("close_sketch") + if options.get("twist_control") in ("first_guide", "two_guides"): + options["guide_sketches"] = [] + for i in range(1 if options["twist_control"] == "first_guide" and not extra_guide else 2): + call("create_plane", mode="offset", selection={"planes": ["right"]}, distance_mm=5, flip=bool(i), name=f"GuidePlane{i}") + call("create_sketch", plane_name=f"GuidePlane{i}", name=f"Guide{i}") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=-10, y2_mm=0) + call("close_sketch") + options["guide_sketches"].append(f"Guide{i}") + data = call("surface_sweep", path_sketch="Path", name="SweepSurface", **options) + expected = 2 * math.pi * 5 * 10 if closed or "circular_diameter_mm" in options else twisted_area(options.get("twist_angle_deg", 0)) + if options.get("direction") == "both": + expected += twisted_area(options.get("second_twist_angle_deg", 0)) + if options.get("twist_control") == "constant_twist": + verify_twist_coordinates(options) + integrated = integrated_area() + if options.get("direction") != "both": + near(integrated, expected, "native surface integral vs analytic helicoid") + reported = area() + assert math.isclose(reported, integrated, rel_tol=0.001), (reported, integrated) + print(f"Native area={reported}, integrated={integrated}" + (f", analytic={expected}" if options.get("direction") != "both" else "; bidirectional boundary twists verified"), flush=True) + else: + near(area(), expected, "swept area") + assert len(call("list_surface_bodies")["surface_bodies"]) == 1 + assert call("list_faces")["total_faces"] == 0 + edges = call("list_edges", body_type="surface")["edges"] + points = [edge[key] for edge in edges for key in ("point_mm", "start_mm", "end_mm") if edge.get(key)] + z0, z1 = min(p[2] for p in points), max(p[2] for p in points) + near(z0, -10 if options.get("direction") in ("both", "second") else 0, "sweep start extent") + near(z1, 0 if options.get("direction") == "second" else 10, "sweep end extent") + print(data["native_settings"], flush=True) + print("Open/closed/circular sweep areas and twist/direction controls verified.", flush=True) + finally: + opened = {doc["title"] for doc in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: + call("close_document", name=title, discard_changes=True) + if original and original in opened: + call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_surface_sweep.py b/tests/test_surface_sweep.py new file mode 100644 index 0000000..101575e --- /dev/null +++ b/tests/test_surface_sweep.py @@ -0,0 +1,90 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +import math +import unittest +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +from solidworks_mcp import sw_multibody as surfaces + + +class SurfaceSweepContract(unittest.TestCase): + def test_incompatible_options_fail_before_com(self): + cases = [{}, {"profile_sketch": "P", "circular_diameter_mm": 10}, + {"circular_diameter_mm": float("nan")}, + {"profile_sketch": "P", "twist_angle_deg": 20}, + {"profile_sketch": "P", "twist_control": "constant_twist", "twist_angle_deg": -20}, + {"profile_sketch": "P", "twist_control": "two_guides", "guide_sketches": ["G"]}, + {"profile_sketch": "P", "direction": "both", "guide_sketches": ["G"]}, + {"profile_sketch": "Path"}, + {"profile_sketch": "P", "guide_sketches": ["G", "G"]}] + for options in cases: + with self.subTest(options=options), patch.object(surfaces, "require_part") as require: + with self.assertRaises(RuntimeError): + surfaces.surface_sweep({"path_sketch": "Path", **options}) + require.assert_not_called() + + def invoke(self, options=None, overrides=None, edit_success=True): + settings = dict(TwistControlType=0, CircularProfile=False, Direction=-1, + MaintainTangency=False, AdvancedSmoothing=False, MergeSmoothFaces=False, + AlignWithEndFaces=False, GetTwistAngle=lambda: -math.pi / 2, + GetD2TwistAngle=lambda: math.pi / 4, GetGuideCurvesCount=lambda: 0) + settings.update(overrides or {}) + definition = SimpleNamespace(**settings, AccessSelections=Mock(return_value=edit_success), + ReleaseSelectionAccess=Mock(), SetTwistAngle=Mock(), SetD2TwistAngle=Mock()) + body = SimpleNamespace(GetType=lambda: surfaces.BODY_SHEET) + face = SimpleNamespace(GetBody=lambda: body) + feature = SimpleNamespace(GetDefinition=lambda: definition, GetFaces=lambda: [face], + ModifyDefinition=Mock(return_value=edit_success)) + manager = SimpleNamespace(CreateDefinition=Mock(return_value=SimpleNamespace(SetTwistAngle=Mock(), SetD2TwistAngle=Mock())), + CreateFeature=Mock(return_value=feature)) + with ExitStack() as stack: + for name, kwargs in (("require_part", {"return_value": (None, object())}), + ("feature_manager", {"return_value": manager}), + ("_finish", {"return_value": {"ok": True, "data": {"feature": "Sweep"}}}), + ("exit_active_sketch", {}), ("clear_selection", {}), ("require_selection", {})): + stack.enter_context(patch.object(surfaces, name, **kwargs)) + return surfaces.surface_sweep({"profile_sketch": "P", "path_sketch": "Path", **(options or {})}), feature, definition + + def test_endpoint_direction_sentinel_does_not_confirm_bidirectional_request(self): + self.assertTrue(self.invoke()[0]["ok"]) + payload, _, _ = self.invoke({"direction": "both"}) + self.assertFalse(payload["ok"]) + self.assertIn("Direction", payload["message"]) + + def test_signed_twist_requires_successful_geometry_rebuild(self): + options = {"twist_control": "constant_twist", "twist_angle_deg": 90, "reverse_twist": True} + payload, feature, definition = self.invoke(options, {"TwistControlType": 8}) + self.assertTrue(payload["ok"]) + feature.ModifyDefinition.assert_called_once() + definition.ReleaseSelectionAccess.assert_called_once() + self.assertFalse(self.invoke(options, {"TwistControlType": 8}, edit_success=False)[0]["ok"]) + + def test_native_readback_mismatch_keeps_feature_but_reports_failure(self): + payload, _, _ = self.invoke({"keep_tangency": True}) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["feature"], "Sweep") + self.assertIn("MaintainTangency", payload["message"]) + + def test_second_direction_uses_its_own_twist_channel(self): + payload, _, definition = self.invoke( + {"direction": "second", "twist_control": "constant_twist", "twist_angle_deg": 45, "reverse_twist": True}, + {"Direction": 2, "TwistControlType": 8, "D2ReverseTwistDir": True}) + self.assertTrue(payload["ok"]) + definition.SetTwistAngle.assert_called_once_with(0) + definition.SetD2TwistAngle.assert_called_once_with(math.pi / 4) + self.assertEqual(payload["data"]["native_settings"]["twist_angle_deg"], 45) + + def test_guide_control_uses_first_guides_without_limiting_total_guide_count(self): + for mode, control, names in (("first_guide", 2, ["G1", "G2"]), + ("two_guides", 3, ["G1", "G2", "G3"])): + with self.subTest(mode=mode): + self.assertTrue(self.invoke({"twist_control": mode, "guide_sketches": names}, + {"TwistControlType": control, "GetGuideCurvesCount": lambda: len(names)})[0]["ok"]) diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index b5f7b0e..091bbde 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 1628ed7..9925b2e 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -118,6 +118,21 @@ async def call(name, arguments=None): hole_face = next(face for face in faces if face["point_mm"][0] >= 90) if not math.isclose(hole_face["area_mm2"], 100 - math.pi, rel_tol=1e-7): raise RuntimeError("Installed MCP selective hole removal changed an unselected hole.") + await call("create_sketch", {"plane": "front", "name": "InstalledSweepProfile"}) + await call("draw_line", {"x1_mm": -5, "y1_mm": 200, "x2_mm": 5, "y2_mm": 200}) + await call("close_sketch") + await call("create_sketch", {"plane": "right", "name": "InstalledSweepPath"}) + await call("draw_line", {"x1_mm": 0, "y1_mm": 200, "x2_mm": -10, "y2_mm": 200}) + await call("close_sketch") + swept = await call("surface_sweep", {"profile_sketch": "InstalledSweepProfile", "path_sketch": "InstalledSweepPath", + "twist_control": "constant_twist", "twist_angle_deg": 90, "reverse_twist": True}) + if not math.isclose(swept["native_settings"]["twist_angle_deg"], -90, abs_tol=1e-8): + raise RuntimeError("Installed MCP signed sweep twist readback differs.") + edges = (await call("list_edges", {"body_type": "surface"}))["edges"] + midpoints = [edge["point_mm"] for edge in edges if edge.get("point_mm") + and edge["point_mm"][1] > 190 and math.isclose(edge["point_mm"][2], 5, abs_tol=0.02)] + if len(midpoints) != 2 or any(p[0] * (p[1] - 200) >= 0 for p in midpoints): + raise RuntimeError(f"Installed MCP reversed sweep geometry differs: {midpoints}.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From 36a0c539fd56fef8fd17aa153440b5bb0a0e1701 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:14:46 +0800 Subject: [PATCH 13/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=AD=E9=9D=A2?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=8E=E5=8E=9F=E7=94=9F=E9=9D=A2=E9=85=8D?= =?UTF-8?q?=E5=AF=B9=E5=8E=9A=E5=BA=A6=E4=BD=8D=E7=BD=AE=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 11 ++- README.md | 3 +- solidworks_mcp/sw_core.py | 8 +++ solidworks_mcp/sw_multibody.py | 104 ++++++++++++++++++++++++++- tests/live_mid_surface_regression.py | 86 ++++++++++++++++++++++ tests/test_mid_surface.py | 75 +++++++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 13 ++++ 8 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 tests/live_mid_surface_regression.py create mode 100644 tests/test_mid_surface.py diff --git a/COVERAGE.md b/COVERAGE.md index 0b929d9..43f5e4c 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 162 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 164 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -101,6 +101,15 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 现代定义接口要求 SW 2018+,仅 SW 2026 SP3.2 已实测。曲线/模型边作为路径与轮廓、 复杂弯曲导引、弯曲路径上的法向控制、端部相切、方向向量、最小扭转、法向恒定同时扭转及旧版创建接口仍有缺口。 +新增中面创建与只读检查 2 个工具,实测平板、带孔板、L 形薄壁和圆管。 +平板面积 200 mm²、中面 Z=1 mm、厚度 2 mm;L 形中面面积 840 mm², +读取两组原始面配对,缝合/独立曲面体分别为 1/2;圆管中面半径 6 mm, +面积 2π×6×30 mm²。只读查询不新增特征,原始实体体积保持不变。 +本机新创建接口的非零 Placement(-1、-0.5、0.5、1)均仍生成中央中面, +旧接口在本机未生成中面。工具用中面点到原始 +配对面的距离核验实际位置,参数未生效会保留特征信息并返回失败,此细项仍未完成。 +装配体上下文、指定面配对、多实体和更复杂曲面及旧版本软件仍待覆盖或专项验证。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index 91daf9b..7dcbf3d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -162 tools: sketching with real relations and driving dimensions, the solid +164 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -195,6 +195,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `move_copy_bodies` | 实体平移、旋转或复制;平移和旋转分两次调用,长度为 mm、角度为度。 | | `combine_bodies` | 多实体并集、差集与交集;差集的第一个实体为保留的主体。 | | `delete_bodies` | 创建删除/保留实体特征。 | +| `mid_surface` / `get_mid_surface_data` | 原生自动中面与只读检查;读取面配对、厚度、面积、位置和缝合结果。非零位置未生效时返回失败。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index 8ec14eb..02efbe6 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -462,6 +462,14 @@ def byref_variant() -> Any: return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_VARIANT, None) +def byref_double(initial: float = 0) -> Any: + return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_R8, float(initial)) + + +def byref_dispatch() -> Any: + return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_DISPATCH, None) + + def as_list(com_array: Any) -> list[Any]: """Normalise the several shapes SOLIDWORKS uses for 'array or nothing'.""" if com_array is None: diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index 3386149..e310a21 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -19,7 +19,8 @@ BODY_SHEET, BODY_SOLID, SELECTION_SCHEMA, dispatch_array, integer_array, byref_long, byref_variant, clear_selection, exit_active_sketch, call_versioned, feature_manager, feature_result, flag_methods, get_bodies, nothing, rename_feature, require_part, require_selection, result, - select_sketch_for_feature, to_m, to_rad, tool, as_list, value, safe, + select_sketch_for_feature, to_m, to_mm, to_rad, tool, as_list, value, safe, + byref_double, byref_dispatch, find_feature, _face_point, _surface_details, mm_point, ) from .sw_feature import _feature_created_after, _feature_names @@ -53,6 +54,107 @@ def _finish(doc, feature, args, action, **data): return feature_result(doc, feature, action, **data) +def _mid_face_info(face): + info = {"area_mm2": float(value(face, "GetArea")) * 1e6} + point = _face_point(face) + if point is not None: + info["point_mm"] = mm_point(point) + normal = safe(face, "Normal") + if normal is not None: + info["normal"] = [float(v) for v in normal[:3]] + surface = safe(face, "GetSurface") + if surface is not None: + info.update(_surface_details(surface)) + return info + + +def _mid_surface_info(feature): + if feature is None or safe(feature, "GetTypeName2") != "MidRefSurface": + raise RuntimeError("Specify an existing midsurface feature.") + mid = flag_methods(value(feature, "GetSpecificFeature2"), "GetFirstFacePair", "GetNextFacePair", "GetFirstFace", "GetNextFace") + pair_count = int(value(mid, "GetFacePairCount")) + face_count = int(value(mid, "GetFaceCount")) + faces = as_list(value(mid, "GetFaces")) + if pair_count <= 0 or face_count <= 0 or len(faces) != face_count: + raise RuntimeError("Native midsurface topology is empty or inconsistent.") + pairs = [] + for i in range(pair_count): + thickness, partner = byref_double(), byref_dispatch() + first = (mid.GetFirstFacePair if i == 0 else mid.GetNextFacePair)(thickness, partner) + if first is None or partner.value is None or not math.isfinite(thickness.value) or thickness.value <= 0: + raise RuntimeError("Native midsurface face-pair traversal is incomplete.") + pairs.append({"index": i, "thickness_mm": to_mm(thickness.value), + "source_faces": [_mid_face_info(first), _mid_face_info(partner.value)]}) + neutral = [] + for i in range(face_count): + first, partner, thickness = byref_dispatch(), byref_dispatch(), byref_double() + face = (mid.GetFirstFace if i == 0 else mid.GetNextFace)(first, partner, thickness) + if face is None or first.value is None or partner.value is None or thickness.value <= 0: + raise RuntimeError("Native neutral-face traversal is incomplete.") + info = _mid_face_info(face) + info["thickness_mm"] = to_mm(thickness.value) + info["measured_placement"] = _mid_placement(face, first.value, partner.value, thickness.value) + neutral.append(info) + return {"name": str(value(feature, "Name")), "face_pair_count": pair_count, + "face_count": face_count, "sheet_count": int(value(mid, "GetNeutralSheetCount")), + "area_mm2": sum(face["area_mm2"] for face in neutral), "face_pairs": pairs, "faces": neutral} + + +def _mid_placement(face, first, partner, thickness): + point = _face_point(face) + if point is None: + return None + try: + distances = [] + for source in (first, partner): + closest = flag_methods(source, "GetClosestPointOn").GetClosestPointOn(*point) + distances.append(math.sqrt(sum((point[i] - closest[i]) ** 2 for i in range(3)))) + if not math.isclose(sum(distances), thickness, rel_tol=1e-5, abs_tol=1e-7): + return None + return (distances[1] - distances[0]) / thickness + except Exception: + return None + + +@tool("mid_surface", "Create an automatic midsurface in the active part using native face-pair detection. Placement -1..1 requests the neutral surface position; 0 is halfway. Nonzero placement is forwarded but returns failure if actual geometry cannot confirm it. Knit requests one sewn surface body. Reports actual face pairs, thicknesses, surface counts and geometry.", + {"placement": {"type": "number", "minimum": -1, "maximum": 1, "default": 0}, + "knit": {"type": "boolean", "default": True}, "name": NAME}) +def mid_surface(args): + placement = float(args.get("placement", 0)) + if not math.isfinite(placement) or not -1 <= placement <= 1: + raise RuntimeError("Midsurface placement must be finite and between -1 and 1.") + _, doc = require_part() + exit_active_sketch(doc) + clear_selection(doc) + before = _feature_names(doc) + manager = flag_methods(feature_manager(doc), "InsertMidSurface") + try: + create = manager.InsertMidSurface + except AttributeError: + returned = flag_methods(doc, "InsertMidSurfaceExt").InsertMidSurfaceExt(placement, bool(args.get("knit", True))) + else: + # Body/document arguments apply to assembly context only; null in a part. + returned = create(nothing(), nothing(), placement, bool(args.get("knit", True))) + feature = _feature_created_after(doc, before) if returned is not None else None + payload = _finish(doc, feature, args, "midsurface") + if payload["ok"]: + info = _mid_surface_info(feature) + payload["data"]["midsurface"] = info + payload["data"]["requested_placement"] = placement + if any(face["measured_placement"] is None or not math.isclose(face["measured_placement"], placement, abs_tol=1e-5) for face in info["faces"]): + payload.update(ok=False, message="Midsurface was created, but its actual geometry does not confirm the requested placement.") + if args.get("knit", True) and info["sheet_count"] != 1: + payload.update(ok=False, message="Midsurface was created, but native knitting did not produce one surface body.") + return payload + + +@tool("get_mid_surface_data", "Read-only: inspect a named midsurface feature. Reports actual original face pairs and thicknesses in mm, neutral surface faces and total area in mm2; does not regenerate the feature.", + {"name": NAME}, ["name"]) +def get_mid_surface_data(args): + _, doc = require_part() + return result(True, "Read native midsurface geometry.", midsurface=_mid_surface_info(find_feature(doc, args["name"]))) + + @tool("scale_bodies", "Scale selected solid bodies uniformly or along X/Y/Z about their centroid or the model origin. Re-list topology afterwards.", {"selection": BODY_SELECTION, "factor": {"type": "number", "exclusiveMinimum": 0, "default": 1}, "uniform": {"type": "boolean", "default": True}, "y_factor": {"type": "number", "exclusiveMinimum": 0, "default": 1}, diff --git a/tests/live_mid_surface_regression.py b/tests/live_mid_surface_regression.py new file mode 100644 index 0000000..0ace865 --- /dev/null +++ b/tests/live_mid_surface_regression.py @@ -0,0 +1,86 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +"""Midsurface geometry, pairing, knitting and unconfirmed placement on scratch parts.""" +import math +from live_expansion_regression import call, near, volume +from solidworks_mcp.sw_core import HANDLERS + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + def part(): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + call("create_sketch", plane="front", name="SourceProfile") + try: + for knit, hole in ((False, False), (True, False), (True, True)): + part() + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=20, y2_mm=10) + if hole: + call("draw_circle", x_mm=10, y_mm=5, radius_mm=1) + call("close_sketch") + call("boss_extrude", sketch_name="SourceProfile", depth_mm=2) + source_volume = volume() + info = call("mid_surface", knit=knit, name="PlateMid")["midsurface"] + assert info["face_pair_count"] == info["face_count"] == info["sheet_count"] == 1 + near(info["area_mm2"], 200 - (math.pi if hole else 0), "plate midsurface area") + near(info["faces"][0]["point_mm"][2], 1, "plate midsurface position") + near(info["face_pairs"][0]["thickness_mm"], 2, "source face-pair thickness") + near(info["faces"][0]["measured_placement"], 0, "measured central placement") + before = call("list_features") + assert call("get_mid_surface_data", name="PlateMid")["midsurface"] == info + assert call("list_features") == before + near(volume(), source_volume, "original solid volume preserved") + for knit in (False, True): + part() + points = [(0, 0), (20, 0), (20, 2), (2, 2), (2, 10), (0, 10)] + for a, b in zip(points, points[1:] + points[:1]): + call("draw_line", x1_mm=a[0], y1_mm=a[1], x2_mm=b[0], y2_mm=b[1]) + call("close_sketch") + call("boss_extrude", sketch_name="SourceProfile", depth_mm=30) + info = call("mid_surface", knit=knit, name="LMid")["midsurface"] + assert info["face_pair_count"] == info["face_count"] == 2 + assert info["sheet_count"] == (1 if knit else 2) + near(info["area_mm2"], ((20 - 1) + (10 - 1)) * 30, "L midsurface area") + assert sorted(round(face["area_mm2"]) for face in info["faces"]) == [270, 570] + assert all(math.isclose(pair["thickness_mm"], 2) for pair in info["face_pairs"]) + assert all(abs(face["measured_placement"]) < 1e-6 for face in info["faces"]) + assert call("get_mid_surface_data", name="LMid")["midsurface"] == info + part() + for radius in (7, 5): + call("draw_circle", x_mm=0, y_mm=0, radius_mm=radius) + call("close_sketch") + call("boss_extrude", sketch_name="SourceProfile", depth_mm=30) + info = call("mid_surface", name="PipeMid")["midsurface"] + near(info["area_mm2"], 2 * math.pi * 6 * 30, "cylindrical midsurface area") + near(info["faces"][0]["radius_mm"], 6, "neutral cylinder radius") + near(info["face_pairs"][0]["thickness_mm"], 2, "pipe wall thickness") + assert call("get_mid_surface_data", name="PipeMid")["midsurface"] == info + for placement in (-1, -0.5, 0.5, 1): + part() + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=20, y2_mm=10) + call("close_sketch") + call("boss_extrude", sketch_name="SourceProfile", depth_mm=2) + payload = HANDLERS["mid_surface"]({"placement": placement, "name": "UnconfirmedMid"}) + assert not payload["ok"] and payload["data"]["feature"] == "UnconfirmedMid" + actual = call("get_mid_surface_data", name="UnconfirmedMid")["midsurface"] + near(actual["faces"][0]["measured_placement"], 0, "native nonzero placement remains unapplied") + print("Planar/holed/L/cylindrical midsurfaces and read-only inspection verified; nonzero placement remains a native gap.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: + call("close_document", name=title, discard_changes=True) + if original and original in opened: + call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_mid_surface.py b/tests/test_mid_surface.py new file mode 100644 index 0000000..45baede --- /dev/null +++ b/tests/test_mid_surface.py @@ -0,0 +1,75 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. +# limitations under the License. + +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch +from solidworks_mcp import sw_core, sw_multibody as surfaces + + +class MidSurfaceContract(unittest.TestCase): + def test_invalid_placement_rejected_before_com(self): + for placement in (-1.01, 1.01, float("nan"), float("inf")): + with self.subTest(placement=placement), patch.object(surfaces, "require_part") as require: + with self.assertRaises(RuntimeError): + surfaces.mid_surface({"placement": placement}) + require.assert_not_called() + + def test_out_parameters_use_double_and_dispatch_types(self): + with patch.object(sw_core.win32com.client, "VARIANT") as variant: + sw_core.byref_double(2) + variant.assert_called_with(sw_core.pythoncom.VT_BYREF | sw_core.pythoncom.VT_R8, 2.0) + sw_core.byref_dispatch() + variant.assert_called_with(sw_core.pythoncom.VT_BYREF | sw_core.pythoncom.VT_DISPATCH, None) + + def test_geometry_measurement_detects_actual_placement(self): + neutral = object() + first = SimpleNamespace(GetClosestPointOn=lambda *p: (0, 0, 0.002)) + partner = SimpleNamespace(GetClosestPointOn=lambda *p: (0, 0, 0)) + for height, expected in ((0.001, 0), (0, -1), (0.002, 1)): + with patch.object(surfaces, "_face_point", return_value=[0, 0, height]): + self.assertAlmostEqual(surfaces._mid_placement(neutral, first, partner, 0.002), expected) + with patch.object(surfaces, "_face_point", return_value=[1, 0, 0.001]): + self.assertIsNone(surfaces._mid_placement(neutral, first, partner, 0.002)) + + def test_incomplete_native_topology_cannot_be_reported_as_valid(self): + mid = SimpleNamespace(GetFacePairCount=lambda: 1, GetFaceCount=lambda: 2, GetFaces=lambda: [object()]) + feature = SimpleNamespace(GetTypeName2=lambda: "MidRefSurface", GetSpecificFeature2=lambda: mid) + with self.assertRaisesRegex(RuntimeError, "inconsistent"): + surfaces._mid_surface_info(feature) + with self.assertRaisesRegex(RuntimeError, "existing midsurface"): + surfaces._mid_surface_info(SimpleNamespace(GetTypeName2=lambda: "Extrusion")) + + def test_version_dispatch_and_placement_failure_preserve_feature_evidence(self): + for modern in (True, False): + create = Mock(return_value=object()) + legacy = Mock(return_value=object()) + manager = SimpleNamespace(**({"InsertMidSurface": create} if modern else {})) + doc = SimpleNamespace(InsertMidSurfaceExt=legacy) + info = {"sheet_count": 1, "faces": [{"measured_placement": 0}]} + with patch.object(surfaces, "require_part", return_value=(None, doc)), \ + patch.object(surfaces, "exit_active_sketch"), patch.object(surfaces, "clear_selection"), \ + patch.object(surfaces, "feature_manager", return_value=manager), \ + patch.object(surfaces, "_feature_names", return_value=[]), \ + patch.object(surfaces, "_feature_created_after", return_value="feature"), \ + patch.object(surfaces, "_finish", side_effect=lambda *a: {"ok": True, "data": {"feature": "Mid"}}), \ + patch.object(surfaces, "_mid_surface_info", return_value=info): + self.assertTrue(surfaces.mid_surface({})["ok"]) + payload = surfaces.mid_surface({"placement": 0.5}) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["feature"], "Mid") + if modern: + legacy.assert_not_called() + self.assertEqual(create.call_args.args[0].varianttype, sw_core.pythoncom.VT_DISPATCH) + create.side_effect = RuntimeError("native failure") + with self.assertRaisesRegex(RuntimeError, "native failure"): + surfaces.mid_surface({}) + legacy.assert_not_called() + else: + legacy.assert_called_with(0.5, True) diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 091bbde..fb358c1 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 9925b2e..39a0599 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -133,6 +133,19 @@ async def call(name, arguments=None): and edge["point_mm"][1] > 190 and math.isclose(edge["point_mm"][2], 5, abs_tol=0.02)] if len(midpoints) != 2 or any(p[0] * (p[1] - 200) >= 0 for p in midpoints): raise RuntimeError(f"Installed MCP reversed sweep geometry differs: {midpoints}.") + await call("create_sketch", {"plane": "front", "name": "InstalledMidProfile"}) + await call("draw_rectangle", {"x1_mm": 300, "y1_mm": 300, "x2_mm": 320, "y2_mm": 310}) + await call("close_sketch") + await call("boss_extrude", {"sketch_name": "InstalledMidProfile", "depth_mm": 2}) + middle = (await call("mid_surface", {"name": "InstalledMid"}))["midsurface"] + if middle["face_pair_count"] != 1 or middle["sheet_count"] != 1: + raise RuntimeError("Installed MCP midsurface topology differs.") + if not math.isclose(middle["area_mm2"], 200, abs_tol=1e-7) or not math.isclose(middle["face_pairs"][0]["thickness_mm"], 2, abs_tol=1e-7): + raise RuntimeError("Installed MCP midsurface area/thickness differs.") + if not math.isclose(middle["faces"][0]["point_mm"][2], 1, abs_tol=1e-7): + raise RuntimeError("Installed MCP midsurface placement differs.") + if (await call("get_mid_surface_data", {"name": "InstalledMid"}))["midsurface"] != middle: + raise RuntimeError("Installed MCP read-only midsurface inspection differs.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From 2d051996342f6973fada151247f2fd4e5ef7ebe9 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:39:08 +0800 Subject: [PATCH 14/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=9B=B2=E9=9D=A2?= =?UTF-8?q?=E4=BF=AE=E5=89=AA=E9=A2=84=E8=A7=88=E5=88=9B=E5=BB=BA=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E4=B8=8E=E5=B7=B2=E6=9C=89=E5=AE=9E=E4=BE=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 19 +++- README.md | 14 ++- solidworks_mcp/sw_core.py | 48 +++++++- solidworks_mcp/sw_manage.py | 40 +++++++ solidworks_mcp/sw_multibody.py | 156 ++++++++++++++++++++++++++ tests/live_surface_trim_regression.py | 129 +++++++++++++++++++++ tests/test_sessions.py | 65 +++++++++++ tests/test_surface_trim.py | 119 ++++++++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 26 +++++ 10 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 tests/live_surface_trim_regression.py create mode 100644 tests/test_sessions.py create mode 100644 tests/test_surface_trim.py diff --git a/COVERAGE.md b/COVERAGE.md index 43f5e4c..ec3e8da 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 164 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 169 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -110,6 +110,23 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 配对面的距离核验实际位置,参数未生效会保留特征信息并返回失败,此细项仍未完成。 装配体上下文、指定面配对、多实体和更复杂曲面及旧版本软件仍待覆盖或专项验证。 +新增曲面修剪预览、创建和只读数据查询 3 个工具。标准修剪以基准面、草图、 +曲面体与曲面面作为工具;预览返回临时区域面积、边界、选择点和稳定排序的索引。 +12 个临时零件验证了 40/60 mm² 两侧保留与删除、圆草图内外区域 4π/100−4π mm²、 +多目标共 80 mm²、相互修剪后 90 mm² 和圆柱侧面保留 2π×5×4 mm²。 +重复预览不新增特征、不改变曲面体查询;特征数据查询与创建时原生读回一致。 +原生选择使用 `ISelectData.X/Y/Z`,只传临时实体指针会留下默认区域。 +相互修剪重复预览后按实体名称重新解析原始曲面,避免原始 `IBody2.Select2` 卡住。 +本机相接曲面的相互修剪即使 `PostTrimSurface(false)` 仍生成一个合并曲面体; +工具检测数量并返回失败、保留已创建特征,此参数变体没有完成。 +旧的 `ModelDoc2.PostTrimSurface(false)` 探测生成了错误的保留面积,未采用该路径。 +形成实体、更多非平面组合、开放草图投影修剪与旧版本软件仍保留为缺口。 + +新增已有 SolidWorks 实例查询和明确选择 2 个工具。本机默认 COM 注册指向另一个 +建模调用异常的实例,按 PID 连接已有可用实例后完成了新建、建面和关闭测试。 +实例选择仅作用于 MCP 服务进程,不启动或关闭 SolidWorks,也不切换用户窗口。 +所选实例退出后不自动替换;只读可访问不等于建模健康。 + 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 diff --git a/README.md b/README.md index 7dcbf3d..b73564c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -164 tools: sketching with real relations and driving dimensions, the solid +169 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -196,6 +196,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `combine_bodies` | 多实体并集、差集与交集;差集的第一个实体为保留的主体。 | | `delete_bodies` | 创建删除/保留实体特征。 | | `mid_surface` / `get_mid_surface_data` | 原生自动中面与只读检查;读取面配对、厚度、面积、位置和缝合结果。非零位置未生效时返回失败。 | +| `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | @@ -207,6 +208,13 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` 索引取自 `list_bodies`,几何变化后需重新查询。曲面体索引与实体索引独立, 曲面选择使用 `surface_bodies`,索引取自 `list_surface_bodies`。 +修剪先调用 `preview_surface_trim`,再把返回的区域索引传给 `trim_surface`。 +标准修剪指定一个 `trim_selection`;相互修剪设置 `mode=mutual`,只指定目标曲面体。 +区域索引按目标顺序、包围盒和面积排序,几何变化后必须重新预览。 +复杂区域可用 `picked_points_mm` 指定实际落在区域上的选择点。 +相互修剪在本机即使 `knit=false` 仍会合并相接曲面;工具返回失败并保留特征信息。 +实体形成选项、更多复杂曲面组合和旧版软件尚待覆盖或实测。 + ### 钣金与焊件 | 工具 | 功能 | @@ -234,12 +242,16 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | 工具 | 功能 | | --- | --- | | `list_open_documents` / `activate_document` / `close_document` | 查询、切换和关闭已有文档;关闭有未保存修改的文档需显式传入 `discard_changes=true`。 | +| `list_solidworks_sessions` / `select_solidworks_session` | 查询已经运行的实例及其文档,按 `process_id` 明确选择后续 MCP 调用连接的实例。 | | `list_configurations` / `create_configuration` / `activate_configuration` / `delete_configuration` | 查询、创建、激活和删除配置;不能删除活动配置。 | | `list_custom_properties` / `get_custom_property` / `set_custom_property` / `delete_custom_property` | 查询和编辑文档级或配置级自定义属性,包括原始值、解析值与类型。 | | `list_equations` / `add_equation` / `set_equation` / `delete_equation` / `evaluate_equations` | 查询和编辑方程与全局变量,使用 SOLIDWORKS 方程语法,例如 `"Width" = 25mm`。 | | `list_materials` | 搜索实际配置的材质库,返回可传给 `set_material` 的准确名称与数据库路径。 | `configuration` 为空时操作文档级属性,非空时必须是已存在的配置名称。 +实例选择只作用于当前 MCP 服务进程;`process_id=0` 恢复默认 COM 注册。 +可通过 `SW_MCP_SESSION_PID` 指定启动时连接的已有实例;显式工具选择优先。 +实例退出后调用会失败,不会自动切换到另一个实例。查询可读不代表建模命令正常。 方程里的单位由 SOLIDWORKS 语法解释,应明确写出 `mm` 等单位;方程索引在删除后变化。 ### Engineering drawings diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index 02efbe6..2e7c5b0 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -484,12 +484,58 @@ def as_list(com_array: Any) -> list[Any]: # -------------------------------------------------------------------------- +_SESSION_PID: int | None = None + + +def selected_session_pid() -> int: + if _SESSION_PID is not None: + return _SESSION_PID + raw = os.environ.get("SW_MCP_SESSION_PID", "0") + try: + pid = int(raw) + except (TypeError, ValueError) as exc: + raise RuntimeError("SW_MCP_SESSION_PID must be a non-negative process ID.") from exc + if pid < 0: + raise RuntimeError("SW_MCP_SESSION_PID must be a non-negative process ID.") + return pid + + +def session_monikers() -> list[tuple[int, Any]]: + pythoncom.CoInitialize() + rot = pythoncom.GetRunningObjectTable() + context = pythoncom.CreateBindCtx(0) + sessions = [] + for moniker in rot: + try: + name = moniker.GetDisplayName(context, None) + except pythoncom.com_error: + continue + prefix = "SolidWorks_PID_" + if name.startswith(prefix) and name[len(prefix):].isdigit(): + sessions.append((int(name[len(prefix):]), moniker)) + return sorted(sessions, key=lambda item: item[0]) + + +def session_app(pid: int) -> Any: + pythoncom.CoInitialize() + if pid == 0: + return flag_methods(win32com.client.GetActiveObject("SldWorks.Application"), *_APP_METHODS) + for candidate, moniker in session_monikers(): + if candidate == pid: + dispatch = pythoncom.GetRunningObjectTable().GetObject(moniker).QueryInterface(pythoncom.IID_IDispatch) + return flag_methods(win32com.client.Dispatch(dispatch), *_APP_METHODS) + raise RuntimeError(f"SolidWorks session {pid} is no longer registered. List sessions and select an existing process ID.") + + def running_app() -> Any: """Attach only to an already-running SOLIDWORKS session; never start one.""" pythoncom.CoInitialize() + pid = selected_session_pid() try: - app = win32com.client.GetActiveObject("SldWorks.Application") + app = session_app(pid) except Exception as exc: + if pid: + raise RuntimeError(f"Cannot attach to selected SolidWorks session {pid}; no other session was substituted.") from exc raise RuntimeError( "No running SOLIDWORKS session is available. Open SOLIDWORKS and finish any modal dialogs first." ) from exc diff --git a/solidworks_mcp/sw_manage.py b/solidworks_mcp/sw_manage.py index 5ca5401..e5f35d6 100644 --- a/solidworks_mcp/sw_manage.py +++ b/solidworks_mcp/sw_manage.py @@ -20,6 +20,7 @@ import pythoncom import win32com.client +from . import sw_core from .sw_core import ( active_document, as_list, byref_long, document_info, document_type, @@ -32,6 +33,45 @@ PROPERTY_TYPES = {"text": 30, "number": 3, "date": 64, "yes_or_no": 11} +@tool("list_solidworks_sessions", "Read registered, already-running SolidWorks instances and their documents. Readability does not imply that modeling commands are healthy. Never starts an application.") +def list_solidworks_sessions(args): + configured = sw_core.selected_session_pid() + current = None + try: + current = int(value(flag_methods(running_app(), "GetProcessID"), "GetProcessID")) + except Exception: + pass + entries = [] + for pid, _ in sw_core.session_monikers(): + entry = {"process_id": pid, "selected": pid == current} + try: + app = sw_core.session_app(pid) + active = app.ActiveDoc + entry.update(readable=True, version=str(value(app, "RevisionNumber")), + documents=[document_info(doc) for doc in as_list(value(app, "GetDocuments"))], + active_title=str(value(active, "GetTitle")) if active is not None else None) + except Exception as exc: + entry.update(readable=False, error=str(exc)) + entries.append(entry) + return result(True, "Read registered SolidWorks sessions.", sessions=entries, + configured_process_id=configured, attached_process_id=current) + + +@tool("select_solidworks_session", "Route subsequent tools in this MCP server to an already-running SolidWorks process. process_id=0 restores the default COM registration. Does not activate windows, move documents, launch or terminate applications. If a selected process exits, calls fail rather than silently switching to another document session.", + {"process_id": {"type": "integer", "minimum": 0}}, ["process_id"]) +def select_solidworks_session(args): + pid = args["process_id"] + if isinstance(pid, bool) or not isinstance(pid, int) or pid < 0: + raise RuntimeError("process_id must be a non-negative integer.") + app = flag_methods(sw_core.session_app(pid), "GetProcessID") + attached = int(value(app, "GetProcessID")) + if pid and attached != pid: + raise RuntimeError("The registered SolidWorks session does not match the requested process ID.") + sw_core._SESSION_PID = pid + return result(True, "Selected SolidWorks connection for this MCP server.", + configured_process_id=pid, attached_process_id=attached) + + def _nonempty(raw: Any, label: str) -> str: name = str(raw).strip() if not name: diff --git a/solidworks_mcp/sw_multibody.py b/solidworks_mcp/sw_multibody.py index e310a21..e94f180 100644 --- a/solidworks_mcp/sw_multibody.py +++ b/solidworks_mcp/sw_multibody.py @@ -28,6 +28,162 @@ NAME = {"type": "string"} NUM = {"type": "number", "default": 0} +TRIM_PROPERTIES = { + "surface_body_indices": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True}, + "mode": {"type": "string", "enum": ["standard", "mutual"], "default": "standard"}, + "trim_selection": SELECTION_SCHEMA, + "split_system": {"type": "boolean", "default": True}, + "linear_extension": {"type": "boolean", "default": False}, + "remove_picked": {"type": "boolean", "default": False}, +} + + +def _trim_body_info(body): + faces = as_list(value(body, "GetFaces")) + point = next((point for face in faces if (point := _face_point(face)) is not None), None) + return {"area_mm2": sum(float(value(face, "GetArea")) for face in faces) * 1e6, + "face_count": len(faces), "box_mm": [to_mm(v) for v in value(body, "GetBodyBox")], + "selection_point_mm": mm_point(point) if point is not None else None} + + +def _prepare_surface_trim(doc, args): + indices = args["surface_body_indices"] + mode = args.get("mode", "standard") + if mode not in ("standard", "mutual"): + raise RuntimeError("mode must be standard or mutual.") + bodies = get_bodies(doc, BODY_SHEET) + if not indices or len(set(indices)) != len(indices) or any(isinstance(i, bool) or not isinstance(i, int) or i < 0 or i >= len(bodies) for i in indices): + raise RuntimeError("Specify distinct surface body indices from list_surface_bodies.") + selection = args.get("trim_selection", {}) + if mode == "mutual": + if len(indices) < 2 or selection: + raise RuntimeError("Mutual trimming needs at least two surface bodies and no separate trim_selection.") + clear_selection(doc) + ext = flag_methods(value(doc, "Extension"), "SelectByID2") + for index in indices: + # Re-resolve originals by name: Body2.Select2 can block after a mutual preview. + point = _trim_body_info(bodies[index])["selection_point_mm"] + if point is None or not ext.SelectByID2(str(value(bodies[index], "Name")), "SURFACEBODY", *[to_m(v) for v in point], True, 0, nothing(), 0): + raise RuntimeError("Cannot select an original surface for mutual trimming.") + else: + if not selection or set(selection) - {"planes", "sketches", "surface_bodies", "surface_faces"}: + raise RuntimeError("Standard trimming needs one plane, sketch, surface body or surface face as trim_selection.") + if set(selection.get("surface_bodies", [])) & set(indices): + raise RuntimeError("A trimming surface cannot also be a target surface.") + if require_selection(doc, selection) != 1: + raise RuntimeError("Standard trimming needs exactly one trimming tool.") + fm = flag_methods(feature_manager(doc), "PreTrimSurface", "GetPreTrimmedBodies", "PostTrimSurface") + if not fm.PreTrimSurface(mode == "mutual", bool(args.get("split_system", True)), + bool(args.get("linear_extension", False)), bool(args.get("remove_picked", False))): + raise RuntimeError("SolidWorks rejected the surface trimming setup.") + if safe(fm, "SolidForTrim") is not None: + fm.SolidForTrim = False + pieces = [] + for index in indices: + regions = as_list(fm.GetPreTrimmedBodies(bodies[index])) + if not regions: + raise RuntimeError(f"No trim regions were generated for surface body {index}.") + ordered = sorted(((body, _trim_body_info(body)) for body in regions), + key=lambda item: (item[1]["box_mm"], item[1]["area_mm2"])) + for body, info in ordered: + pieces.append((body, {**info, "index": len(pieces), "surface_body_index": index})) + return fm, bodies, pieces + + +@tool("preview_surface_trim", "Generate temporary trim regions without creating a feature. Standard mode needs one trim_selection; mutual mode uses only surface_body_indices. Region indices are sorted by target order and geometric bounds. Regenerate the preview after changing geometry. Distances are mm and areas mm2.", + TRIM_PROPERTIES, ["surface_body_indices"]) +def preview_surface_trim(args): + _, doc = require_part() + exit_active_sketch(doc) + try: + _, _, pieces = _prepare_surface_trim(doc, args) + return result(True, "Read temporary surface trimming regions.", regions=[info for _, info in pieces]) + finally: + clear_selection(doc) + + +def _surface_trim_info(doc, feature): + if feature is None or safe(feature, "GetTypeName2") != "TrimRefSurface": + raise RuntimeError("Specify an existing surface trim feature.") + definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections") + if not definition.AccessSelections(doc, nothing()): + raise RuntimeError("Cannot access surface trim feature selections.") + try: + return {"type": int(value(definition, "GetType")), + "trim_tool_count": int(value(definition, "GetTrimToolsCount")), + "pieces_to_keep_count": int(value(definition, "GetPiecesToKeepCount"))} + finally: + value(definition, "ReleaseSelectionAccess") + + +@tool("get_surface_trim_data", "Read native type, trimming tool count and kept-piece count of an existing surface trim feature.", + {"name": NAME}, ["name"]) +def get_surface_trim_data(args): + _, doc = require_part() + return result(True, "Read surface trim feature data.", trim=_surface_trim_info(doc, find_feature(doc, args["name"]))) + + +@tool("trim_surface", "Create a standard or mutual surface trim from preview region indices. remove_picked=false keeps selected regions; true removes them. Selection points are sent to native ISelectData, not only body pointers. Optional picked_points_mm overrides preview points in region_indices order. Rebuild, native definition and total remaining sheet area are verified; failed verification retains the created feature for inspection.", + {**TRIM_PROPERTIES, "region_indices": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True}, + "picked_points_mm": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}}, + "knit": {"type": "boolean", "default": False}, "name": NAME}, ["surface_body_indices", "region_indices"]) +def trim_surface(args): + selected = args["region_indices"] + if not selected or len(set(selected)) != len(selected) or any(isinstance(i, bool) or not isinstance(i, int) or i < 0 for i in selected): + raise RuntimeError("Specify distinct non-negative region indices from preview_surface_trim.") + points = args.get("picked_points_mm") + if points is not None and (len(points) != len(selected) or any(len(p) != 3 or not all(math.isfinite(float(v)) for v in p) for p in points)): + raise RuntimeError("picked_points_mm needs one finite XYZ point per selected region.") + _, doc = require_part() + exit_active_sketch(doc) + try: + fm, bodies, pieces = _prepare_surface_trim(doc, args) + if any(i >= len(pieces) for i in selected): + raise RuntimeError("A region index is out of range. Regenerate preview_surface_trim.") + selected_set = set(selected) + kept = [info for _, info in pieces if (info["index"] not in selected_set) == bool(args.get("remove_picked", False))] + if not kept: + raise RuntimeError("The trimming selection would remove every target region.") + sm = flag_methods(value(doc, "SelectionManager"), "CreateSelectData") + for order, index in enumerate(selected): + body, info = pieces[index] + point = points[order] if points is not None else info["selection_point_mm"] + if point is None: + raise RuntimeError("Cannot find a selection point for this region; provide picked_points_mm.") + point_m = [to_m(v) for v in point] + faces = as_list(value(body, "GetFaces")) + if not any(math.dist(point_m, flag_methods(face, "GetClosestPointOn").GetClosestPointOn(*point_m)[:3]) < 1e-8 for face in faces): + raise RuntimeError("The selected point does not lie on its requested trim region.") + data = value(sm, "CreateSelectData") + data.Mark = 0 + data.X, data.Y, data.Z = point_m + if not flag_methods(body, "Select2").Select2(True, data): + raise RuntimeError("Cannot select the requested temporary trim region.") + expected_regions = kept + [_trim_body_info(body) for i, body in enumerate(bodies) if i not in args["surface_body_indices"]] + expected_area = sum(info["area_mm2"] for info in expected_regions) + expected_box = [min(info["box_mm"][i] for info in expected_regions) if i < 3 else max(info["box_mm"][i] for info in expected_regions) for i in range(6)] + feature = fm.PostTrimSurface(bool(args.get("knit", False))) + payload = _finish(doc, feature, args, "surface trim", regions=[info for _, info in pieces], kept_region_indices=[info["index"] for info in kept]) + if payload["ok"]: + try: + info = _surface_trim_info(doc, feature) + actual_regions = [_trim_body_info(body) for body in get_bodies(doc, BODY_SHEET)] + actual_area = sum(region["area_mm2"] for region in actual_regions) + actual_box = [min(region["box_mm"][i] for region in actual_regions) if i < 3 else max(region["box_mm"][i] for region in actual_regions) for i in range(6)] if actual_regions else None + payload["data"].update(trim=info, expected_sheet_area_mm2=expected_area, actual_sheet_area_mm2=actual_area, + expected_sheet_box_mm=expected_box, actual_sheet_box_mm=actual_box, + sheet_body_count=len(actual_regions), knit_requested=bool(args.get("knit", False))) + if info["type"] != (1 if args.get("mode", "standard") == "mutual" else 0) or not math.isclose(actual_area, expected_area, rel_tol=1e-6, abs_tol=1e-5) or actual_box is None or not all(math.isclose(a, b, abs_tol=1e-5) for a, b in zip(actual_box, expected_box)): + payload.update(ok=False, message="Surface trim geometry or native type differs from the requested regions.") + minimum_separate = len({region["surface_body_index"] for region in kept}) + len(bodies) - len(args["surface_body_indices"]) + if not args.get("knit", False) and len(actual_regions) < minimum_separate: + payload.update(ok=False, message="SolidWorks joined distinct source sheets despite knit=false; the created feature is retained for inspection.") + except Exception as exc: + payload.update(ok=False, message=f"Created surface trim feature but could not verify its native result: {exc}") + return payload + finally: + clear_selection(doc) + def _positive(raw, label): number = float(raw) diff --git a/tests/live_surface_trim_regression.py b/tests/live_surface_trim_regression.py new file mode 100644 index 0000000..77fdae0 --- /dev/null +++ b/tests/live_surface_trim_regression.py @@ -0,0 +1,129 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Native surface trim geometry on newly-created documents only.""" +import math +from live_expansion_regression import call, near +from solidworks_mcp.sw_core import HANDLERS, selected_session_pid + + +def run(): + configured = selected_session_pid() + sessions = call("list_solidworks_sessions") + attached = sessions["attached_process_id"] + call("select_solidworks_session", process_id=attached) + assert call("list_solidworks_sessions")["attached_process_id"] == attached + original = call("list_open_documents")["active_title"] + scratch = [] + + def part(): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + + def plate(name="BaseSurface", y=0): + call("create_sketch", plane="front", name=f"{name}Profile") + call("draw_rectangle", x1_mm=0, y1_mm=y, x2_mm=10, y2_mm=y + 10) + call("close_sketch") + call("planar_surface", sketch_name=f"{name}Profile", name=name) + + def knife(): + call("create_plane", mode="offset", selection={"planes": ["right"]}, distance_mm=4, name="KnifePlane") + + def index(name): + return next(b["index"] for b in call("list_surface_bodies")["surface_bodies"] if b["name"] == name) + + def preview(args): + features = call("list_features") + bodies = call("list_surface_bodies") + regions = call("preview_surface_trim", **args)["regions"] + assert call("preview_surface_trim", **args)["regions"] == regions + assert call("list_features") == features + assert call("list_surface_bodies") == bodies + return regions + + def finish(args, selected, expected, **extra): + result = call("trim_surface", **args, region_indices=selected, name="VerifiedTrim", **extra) + near(result["actual_sheet_area_mm2"], expected, "trimmed sheet area") + assert result["actual_sheet_box_mm"] == result["expected_sheet_box_mm"] + before = call("list_features") + assert call("get_surface_trim_data", name="VerifiedTrim")["trim"] == result["trim"] + assert call("list_features") == before + return result + + try: + for region, remove, linear, split in ((0, False, False, True), (1, False, False, True), + (1, True, False, True), (0, False, True, False)): + part(); plate(); knife() + args = {"surface_body_indices": [index("BaseSurface")], "trim_selection": {"planes": ["KnifePlane"]}, + "remove_picked": remove, "linear_extension": linear, "split_system": split} + regions = preview(args) + assert [round(r["area_mm2"]) for r in regions] == [40, 60] + result = finish(args, [region], 40 if remove or region == 0 else 60) + assert result["trim"] == {"type": 0, "trim_tool_count": 1, "pieces_to_keep_count": 1} + near(result["actual_sheet_box_mm"][0], 4 if region == 1 and not remove else 0, "retained side") + + for tool_kind in ("surface_bodies", "surface_faces", "mutual_separate", "mutual_knit"): + part(); plate(); knife() + call("create_sketch", plane="KnifePlane", name="KnifeProfile") + call("draw_rectangle", x1_mm=-5, y1_mm=0, x2_mm=5, y2_mm=10) + call("close_sketch"); call("planar_surface", sketch_name="KnifeProfile", name="KnifeSurface") + base, cutter = index("BaseSurface"), index("KnifeSurface") + mutual = tool_kind.startswith("mutual") + args = {"surface_body_indices": [base, cutter] if mutual else [base], "mode": "mutual" if mutual else "standard"} + if not mutual: + if tool_kind == "surface_bodies": args["trim_selection"] = {tool_kind: [cutter]} + else: + faces = call("list_faces", body_type="surface")["faces"] + face_index = next(f["index"] for f in faces if abs(f["point_mm"][0] - 4) < 1e-6) + args["trim_selection"] = {tool_kind: [face_index]} + regions = preview(args) + selected = [r["index"] for r in regions if (r["surface_body_index"] == base and r["box_mm"][3] < 5) + or (mutual and r["surface_body_index"] == cutter and r["box_mm"][2] > -1)] + if tool_kind == "mutual_separate": + payload = HANDLERS["trim_surface"]({**args, "region_indices": selected, "knit": False, "name": "UnconfirmedSeparate"}) + assert not payload["ok"] and payload["data"]["feature"] == "UnconfirmedSeparate" + near(payload["data"]["actual_sheet_area_mm2"], 90, "mutual geometry retained") + assert payload["data"]["sheet_body_count"] == 1 + print("Native mutual trim joins the sheets despite knit=false; failure preserves feature evidence.", flush=True) + continue + result = finish(args, selected, 90 if mutual else 140, knit=tool_kind == "mutual_knit") + assert result["trim"]["type"] == int(mutual) + if mutual: + assert len(call("list_surface_bodies")["surface_bodies"]) == 1 + + for keep_inside in (True, False): + part(); plate() + call("create_sketch", plane="front", name="CircleTool") + call("draw_circle", x_mm=5, y_mm=5, radius_mm=2); call("close_sketch") + args = {"surface_body_indices": [index("BaseSurface")], "trim_selection": {"sketches": ["CircleTool"]}} + regions = preview(args) + selected = next(r["index"] for r in regions if (r["area_mm2"] < 50) == keep_inside) + finish(args, [selected], 4 * math.pi if keep_inside else 100 - 4 * math.pi, + picked_points_mm=[[5, 5, 0] if keep_inside else [1, 1, 0]]) + + part(); plate(); plate("OtherSurface", y=20); knife() + args = {"surface_body_indices": [index("BaseSurface"), index("OtherSurface")], "trim_selection": {"planes": ["KnifePlane"]}} + regions = preview(args) + finish(args, [r["index"] for r in regions if r["box_mm"][3] < 5], 80) + + part() + call("create_sketch", plane="front", name="CylinderProfile") + call("draw_line", x1_mm=5, y1_mm=0, x2_mm=5, y2_mm=10) + call("draw_centerline", x1_mm=0, y1_mm=0, x2_mm=0, y2_mm=10); call("close_sketch") + call("surface_revolve", sketch_name="CylinderProfile", name="Cylinder") + call("create_plane", mode="offset", selection={"planes": ["top"]}, distance_mm=4, name="CutHeight") + args = {"surface_body_indices": [0], "trim_selection": {"planes": ["CutHeight"]}} + regions = preview(args) + finish(args, [min(regions, key=lambda r: r["area_mm2"])["index"]], 2 * math.pi * 5 * 4) + print("Verified plane/sketch/surface/face tools, both retained sides, removal, mutual trim/knit, multiple targets and cylinder area on 12 scratch parts; knit=false remains a native gap.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original in opened: call("activate_document", name=original) + call("select_solidworks_session", process_id=configured) + + +if __name__ == "__main__": + run() diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 0000000..1c14978 --- /dev/null +++ b/tests/test_sessions.py @@ -0,0 +1,65 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +import os +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch +from solidworks_mcp import sw_core as core, sw_manage as manage + + +class SessionContracts(unittest.TestCase): + def test_rot_enumeration_filters_and_sorts_instance_monikers(self): + names = ["SolidWorks_PID_42", "unrelated", "SolidWorks_PID_invalid", "SolidWorks_PID_7"] + monikers = [SimpleNamespace(GetDisplayName=lambda *args, name=name: name) for name in names] + with patch.object(core.pythoncom, "GetRunningObjectTable", return_value=monikers): + self.assertEqual([pid for pid, _ in core.session_monikers()], [7, 42]) + + def test_missing_selected_instance_never_falls_back_to_default(self): + with patch.object(core, "_SESSION_PID", 7), patch.object(core, "session_monikers", return_value=[]), \ + patch.object(core.win32com.client, "GetActiveObject") as default: + with self.assertRaisesRegex(RuntimeError, "no other session"): + core.running_app() + default.assert_not_called() + + def test_environment_and_explicit_default_precedence(self): + with patch.dict(os.environ, {"SW_MCP_SESSION_PID": "42"}), patch.object(core, "_SESSION_PID", None): + self.assertEqual(core.selected_session_pid(), 42) + core._SESSION_PID = 0 + self.assertEqual(core.selected_session_pid(), 0) + for raw in ("-1", "nan", "7.5"): + with patch.dict(os.environ, {"SW_MCP_SESSION_PID": raw}), patch.object(core, "_SESSION_PID", None): + with self.assertRaises(RuntimeError): + core.selected_session_pid() + + def test_selection_failure_preserves_previous_route(self): + with patch.object(core, "_SESSION_PID", 7), patch.object(core, "session_app", side_effect=RuntimeError("unavailable")): + with self.assertRaises(RuntimeError): + manage.select_solidworks_session({"process_id": 42}) + self.assertEqual(core._SESSION_PID, 7) + with patch.object(core, "_SESSION_PID", 7), patch.object(core, "session_app", return_value=SimpleNamespace(GetProcessID=lambda: 99)): + with self.assertRaisesRegex(RuntimeError, "does not match"): + manage.select_solidworks_session({"process_id": 42}) + self.assertEqual(core._SESSION_PID, 7) + + def test_default_route_and_invalid_pid(self): + with patch.object(core, "_SESSION_PID", 7), patch.object(core, "session_app", return_value=SimpleNamespace(GetProcessID=lambda: 42)): + self.assertEqual(manage.select_solidworks_session({"process_id": 0})["data"]["attached_process_id"], 42) + self.assertEqual(core._SESSION_PID, 0) + for pid in (-1, True, 1.5, "42"): + with patch.object(core, "session_app") as bind: + with self.assertRaises(RuntimeError): + manage.select_solidworks_session({"process_id": pid}) + bind.assert_not_called() + + def test_unreadable_session_remains_visible(self): + with patch.object(core, "selected_session_pid", return_value=7), \ + patch.object(manage, "running_app", side_effect=RuntimeError("unavailable")), \ + patch.object(core, "session_monikers", return_value=[(7, None)]), \ + patch.object(core, "session_app", side_effect=RuntimeError("native error")): + data = manage.list_solidworks_sessions({})["data"] + self.assertIsNone(data["attached_process_id"]) + self.assertFalse(data["sessions"][0]["readable"]) + self.assertEqual(data["sessions"][0]["process_id"], 7) diff --git a/tests/test_surface_trim.py b/tests/test_surface_trim.py new file mode 100644 index 0000000..3e7edf3 --- /dev/null +++ b/tests/test_surface_trim.py @@ -0,0 +1,119 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +import unittest +from solidworks_mcp import sw_multibody as surfaces + + +LEFT = {"index": 0, "surface_body_index": 0, "area_mm2": 40., "box_mm": [0, 0, 0, 4, 10, 0], "selection_point_mm": [2, 5, 0]} +RIGHT = {"index": 1, "surface_body_index": 0, "area_mm2": 60., "box_mm": [4, 0, 0, 10, 10, 0], "selection_point_mm": [7, 5, 0]} + + +class SurfaceTrimContracts(unittest.TestCase): + def test_invalid_region_indices_and_points_fail_before_com(self): + for regions in ([], [True], [-1], [0, 0], [1.5]): + with patch.object(surfaces, "require_part") as part: + with self.assertRaises(RuntimeError): + surfaces.trim_surface({"region_indices": regions}) + part.assert_not_called() + for points in ([], [[1, 2]], [[float("nan"), 0, 0]]): + with patch.object(surfaces, "require_part") as part: + with self.assertRaises(RuntimeError): + surfaces.trim_surface({"region_indices": [0], "picked_points_mm": points}) + part.assert_not_called() + + def test_preselection_precedes_native_setup_and_regions_have_stable_indices(self): + events = [] + fm = SimpleNamespace(PreTrimSurface=lambda *args: events.append(("pre", args)) or True, + GetPreTrimmedBodies=lambda body: ["right", "left"]) + with patch.object(surfaces, "get_bodies", return_value=["source"]), \ + patch.object(surfaces, "require_selection", side_effect=lambda *a: events.append("select") or 1), \ + patch.object(surfaces, "feature_manager", return_value=fm), \ + patch.object(surfaces, "_trim_body_info", side_effect=lambda body: RIGHT if body == "right" else LEFT): + _, _, pieces = surfaces._prepare_surface_trim(None, {"surface_body_indices": [0], "trim_selection": {"planes": ["Cutter"]}, "linear_extension": True, "remove_picked": True, "split_system": False}) + self.assertEqual(events, ["select", ("pre", (False, False, True, True))]) + self.assertEqual([body for body, _ in pieces], ["left", "right"]) + self.assertEqual([info["index"] for _, info in pieces], [0, 1]) + + def test_invalid_targets_and_mutual_contract_never_prepare_trim(self): + cases = [{"surface_body_indices": [True]}, {"surface_body_indices": [2]}, + {"surface_body_indices": [0], "mode": "mutual"}, + {"surface_body_indices": [0, 1], "mode": "mutual", "trim_selection": {"planes": ["Cutter"]}}, + {"surface_body_indices": [0], "trim_selection": {"surface_bodies": [0]}}] + for args in cases: + with patch.object(surfaces, "get_bodies", return_value=["a", "b"]), patch.object(surfaces, "feature_manager") as manager: + with self.assertRaises(RuntimeError): + surfaces._prepare_surface_trim(None, args) + manager.assert_not_called() + + def test_preview_clears_selection_even_if_native_generation_fails(self): + with patch.object(surfaces, "require_part", return_value=(None, "doc")), patch.object(surfaces, "exit_active_sketch"), \ + patch.object(surfaces, "_prepare_surface_trim", side_effect=RuntimeError("native error")), \ + patch.object(surfaces, "clear_selection") as clear: + with self.assertRaisesRegex(RuntimeError, "native error"): + surfaces.preview_surface_trim({}) + clear.assert_called_once_with("doc") + + def test_native_selection_point_and_geometry_verification(self): + for remove in (False, True): + for wrong_side in (False, True): + with self.subTest(remove=remove, wrong_side=wrong_side), ExitStack() as stack: + data = SimpleNamespace() + select = Mock(return_value=True) + face = SimpleNamespace(GetClosestPointOn=lambda *p: p) + body = SimpleNamespace(Select2=select, GetFaces=lambda: [face]) + fm = SimpleNamespace(PostTrimSurface=Mock(return_value="feature")) + doc = SimpleNamespace(SelectionManager=SimpleNamespace(CreateSelectData=lambda: data)) + expected = RIGHT if remove else LEFT + actual = {**expected, "box_mm": [100, 0, 0, 104, 10, 0]} if wrong_side else expected + replacements = {"require_part": (None, doc), "_prepare_surface_trim": (fm, ["source"], [(body, LEFT), (body, RIGHT)]), + "get_bodies": ["remaining"], "_trim_body_info": actual, + "_surface_trim_info": {"type": 0, "pieces_to_keep_count": 1, "trim_tool_count": 1}} + for name, returned in replacements.items(): + stack.enter_context(patch.object(surfaces, name, return_value=returned)) + stack.enter_context(patch.object(surfaces, "_finish", side_effect=lambda *a, **kw: {"ok": True, "data": {"feature": "Trim", **kw}})) + stack.enter_context(patch.object(surfaces, "exit_active_sketch")) + clear = stack.enter_context(patch.object(surfaces, "clear_selection")) + result = surfaces.trim_surface({"surface_body_indices": [0], "region_indices": [0], "remove_picked": remove}) + self.assertEqual(result["ok"], not wrong_side) + self.assertEqual(result["data"]["feature"], "Trim") + self.assertEqual((data.X, data.Y, data.Z, data.Mark), (.002, .005, 0., 0)) + select.assert_called_once_with(True, data) + clear.assert_called() + + def test_native_definition_access_is_released_when_read_fails(self): + release = Mock() + definition = SimpleNamespace(AccessSelections=lambda *a: True, GetType=lambda: (_ for _ in ()).throw(RuntimeError("native error")), ReleaseSelectionAccess=lambda: release()) + feature = SimpleNamespace(GetTypeName2=lambda: "TrimRefSurface", GetDefinition=lambda: definition) + with self.assertRaisesRegex(RuntimeError, "native error"): + surfaces._surface_trim_info(None, feature) + release.assert_called_once() + + def test_unrequested_knitting_cannot_be_reported_as_success(self): + for knit in (False, True): + with self.subTest(knit=knit), ExitStack() as stack: + data = SimpleNamespace() + face = SimpleNamespace(GetClosestPointOn=lambda *p: p) + body = SimpleNamespace(Select2=lambda *a: True, GetFaces=lambda: [face]) + fm = SimpleNamespace(PostTrimSurface=lambda *a: "feature") + doc = SimpleNamespace(SelectionManager=SimpleNamespace(CreateSelectData=lambda: data)) + right = {**RIGHT, "surface_body_index": 1} + merged = {"area_mm2": 100., "box_mm": [0, 0, 0, 10, 10, 0]} + values = {"require_part": (None, doc), "_prepare_surface_trim": (fm, ["a", "b"], [(body, LEFT), (body, right)]), + "get_bodies": ["merged"], "_trim_body_info": merged, + "_surface_trim_info": {"type": 1, "pieces_to_keep_count": 2, "trim_tool_count": 2}} + for name, returned in values.items(): + stack.enter_context(patch.object(surfaces, name, return_value=returned)) + stack.enter_context(patch.object(surfaces, "_finish", side_effect=lambda *a, **kw: {"ok": True, "data": {"feature": "Trim", **kw}})) + stack.enter_context(patch.object(surfaces, "exit_active_sketch")) + stack.enter_context(patch.object(surfaces, "clear_selection")) + payload = surfaces.trim_surface({"surface_body_indices": [0, 1], "region_indices": [0, 1], "mode": "mutual", "knit": knit}) + self.assertEqual(payload["ok"], knit) + self.assertEqual(payload["data"]["feature"], "Trim") + if not knit: + self.assertIn("joined distinct", payload["message"]) diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index fb358c1..594bbcb 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data", "preview_surface_trim", "trim_surface", "get_surface_trim_data"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index 39a0599..b44733e 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -41,6 +41,13 @@ async def call(name, arguments=None): if not payload["ok"]: raise RuntimeError(f"Installed MCP call failed: {name}: {payload}") return payload.get("data", {}) + connections = await call("list_solidworks_sessions") + attached = connections["attached_process_id"] + if attached is None: + raise RuntimeError("Cannot identify the installed MCP SolidWorks connection.") + selected = await call("select_solidworks_session", {"process_id": attached}) + if selected["attached_process_id"] != attached: + raise RuntimeError("Installed MCP instance selection differs.") initial = await call("list_open_documents") original = initial["active_title"] title = None @@ -146,6 +153,25 @@ async def call(name, arguments=None): raise RuntimeError("Installed MCP midsurface placement differs.") if (await call("get_mid_surface_data", {"name": "InstalledMid"}))["midsurface"] != middle: raise RuntimeError("Installed MCP read-only midsurface inspection differs.") + await call("create_sketch", {"plane": "front", "name": "InstalledTrimProfile"}) + await call("draw_rectangle", {"x1_mm": 400, "y1_mm": 400, "x2_mm": 410, "y2_mm": 410}) + await call("close_sketch") + await call("planar_surface", {"sketch_name": "InstalledTrimProfile", "name": "InstalledTrimSource"}) + bodies = (await call("list_surface_bodies"))["surface_bodies"] + source = next(body["index"] for body in bodies if body["name"] == "InstalledTrimSource") + await call("create_plane", {"mode": "offset", "selection": {"planes": ["right"]}, "distance_mm": 404, "name": "InstalledKnife"}) + trim_args = {"surface_body_indices": [source], "trim_selection": {"planes": ["InstalledKnife"]}} + before = await call("list_features") + regions = (await call("preview_surface_trim", trim_args))["regions"] + if before != await call("list_features") or [round(r["area_mm2"]) for r in regions] != [40, 60]: + raise RuntimeError("Installed MCP trim preview changed features or returned wrong regions.") + trimmed = await call("trim_surface", {**trim_args, "region_indices": [0], "name": "InstalledTrim"}) + bodies = (await call("list_surface_bodies"))["surface_bodies"] + actual = next(body for body in bodies if body["name"] == "InstalledTrim") + if not math.isclose(actual["area_mm2"], 40, abs_tol=1e-7): + raise RuntimeError("Installed MCP trim retained the wrong sheet area.") + if (await call("get_surface_trim_data", {"name": "InstalledTrim"}))["trim"] != trimmed["trim"]: + raise RuntimeError("Installed MCP trim definition readback differs.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From 1ea7712385af4df14e6d0ebb4bb38609c9d239e7 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:12:31 +0800 Subject: [PATCH 15/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E6=9B=B2=E9=9D=A2=E5=8F=8A=E5=8E=9F=E7=94=9F=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=B9=B6=E6=A0=B8=E9=AA=8C=E5=87=A0=E4=BD=95?= =?UTF-8?q?=E8=BF=9E=E7=BB=AD=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 16 +- README.md | 10 +- solidworks_mcp/server.py | 1 + solidworks_mcp/sw_boundary.py | 247 ++++++++++++++++++++++++++++++ tests/live_boundary_regression.py | 79 ++++++++++ tests/test_boundary.py | 109 +++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 13 ++ 8 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 solidworks_mcp/sw_boundary.py create mode 100644 tests/live_boundary_regression.py create mode 100644 tests/test_boundary.py diff --git a/COVERAGE.md b/COVERAGE.md index ec3e8da..0c5bb33 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 169 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 171 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 @@ -130,6 +130,20 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 +新增边界曲面创建及原生定义查询 2 个工具。12 个临时零件验证单/双方向、 +三个轮廓、按两个方向分别修剪及同时修剪、圆草图/模型边轮廓、法向条件和形成实体。 +双方向完整延伸面积 400 mm²,单向修剪面积 200 mm²,双向修剪面积 100 mm²; +半径 5 mm、高 10 mm 圆柱的形成实体体积为 785.398163 mm³。 +曲线选择标记为 `8192*(顺序+1)+方向+1`,需先选完全部曲线再设置条件。 +本机创建接口返回空时,从新增特征树恢复 `NetBlend` 后核验实际曲线、控制和实体类型。 + +输入圆端面的相切/曲率请求虽分别读回类型 3/4,但输出仍为圆柱侧面, +边界法向与输入平面法向差 90°,未达到 G1,更不能声称 G2。工具用输入边界采样、 +输出法向与第二基本形式核验连续性;不符时保留特征并返回未确认状态。 +第二基本形式在共同的世界坐标切向基底比较,曲率单位为 1/mm;不同参数化不能直接比较导数值。 +控制柄修改与方向向量、中心线、更多开放轮廓法向条件、闭合方向和复杂曲线组合仍待覆盖或专项验证。 +探索 `ModifyDefinition` 曾改变输出实体类型,未用它校正曲面控制。旧创建方法仅离线调度验证,旧软件未实测。 + ## 原生 API 清点 ```powershell diff --git a/README.md b/README.md index b73564c..b63f96f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -169 tools: sketching with real relations and driving dimensions, the solid +171 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -197,6 +197,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `delete_bodies` | 创建删除/保留实体特征。 | | `mid_surface` / `get_mid_surface_data` | 原生自动中面与只读检查;读取面配对、厚度、面积、位置和缝合结果。非零位置未生效时返回失败。 | | `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | +| `boundary_surface` / `get_boundary_feature_data` | 单/双方向有序曲线的边界曲面、按方向修剪、原生形成实体选项及只读检查;相切/曲率控制按边界采样核验几何,未确认时保留特征并返回失败。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | @@ -215,6 +216,13 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` 相互修剪在本机即使 `knit=false` 仍会合并相接曲面;工具返回失败并保留特征信息。 实体形成选项、更多复杂曲面组合和旧版软件尚待覆盖或实测。 +边界曲面的 `direction1`、`direction2` 是有序曲线列表,每项包含一个 `selection`。 +曲线需先全部选择,再设置条件;创建后读取实际曲线数量、影响范围、相切类型和实体类型。 +`create_solid=true` 使用原生 `InsertNetBlend2` 的形成实体选项,旧接口不支持该选项。 +相切/曲率连续性检查使用输入面边界的几何采样和输出曲面的法向、第二基本形式, +参数读回正确而几何不符时仍返回失败。本机圆端面案例的法向差为 90°,该细项尚未完成。 +方向向量、中心线、控制柄编辑和更多复杂边界条件仍待覆盖。 + ### 钣金与焊件 | 工具 | 功能 | diff --git a/solidworks_mcp/server.py b/solidworks_mcp/server.py index 3f2a44c..e0825e5 100644 --- a/solidworks_mcp/server.py +++ b/solidworks_mcp/server.py @@ -61,6 +61,7 @@ from . import sw_demo # noqa: F401 from . import sw_manage # noqa: F401 from . import sw_multibody # noqa: F401 +from . import sw_boundary # noqa: F401 from . import sw_sheetmetal # noqa: F401 from . import sw_weldment # noqa: F401 from . import sw_motion # noqa: F401 diff --git a/solidworks_mcp/sw_boundary.py b/solidworks_mcp/sw_boundary.py new file mode 100644 index 0000000..e0dc93c --- /dev/null +++ b/solidworks_mcp/sw_boundary.py @@ -0,0 +1,247 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Native boundary surfaces, ordered curve groups and definition inspection.""" +import math +from .sw_core import ( + SELECTION_SCHEMA, as_list, clear_selection, exit_active_sketch, feature_manager, + find_feature, flag_methods, nothing, require_part, require_selection, + result, safe, tool, value, call_versioned, BODY_SHEET, BODY_SOLID, + get_bodies, iter_face_objects, +) +from .sw_feature import _feature_names, _feature_created_after +from .sw_multibody import _finish, _trim_body_info + +TANGENCIES = {"none": 0, "normal_to_profile": 1, "tangent_to_face": 3, "curvature_to_face": 4, "default": 6} +INFLUENCES = {"to_next_curve": 0, "to_next_sharp": 16, "global": 32, "to_edge": 64, "linear": 144} +CURVE = {"type": "object", "properties": { + "selection": SELECTION_SCHEMA, + "tangency": {"type": "string", "enum": list(TANGENCIES), "default": "none"}, +}, "required": ["selection"], "additionalProperties": False} +CURVES = {"type": "array", "items": CURVE} +INFLUENCE = {"type": "string", "enum": list(INFLUENCES), "default": "global"} + + +def _dot(a, b): + return sum(x * y for x, y in zip(a, b)) + + +def _frame(surface, point, curvature=True): + surface = flag_methods(surface, "GetClosestPointOn", "Evaluate") + closest = surface.GetClosestPointOn(*point) + order = 2 if curvature else 1 + values = surface.Evaluate(closest[3], closest[4], order, order) + if curvature: + du, duu, dv, duv, dvv = [list(values[i:i + 3]) for i in (3, 6, 9, 12, 18)] + else: + du, dv = list(values[3:6]), list(values[6:9]) + duu = duv = dvv = [0., 0., 0.] + cross = [du[1] * dv[2] - du[2] * dv[1], du[2] * dv[0] - du[0] * dv[2], du[0] * dv[1] - du[1] * dv[0]] + length = math.sqrt(_dot(cross, cross)) + if length <= 1e-20 or not all(math.isfinite(v) for v in values): + raise RuntimeError("Cannot evaluate a regular surface frame for continuity verification.") + normal = [v / length for v in cross] + return {"du": du, "dv": dv, "normal": normal, + "e": _dot(normal, duu), "f": _dot(normal, duv), "g": _dot(normal, dvv)} + + +def _curvature(frame, a, b, reference_normal): + du, dv = frame["du"], frame["dv"] + e, f, g = _dot(du, du), _dot(du, dv), _dot(dv, dv) + det = e * g - f * f + if det <= 1e-12 * e * g: + raise RuntimeError("Degenerate parameterization in continuity verification.") + def coefficients(t): + u, v = _dot(du, t), _dot(dv, t) + return ((g * u - f * v) / det, (e * v - f * u) / det) + u1, v1 = coefficients(a) + u2, v2 = coefficients(b) + sign = 1 if _dot(frame["normal"], reference_normal) >= 0 else -1 + return sign * (u1 * u2 * frame["e"] + (u1 * v2 + v1 * u2) * frame["f"] + v1 * v2 * frame["g"]) / 1000 + + +def _continuity_samples(doc, curve): + if curve.get("tangency", "none") not in ("tangent_to_face", "curvature_to_face"): + return [] + selection = curve["selection"] + faces = [] + for key, kind in (("faces", BODY_SOLID), ("surface_faces", BODY_SHEET)): + entries = iter_face_objects(doc, kind) if selection.get(key) else [] + faces.extend(entries[i][0] for i in selection.get(key, [])) + for index in selection.get("surface_bodies", []): + faces.extend(as_list(value(get_bodies(doc, BODY_SHEET)[index], "GetFaces"))) + samples = [] + for face in faces: + surface = value(face, "GetSurface") + for edge in as_list(value(face, "GetEdges")): + params = value(edge, "GetCurveParams3") + edge_curve = flag_methods(value(edge, "GetCurve"), "Evaluate") + for fraction in (.25, .5, .75): + point = list(edge_curve.Evaluate(float(params.UMinValue) + fraction * (float(params.UMaxValue) - float(params.UMinValue)))[:3]) + samples.append({"point": point, "frame": _frame(surface, point, curve["tangency"] == "curvature_to_face")}) + return samples + + +def _verify_continuity(feature, references, require_curvature): + if not references: + return {"confirmed": False, "reason": "No explicit supporting face geometry was available."} + faces = [flag_methods(face, "GetClosestPointOn") for face in as_list(value(feature, "GetFaces"))] + maximum_angle, maximum_curvature, maximum_distance = 0., 0., 0. + for reference in references: + point = reference["point"] + if not faces: + return {"confirmed": False, "reason": "Created feature has no faces."} + face, closest = min(((face, face.GetClosestPointOn(*point)) for face in faces), key=lambda item: math.dist(point, item[1][:3])) + maximum_distance = max(maximum_distance, math.dist(point, closest[:3]) * 1000) + target = _frame(value(face, "GetSurface"), closest[:3], require_curvature) + source = reference["frame"] + angle = math.degrees(math.acos(min(1., abs(_dot(source["normal"], target["normal"]))))) + maximum_angle = max(maximum_angle, angle) + if require_curvature and angle <= .1: + a = [v / math.sqrt(_dot(source["du"], source["du"])) for v in source["du"]] + n = source["normal"] + b = [n[1] * a[2] - n[2] * a[1], n[2] * a[0] - n[0] * a[2], n[0] * a[1] - n[1] * a[0]] + for t1, t2 in ((a, a), (a, b), (b, b)): + maximum_curvature = max(maximum_curvature, abs(_curvature(source, t1, t2, n) - _curvature(target, t1, t2, n))) + return {"confirmed": maximum_distance <= .01 and maximum_angle <= .1 and (not require_curvature or maximum_curvature <= 1e-4), + "sample_count": len(references), "maximum_gap_mm": maximum_distance, "maximum_normal_angle_deg": maximum_angle, + "maximum_curvature_difference_per_mm": maximum_curvature if require_curvature and maximum_angle <= .1 else None} + + +def _boundary_arguments(args): + groups = [args["direction1"], args.get("direction2", [])] + if not groups[0] or sum(len(group) for group in groups) < 2: + raise RuntimeError("A boundary needs direction1 and at least two curves in total.") + for group in groups: + for curve in group: + selection = curve.get("selection", {}) + if not selection or set(selection) - {"sketches", "sketch_segments", "sketch_name", "edges", "surface_edges", "faces", "surface_faces", "surface_bodies"}: + raise RuntimeError("Each boundary curve must select one sketch, segment, model edge, face or surface body.") + if curve.get("tangency", "none") not in TANGENCIES: + raise RuntimeError("Invalid boundary curve tangency.") + influences = [args.get(f"direction{d + 1}_influence", "global") for d in range(2)] + if any(influence not in INFLUENCES for influence in influences): + raise RuntimeError("Invalid boundary direction influence.") + return groups, influences + + +def _boundary_info(doc, feature): + if feature is None or safe(feature, "GetTypeName2") != "NetBlend": + raise RuntimeError("Specify an existing native boundary feature.") + data = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "GetCurvesCount", + "GetGuideTangencyType", "GetDraftAngle", "GetTangentLength") + if not data.AccessSelections(doc, nothing()): + raise RuntimeError("Cannot access boundary feature selections.") + try: + directions = [] + for d in range(2): + count = int(data.GetCurvesCount(d)) + refs = as_list(value(data, f"D{d + 1}Curves")) + if count != len(refs): + raise RuntimeError("Native boundary curve count and references are inconsistent.") + curves = [] + for i in range(count): + tangency = int(data.GetGuideTangencyType(d, i)) + curve = {"index": i, "tangency": tangency} + if tangency not in (0, 6): + curve.update(draft_angle_deg=math.degrees(float(data.GetDraftAngle(d, i))), + tangent_handle_value=float(data.GetTangentLength(d, i))) + curves.append(curve) + directions.append({"curve_count": count, "influence": int(value(data, f"D{d + 1}CurveInfluence")), "curves": curves}) + info = {"directions": directions, "trim_direction1": bool(value(data, "TrimByD1")), + "merge_result": bool(value(data, "MergeResult")), + "merge_tangent_faces": bool(value(data, "MergeTangentFaces"))} + finally: + value(data, "ReleaseSelectionAccess") + faces = as_list(value(feature, "GetFaces")) + bodies = {} + for face in faces: + body = value(face, "GetBody") + label = str(value(body, "Name")) + if label not in bodies: + kind = int(value(body, "GetType")) + geometry = {**_trim_body_info(body), "name": label, "body_type": kind} + if kind == 0: + geometry["volume_mm3"] = float(flag_methods(body, "GetMassProperties").GetMassProperties(1.)[3]) * 1e9 + bodies[label] = geometry + info.update(feature_face_count=len(faces), feature_face_area_mm2=sum(float(value(face, "GetArea")) for face in faces) * 1e6, + bodies=list(bodies.values())) + return info + + +@tool("get_boundary_feature_data", "Read native boundary-feature curve groups, influence values, tangencies, trim and merge state, owned faces and affected bodies. Area is mm2, volume mm3. tangent_handle_value is the native control value, not a model distance.", + {"name": {"type": "string", "minLength": 1}}, ["name"]) +def get_boundary_feature_data(args): + _, doc = require_part() + return result(True, "Read boundary feature definition and geometry.", boundary=_boundary_info(doc, find_feature(doc, args["name"]))) + + +@tool("boundary_surface", "Create a native boundary surface from ordered curves in one or two directions. Each curve selects exactly one entity. Selection marks include both direction and curve order. create_solid=true requests the native solid-forming option (requires InsertNetBlend2). Native curve counts, influence, tangency, trim/merge state and actual body types are checked. Centerlines, direction vectors and arbitrary per-curve handle edits are not implemented yet.", + {"direction1": {**CURVES, "minItems": 1}, "direction2": CURVES, + "direction1_influence": INFLUENCE, "direction2_influence": INFLUENCE, + "trim_direction1": {"type": "boolean", "default": False}, "trim_direction2": {"type": "boolean", "default": False}, + "close_direction1": {"type": "boolean", "default": False}, "close_direction2": {"type": "boolean", "default": False}, + "merge_result": {"type": "boolean", "default": False}, "create_solid": {"type": "boolean", "default": False}, + "force_non_rational": {"type": "boolean", "default": True}, "name": {"type": "string"}}, ["direction1"]) +def boundary_surface(args): + groups, influences = _boundary_arguments(args) + _, doc = require_part() + exit_active_sketch(doc) + clear_selection(doc) + fm = flag_methods(feature_manager(doc), "SetNetBlendCurveData", "SetNetBlendDirectionData", "InsertNetBlend2", "InsertNetBlend") + create_solid = bool(args.get("create_solid", False)) + if create_solid and not hasattr(fm, "InsertNetBlend2"): + raise RuntimeError("Solid-forming boundary surfaces require InsertNetBlend2; the older method cannot preserve this option.") + before = _feature_names(doc) + try: + continuity = {(d, i): _continuity_samples(doc, curve) for d, group in enumerate(groups) for i, curve in enumerate(group) + if curve.get("tangency", "none") in ("tangent_to_face", "curvature_to_face")} + # Complete both selection groups before configuring any curve conditions. + for d, group in enumerate(groups): + for i, curve in enumerate(group): + if require_selection(doc, curve["selection"], mark=8192 * (i + 1) + d + 1, append=True) != 1: + raise RuntimeError("Each boundary curve must resolve to exactly one entity.") + for d, group in enumerate(groups): + for i, curve in enumerate(group): + fm.SetNetBlendCurveData(d, i, TANGENCIES[curve.get("tangency", "none")], 0., 1., True) + fm.SetNetBlendDirectionData(d, INFLUENCES[influences[d]], int(bool(args.get(f"trim_direction{d + 1}", False))), + bool(args.get(f"close_direction{d + 1}", False)), False) + arguments = (2, len(groups[0]), len(groups[1]), False, .0001, False, bool(args.get("merge_result", False)), + False, True, False, -1., -1., False, -1, False, False, -1., False, -1., bool(args.get("force_non_rational", True))) + feature = call_versioned(fm, ("InsertNetBlend2", (*arguments, create_solid)), ("InsertNetBlend", arguments)) + if feature is None: + feature = _feature_created_after(doc, before) + payload = _finish(doc, feature, args, "boundary surface") + if payload["ok"]: + try: + info = _boundary_info(doc, feature) + payload["data"]["boundary"] = info + issues = [] + for d, group in enumerate(groups): + actual = info["directions"][d] + if actual["curve_count"] != len(group) or actual["influence"] != INFLUENCES[influences[d]]: + issues.append(f"direction {d + 1} curves or influence") + for i, curve in enumerate(group): + requested = TANGENCIES[curve.get("tangency", "none")] + if i >= len(actual["curves"]) or (requested != 6 and actual["curves"][i]["tangency"] != requested): + issues.append(f"direction {d + 1} curve {i} tangency") + if (d, i) in continuity: + check = _verify_continuity(feature, continuity[d, i], curve["tangency"] == "curvature_to_face") + if i < len(actual["curves"]): + actual["curves"][i]["continuity"] = check + if not check["confirmed"]: + issues.append(f"direction {d + 1} curve {i} geometric continuity unconfirmed") + if info["trim_direction1"] != bool(args.get("trim_direction1", False)) or info["merge_result"] != bool(args.get("merge_result", False)): + issues.append("trim or merge state") + expected_type = 0 if create_solid else 1 + if not info["bodies"] or any(body["body_type"] != expected_type for body in info["bodies"]): + issues.append("resulting body type") + if issues: + payload.update(ok=False, message="Boundary feature differs from requested settings: " + ", ".join(issues)) + except Exception as exc: + payload.update(ok=False, message=f"Created boundary feature but could not verify its native result: {exc}") + return payload + finally: + clear_selection(doc) diff --git a/tests/live_boundary_regression.py b/tests/live_boundary_regression.py new file mode 100644 index 0000000..f313343 --- /dev/null +++ b/tests/live_boundary_regression.py @@ -0,0 +1,79 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Boundary surfaces and solid-forming options on scratch parts.""" +import math +from live_expansion_regression import call, near +from solidworks_mcp.sw_core import HANDLERS + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + def part(): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + def line(name, points): + call("create_sketch", plane="front", name=name) + call("draw_line", x1_mm=points[0], y1_mm=points[1], x2_mm=points[2], y2_mm=points[3]); call("close_sketch") + def curves(names): + return [{"selection": {"sketches": [name]}} for name in names] + def finish(args, area): + info = call("boundary_surface", **args, name="Boundary")["boundary"] + near(info["feature_face_area_mm2"], area, "boundary face area") + features = call("list_features") + assert call("get_boundary_feature_data", name="Boundary")["boundary"] == info + assert call("list_features") == features + return info + try: + for two_directions, trim1, trim2 in ((False, False, False), (True, False, False), (True, True, False), (True, False, True), (True, True, True)): + part() + line("Bottom", (-5, 0, 15, 0)); line("Top", (-5, 10, 15, 10)) + args = {"direction1": curves(["Bottom", "Top"])} + if two_directions: + line("Left", (0, -5, 0, 15)); line("Right", (10, -5, 10, 15)) + args.update(direction2=curves(["Left", "Right"]), trim_direction1=trim1, trim_direction2=trim2) + # Full extent 20x20 with both directions; each trim restricts its extent to 10 mm. + expected = (10 if trim1 else 20) * (10 if trim2 else 20) if two_directions else 200 + info = finish(args, expected) + assert [d["curve_count"] for d in info["directions"]] == [2, 2 if two_directions else 0] + assert info["bodies"][0]["body_type"] == 1 + + part() + for name, points in (("Bottom", (0, 0, 10, 0)), ("Middle", (0, 5, 10, 5)), ("Top", (0, 10, 10, 10))): line(name, points) + info = finish({"direction1": curves(["Bottom", "Middle", "Top"])}, 100) + assert info["directions"][0]["curve_count"] == 3 + + for solid, tangency, profile in ((False, "none", "sketch"), (False, "normal_to_profile", "sketch"), + (True, "none", "body"), (False, "none", "edge"), + (False, "tangent_to_face", "face"), (False, "curvature_to_face", "face")): + part(); call("create_plane", mode="offset", selection={"planes": ["front"]}, distance_mm=10, name="EndPlane") + for name, plane in (("Start", "front"), ("End", "EndPlane")): + call("create_sketch", plane=plane, name=name); call("draw_circle", x_mm=0, y_mm=0, radius_mm=5); call("close_sketch") + if profile != "sketch": call("planar_surface", sketch_name=name, name=name + "Cap") + if profile == "sketch": selected = [{"sketches": [name]} for name in ("Start", "End")] + elif profile == "body": selected = [{"surface_bodies": [b["index"]]} for b in call("list_surface_bodies")["surface_bodies"]] + elif profile == "edge": selected = [{"surface_edges": [e["index"]]} for e in call("list_edges", body_type="surface")["edges"]] + else: selected = [{"surface_faces": [f["index"]]} for f in call("list_faces", body_type="surface")["faces"]] + args = {"direction1": [{"selection": s, "tangency": tangency} for s in selected], "create_solid": solid, "merge_result": solid} + if tangency in ("tangent_to_face", "curvature_to_face"): + payload = HANDLERS["boundary_surface"]({**args, "name": "UnconfirmedContinuity"}) + assert not payload["ok"] and payload["data"]["feature"] == "UnconfirmedContinuity" + checks = [c["continuity"] for c in payload["data"]["boundary"]["directions"][0]["curves"]] + assert all(not c["confirmed"] and c["maximum_normal_angle_deg"] > 89 for c in checks) + print("Native stored continuity does not match endpoint normals; feature preserved as unconfirmed.", flush=True) + else: + info = finish(args, 2 * math.pi * 5 * 10) + assert info["bodies"][0]["body_type"] == (0 if solid else 1) + if solid: near(info["bodies"][0]["volume_mm3"], math.pi * 25 * 10, "formed solid volume") + print("Boundary one/two-direction trims, three profiles, circle/edge profiles, normal condition and formed-solid volume verified on 12 scratch parts; G1/G2 remains a native gap.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for name in scratch: + if name in opened: call("close_document", name=name, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_boundary.py b/tests/test_boundary.py new file mode 100644 index 0000000..cfef92a --- /dev/null +++ b/tests/test_boundary.py @@ -0,0 +1,109 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +import unittest +from solidworks_mcp import sw_boundary as boundary + + +ARGS = {"direction1": [{"selection": {"sketches": ["A"]}}, {"selection": {"sketches": ["B"]}}], + "direction2": [{"selection": {"sketches": ["C"]}}, {"selection": {"sketches": ["D"]}}]} + + +class BoundaryContracts(unittest.TestCase): + def test_invalid_inputs_fail_before_com(self): + for args in ({"direction1": []}, {"direction1": ARGS["direction1"][:1]}, + {"direction1": [{"selection": {"planes": ["front"]}}] * 2}, + {"direction1": [{"selection": {"sketches": ["A"]}, "tangency": "unknown"}] * 2}, + {**ARGS, "direction1_influence": "unknown"}): + with patch.object(boundary, "require_part") as part: + with self.assertRaises(RuntimeError): + boundary.boundary_surface(args) + part.assert_not_called() + + def _patches(self, stack, manager, info=None): + values = {"require_part": (None, "doc"), "feature_manager": manager, "_feature_names": [], + "_feature_created_after": "recovered", "_boundary_info": info} + for name, returned in values.items(): + stack.enter_context(patch.object(boundary, name, return_value=returned)) + stack.enter_context(patch.object(boundary, "exit_active_sketch")) + stack.enter_context(patch.object(boundary, "clear_selection")) + stack.enter_context(patch.object(boundary, "_finish", side_effect=lambda doc, feature, *args: {"ok": info is not None, "data": {"feature": feature}})) + + def test_selection_order_marks_and_void_creation_recovery(self): + events = [] + manager = SimpleNamespace(SetNetBlendCurveData=lambda *args: events.append(("condition", args)), + SetNetBlendDirectionData=lambda *args: None, + InsertNetBlend2=lambda *args: None) + with ExitStack() as stack: + self._patches(stack, manager) + stack.enter_context(patch.object(boundary, "require_selection", side_effect=lambda doc, spec, **kw: events.append(("select", kw["mark"])) or 1)) + payload = boundary.boundary_surface(ARGS) + self.assertEqual(payload["data"]["feature"], "recovered") + self.assertEqual(events[:4], [("select", 8193), ("select", 16385), ("select", 8194), ("select", 16386)]) + self.assertTrue(all(event[0] == "condition" for event in events[4:])) + + def test_legacy_method_preserves_surface_but_cannot_form_solid(self): + legacy = Mock(return_value=None) + manager = SimpleNamespace(SetNetBlendCurveData=lambda *a: None, SetNetBlendDirectionData=lambda *a: None, InsertNetBlend=legacy) + with ExitStack() as stack: + self._patches(stack, manager) + stack.enter_context(patch.object(boundary, "require_selection", return_value=1)) + boundary.boundary_surface(ARGS) + self.assertEqual(len(legacy.call_args.args), 20) + legacy.reset_mock() + with self.assertRaisesRegex(RuntimeError, "older method"): + boundary.boundary_surface({**ARGS, "create_solid": True}) + legacy.assert_not_called() + + def test_real_creation_failure_is_not_retried_through_legacy_method(self): + legacy = Mock() + manager = SimpleNamespace(SetNetBlendCurveData=lambda *a: None, SetNetBlendDirectionData=lambda *a: None, + InsertNetBlend2=Mock(side_effect=RuntimeError("native error")), InsertNetBlend=legacy) + with ExitStack() as stack: + self._patches(stack, manager) + stack.enter_context(patch.object(boundary, "require_selection", return_value=1)) + with self.assertRaisesRegex(RuntimeError, "native error"): + boundary.boundary_surface(ARGS) + legacy.assert_not_called() + + def test_inconsistent_curve_references_release_selection_access(self): + release = Mock() + definition = SimpleNamespace(AccessSelections=lambda *a: True, GetCurvesCount=lambda d: 2, D1Curves=[], ReleaseSelectionAccess=lambda: release()) + feature = SimpleNamespace(GetTypeName2=lambda: "NetBlend", GetDefinition=lambda: definition) + with self.assertRaisesRegex(RuntimeError, "inconsistent"): + boundary._boundary_info(None, feature) + release.assert_called_once() + + def test_wrong_body_type_retains_feature_and_reports_failure(self): + info = {"directions": [{"curve_count": 2, "influence": 32, "curves": [{"tangency": 0}] * 2}] * 2, + "trim_direction1": False, "merge_result": False, "bodies": [{"body_type": 0}]} + manager = SimpleNamespace(SetNetBlendCurveData=lambda *a: None, SetNetBlendDirectionData=lambda *a: None, InsertNetBlend2=lambda *a: "feature") + with ExitStack() as stack: + self._patches(stack, manager, info) + stack.enter_context(patch.object(boundary, "require_selection", return_value=1)) + payload = boundary.boundary_surface(ARGS) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["feature"], "feature") + + def test_curvature_comparison_uses_world_tangent_directions_and_mm(self): + # Cylinder R=5 mm; two equivalent parameterizations, including swapped axes. + cylinder = {"du": [0, .005, 0], "dv": [0, 0, 1], "normal": [1, 0, 0], "e": -.005, "f": 0, "g": 0} + swapped = {"du": [0, 0, 2], "dv": [0, .01, 0], "normal": [-1, 0, 0], "e": 0, "f": 0, "g": .02} + for frame in (cylinder, swapped): + self.assertAlmostEqual(boundary._curvature(frame, [0, 1, 0], [0, 1, 0], [1, 0, 0]), -.2) + self.assertAlmostEqual(boundary._curvature(frame, [0, 0, 1], [0, 0, 1], [1, 0, 0]), 0) + + def test_no_geometric_reference_cannot_confirm_continuity(self): + self.assertFalse(boundary._verify_continuity(None, [], True)["confirmed"]) + + def test_tangency_only_needs_first_derivatives(self): + # Native blend surfaces reject second-order requests but allow first order. + evaluate = Mock(return_value=[0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1]) + surface = SimpleNamespace(GetClosestPointOn=lambda *p: [0, 0, 0, .2, .3], Evaluate=evaluate) + self.assertEqual(boundary._frame(surface, [0, 0, 0], False)["normal"], [0, 0, 1]) + evaluate.assert_called_once_with(.2, .3, 1, 1) diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 594bbcb..f27de2e 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -30,7 +30,7 @@ "swFmAbsoluteView": ["insert_model_view"], "swFmDetailView": ["insert_detail_view"], "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], - "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data", "preview_surface_trim", "trim_surface", "get_surface_trim_data"], + "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data", "preview_surface_trim", "trim_surface", "get_surface_trim_data", "boundary_surface", "get_boundary_feature_data"], "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], diff --git a/tools/verify_install.py b/tools/verify_install.py index b44733e..b80ea31 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -172,6 +172,19 @@ async def call(name, arguments=None): raise RuntimeError("Installed MCP trim retained the wrong sheet area.") if (await call("get_surface_trim_data", {"name": "InstalledTrim"}))["trim"] != trimmed["trim"]: raise RuntimeError("Installed MCP trim definition readback differs.") + for name, coords in (("BoundaryBottom", [500, 500, 510, 500]), ("BoundaryTop", [500, 510, 510, 510]), + ("BoundaryLeft", [500, 500, 500, 510]), ("BoundaryRight", [510, 500, 510, 510])): + await call("create_sketch", {"plane": "front", "name": name}) + await call("draw_line", dict(zip(("x1_mm", "y1_mm", "x2_mm", "y2_mm"), coords))) + await call("close_sketch") + boundary = (await call("boundary_surface", { + "direction1": [{"selection": {"sketches": [n]}} for n in ("BoundaryBottom", "BoundaryTop")], + "direction2": [{"selection": {"sketches": [n]}} for n in ("BoundaryLeft", "BoundaryRight")], + "name": "InstalledBoundary"}))["boundary"] + if not math.isclose(boundary["feature_face_area_mm2"], 100, abs_tol=1e-7) or [d["curve_count"] for d in boundary["directions"]] != [2, 2]: + raise RuntimeError("Installed MCP boundary area or curve groups differ.") + if (await call("get_boundary_feature_data", {"name": "InstalledBoundary"}))["boundary"] != boundary: + raise RuntimeError("Installed MCP boundary definition query differs.") await call("list_reference_points") systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: From 9ddf34e428bc24c62cf8310a8be009461d984959 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:22:02 +0800 Subject: [PATCH 16/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8C=85=E8=A6=86?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=8E=E5=8E=9F=E7=94=9F=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=B9=B6=E9=AA=8C=E8=AF=81=E4=BD=93=E7=A7=AF?= =?UTF-8?q?=E5=8F=8A=E9=9D=A2=E5=88=86=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 10 ++- README.md | 3 +- solidworks_mcp/server.py | 1 + solidworks_mcp/sw_wrap.py | 120 ++++++++++++++++++++++++++++++++++ tests/live_wrap_regression.py | 50 ++++++++++++++ tests/test_wrap.py | 86 ++++++++++++++++++++++++ tools/verify_install.py | 21 ++++++ 7 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 solidworks_mcp/sw_wrap.py create mode 100644 tests/live_wrap_regression.py create mode 100644 tests/test_wrap.py diff --git a/COVERAGE.md b/COVERAGE.md index 0c5bb33..8753e22 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,8 +47,16 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 171 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 173 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 + +新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 +刻划及解析/样条方法:解析凸起增加 42 mm³、凹陷减少 38 mm³,样条结果误差 +小于 0.007 mm³;刻划保持体积并把实体面数从 3 增加到 4。读回原生模式、 +厚度、源草图与目标/拉伸方向存在状态,读取几何前释放原生选择回滚。 +IWrapSketchFeatureData 不暴露方法、网格精度和全部目标面,查询明确列为不可读取。 +目前支持单个实体面、闭合二维草图和默认方向;多个目标面、指定拉伸方向、 +反向、参数修改及其他复杂曲面仍保留为覆盖缺口。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回。 diff --git a/README.md b/README.md index b63f96f..070a9c2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -171 tools: sketching with real relations and driving dimensions, the solid +173 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -198,6 +198,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `mid_surface` / `get_mid_surface_data` | 原生自动中面与只读检查;读取面配对、厚度、面积、位置和缝合结果。非零位置未生效时返回失败。 | | `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | | `boundary_surface` / `get_boundary_feature_data` | 单/双方向有序曲线的边界曲面、按方向修剪、原生形成实体选项及只读检查;相切/曲率控制按边界采样核验几何,未确认时保留特征并返回失败。 | +| `wrap_sketch` / `get_wrap_data` | 单个实体面的包覆凸起、凹陷与刻划,支持解析/样条方法;以体积变化、面分割和原生定义验证。原生接口无法读回方法和网格精度,明确标记为不可读取。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | diff --git a/solidworks_mcp/server.py b/solidworks_mcp/server.py index e0825e5..deed954 100644 --- a/solidworks_mcp/server.py +++ b/solidworks_mcp/server.py @@ -62,6 +62,7 @@ from . import sw_manage # noqa: F401 from . import sw_multibody # noqa: F401 from . import sw_boundary # noqa: F401 +from . import sw_wrap # noqa: F401 from . import sw_sheetmetal # noqa: F401 from . import sw_weldment # noqa: F401 from . import sw_motion # noqa: F401 diff --git a/solidworks_mcp/sw_wrap.py b/solidworks_mcp/sw_wrap.py new file mode 100644 index 0000000..626e913 --- /dev/null +++ b/solidworks_mcp/sw_wrap.py @@ -0,0 +1,120 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Native single-face wrap creation and definition/geometry inspection.""" +import math +from .sw_core import ( + as_list, clear_selection, exit_active_sketch, feature_manager, find_feature, + flag_methods, get_bodies, nothing, require_part, require_selection, + result, safe, tool, value, +) +from .sw_feature import _feature_names, _feature_created_after +from .sw_multibody import _finish + +MODES = {"emboss": 0, "engrave": 1, "scribe": 2} +METHODS = {"analytical": 0, "spline": 1} + + +def _arguments(args): + mode, method = args.get("mode", "emboss"), args.get("method", "analytical") + if mode not in MODES or method not in METHODS: + raise RuntimeError("Unknown wrap mode or method.") + thickness = float(args.get("thickness_mm", 1.)) + if not math.isfinite(thickness) or not .01 <= thickness <= 1e7: + raise RuntimeError("Wrap thickness_mm must be between 0.01 and 10000000.") + mesh = args.get("mesh_factor", 5) + if isinstance(mesh, bool) or not isinstance(mesh, int) or not 1 <= mesh <= 10: + raise RuntimeError("mesh_factor must be an integer from 1 to 10.") + index = args["face_index"] + if isinstance(index, bool) or not isinstance(index, int) or index < 0: + raise RuntimeError("face_index must be a nonnegative solid-face index.") + if not isinstance(args["sketch_name"], str) or not args["sketch_name"].strip(): + raise RuntimeError("A closed 2D sketch_name is required.") + return mode, method, thickness, mesh + + +def _geometry(doc): + bodies = get_bodies(doc) + return {"solid_body_count": len(bodies), + "solid_volume_mm3": sum(float(flag_methods(b, "GetMassProperties").GetMassProperties(1.)[3]) * 1e9 for b in bodies), + "solid_face_count": sum(len(as_list(value(b, "GetFaces"))) for b in bodies)} + + +def _wrap_info(doc, feature): + if feature is None or safe(feature, "GetTypeName2") != "Emboss": + raise RuntimeError("Specify an existing native wrap feature.") + definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") + if not definition.AccessSelections(doc, nothing()): + raise RuntimeError("Cannot access wrap feature selections.") + try: + source = value(definition, "SourceSketch") + mode = int(value(definition, "Type")) + info = {"mode": next((k for k, v in MODES.items() if v == mode), "unknown"), "native_type": mode, + "thickness_mm": float(value(definition, "Thickness")) * 1000, + "reverse_direction": bool(value(definition, "ReverseDirection")), + "source_sketch": str(value(source, "Name")) if source is not None else None, + "has_target_face": value(definition, "Face") is not None, + "has_pull_direction": value(definition, "PullDirection") is not None, + "unreadable_parameters": ["method", "mesh_factor", "all_target_faces"]} + finally: + value(definition, "ReleaseSelectionAccess") + # AccessSelections rolls back the feature. Read geometry only after releasing it. + faces = as_list(value(feature, "GetFaces")) + info.update(_geometry(doc), feature_face_count=len(faces), + feature_face_area_mm2=sum(float(value(f, "GetArea")) for f in faces) * 1e6) + return info + + +@tool("get_wrap_data", "Read native wrap mode, thickness in mm, reverse state, source sketch, target/pull presence and current solid geometry. Native IWrapSketchFeatureData cannot expose method, mesh factor or all target faces; these are explicitly listed as unreadable.", + {"name": {"type": "string", "minLength": 1}}, ["name"]) +def get_wrap_data(args): + _, doc = require_part() + return result(True, "Read wrap definition and geometry.", wrap=_wrap_info(doc, find_feature(doc, args["name"]))) + + +@tool("wrap_sketch", "Wrap a closed 2D sketch on one solid face: emboss adds material, engrave removes it, scribe splits the face. Thickness is mm (unused by scribe). Supports analytical or spline method and spline mesh factor 1..10. Uses normal pull direction. Multiple target faces, custom pull and reverse direction are not implemented. Checks native definition, rebuild, volume change and face splitting; failed verification retains the feature.", + {"sketch_name": {"type": "string", "minLength": 1}, "face_index": {"type": "integer", "minimum": 0}, + "mode": {"type": "string", "enum": list(MODES), "default": "emboss"}, + "method": {"type": "string", "enum": list(METHODS), "default": "analytical"}, + "thickness_mm": {"type": "number", "minimum": .01, "maximum": 1e7, "default": 1}, + "mesh_factor": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5}, + "name": {"type": "string"}}, ["sketch_name", "face_index"]) +def wrap_sketch(args): + mode, method, thickness, mesh = _arguments(args) + _, doc = require_part() + exit_active_sketch(doc) + manager = flag_methods(feature_manager(doc), "InsertWrapFeature2", "InsertWrapFeature") + modern = getattr(manager, "InsertWrapFeature2", None) + if modern is None and method != "analytical": + raise RuntimeError("Spline wrap requires InsertWrapFeature2; the older method cannot preserve this option.") + before = _geometry(doc) + names = _feature_names(doc) + try: + require_selection(doc, {"sketches": [args["sketch_name"]]}, mark=4) + require_selection(doc, {"faces": [args["face_index"]]}, mark=1, append=True) + feature = (modern(MODES[mode], thickness / 1000, False, METHODS[method], mesh) if modern is not None + else manager.InsertWrapFeature(MODES[mode], thickness / 1000, False)) + if feature is None: + feature = _feature_created_after(doc, names) + payload = _finish(doc, feature, args, "wrap feature") + if feature is None or not payload["ok"]: + return payload + info = _wrap_info(doc, feature) + delta = info["solid_volume_mm3"] - before["solid_volume_mm3"] + volume_tol = max(1e-6, before["solid_volume_mm3"] * 1e-9) + geometric = (delta > volume_tol if mode == "emboss" else delta < -volume_tol if mode == "engrave" + else abs(delta) <= volume_tol and info["solid_face_count"] > before["solid_face_count"]) + confirmed = (info["mode"] == mode and info["source_sketch"] == args["sketch_name"] and info["has_target_face"] + and not info["reverse_direction"] and not info["has_pull_direction"] + and math.isclose(info["thickness_mm"], thickness, abs_tol=1e-7) and geometric) + payload["data"].update(wrap=info, volume_change_mm3=delta, + requested_parameters={"method": method, "mesh_factor": mesh}, + geometry_confirmed=geometric) + if not confirmed: + payload["ok"] = False + payload["message"] = "Wrap was created, but native definition or geometric effect could not be confirmed. Feature retained." + return payload + finally: + clear_selection(doc) diff --git a/tests/live_wrap_regression.py b/tests/live_wrap_regression.py new file mode 100644 index 0000000..2f8f460 --- /dev/null +++ b/tests/live_wrap_regression.py @@ -0,0 +1,50 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Wrap geometry checks using independent scratch parts.""" +from live_expansion_regression import call, near +from solidworks_mcp.sw_core import require_part, get_bodies, flag_methods + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + try: + for mode in (0, 1, 2): + for method in (0, 1): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + call("create_sketch", plane="front", name="Base") + call("draw_circle", x_mm=0, y_mm=0, radius_mm=10); call("close_sketch") + call("boss_extrude", sketch_name="Base", depth_mm=30) + call("create_plane", mode="offset", selection={"planes": ["right"]}, distance_mm=10, name="WrapPlane") + call("create_sketch", plane_name="WrapPlane", name="WrapProfile") + call("draw_rectangle", x1_mm=-20, y1_mm=-2, x2_mm=-10, y2_mm=2); call("close_sketch") + _, doc = require_part() + def volume(): + return sum(flag_methods(b, "GetMassProperties").GetMassProperties(1.)[3] * 1e9 for b in get_bodies(doc)) + before = volume() + face_index = next(f["index"] for f in call("list_faces")["faces"] if f["surface_type"] == "cylinder") + before_count = len(call("list_faces")["faces"]) + data = call("wrap_sketch", sketch_name="WrapProfile", face_index=face_index, + mode=("emboss", "engrave", "scribe")[mode], method=("analytical", "spline")[method], + thickness_mm=1, name="Wrapped") + delta = data["volume_change_mm3"] + info = data["wrap"] + assert data["geometry_confirmed"] and info["source_sketch"] == "WrapProfile" + assert call("get_wrap_data", name="Wrapped")["wrap"] == info + assert abs(volume() - before - delta) < 1e-6 + assert info["solid_face_count"] > before_count + print(mode, method, delta, info["solid_face_count"], flush=True) + assert abs(delta - (42., -38., 0.)[mode]) < .1 + print("Wrap modes, methods, volume changes, split topology and read-only definition verified on six scratch parts.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for name in scratch: + if name in opened: call("close_document", name=name, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_wrap.py b/tests/test_wrap.py new file mode 100644 index 0000000..c181812 --- /dev/null +++ b/tests/test_wrap.py @@ -0,0 +1,86 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +import unittest +from solidworks_mcp import sw_wrap as wrap + +ARGS = {"sketch_name": "Profile", "face_index": 0} + + +class WrapContracts(unittest.TestCase): + def test_invalid_arguments_never_touch_com(self): + for change in ({"mode": "other"}, {"method": "other"}, {"thickness_mm": float("nan")}, + {"thickness_mm": .001}, {"mesh_factor": True}, {"mesh_factor": 11}, + {"face_index": -1}, {"sketch_name": " "}): + with patch.object(wrap, "require_part") as part: + with self.assertRaises(RuntimeError): wrap.wrap_sketch({**ARGS, **change}) + part.assert_not_called() + + def setup_calls(self, stack, manager, delta=2, faces=8): + info = {"mode": "emboss", "source_sketch": "Profile", "has_target_face": True, + "reverse_direction": False, "has_pull_direction": False, "thickness_mm": 1, + "solid_volume_mm3": 100 + delta, "solid_face_count": faces} + for name, returned in {"require_part": (None, "doc"), "feature_manager": manager, + "_feature_names": [], "_feature_created_after": "recovered", "_wrap_info": info, + "_geometry": {"solid_volume_mm3": 100, "solid_face_count": 3}}.items(): + stack.enter_context(patch.object(wrap, name, return_value=returned)) + for name in ("exit_active_sketch", "clear_selection"): + stack.enter_context(patch.object(wrap, name)) + stack.enter_context(patch.object(wrap, "_finish", side_effect=lambda *a: {"ok": True, "data": {"feature": a[1]}})) + return stack.enter_context(patch.object(wrap, "require_selection", return_value=1)) + + def test_marks_units_and_void_feature_recovery(self): + create = Mock(return_value=None) + with ExitStack() as stack: + select = self.setup_calls(stack, SimpleNamespace(InsertWrapFeature2=create)) + payload = wrap.wrap_sketch(ARGS) + create.assert_called_once_with(0, .001, False, 0, 5) + self.assertEqual([c.kwargs["mark"] for c in select.call_args_list], [4, 1]) + self.assertTrue(select.call_args_list[1].kwargs["append"]) + self.assertEqual(payload["data"]["feature"], "recovered") + self.assertTrue(payload["ok"]) + + def test_legacy_rejects_spline_before_mutation(self): + create = Mock() + with ExitStack() as stack: + select = self.setup_calls(stack, SimpleNamespace(InsertWrapFeature=create)) + with self.assertRaisesRegex(RuntimeError, "older method"): + wrap.wrap_sketch({**ARGS, "method": "spline"}) + select.assert_not_called(); create.assert_not_called() + wrap.wrap_sketch(ARGS) + create.assert_called_once_with(0, .001, False) + + def test_modern_errors_are_not_retried(self): + legacy = Mock() + with ExitStack() as stack: + self.setup_calls(stack, SimpleNamespace(InsertWrapFeature2=Mock(side_effect=RuntimeError("native failure")), InsertWrapFeature=legacy)) + with self.assertRaisesRegex(RuntimeError, "native failure"): wrap.wrap_sketch(ARGS) + legacy.assert_not_called() + + def test_non_effective_feature_is_preserved_as_failure(self): + with ExitStack() as stack: + self.setup_calls(stack, SimpleNamespace(InsertWrapFeature2=lambda *a: "feature"), delta=0) + payload = wrap.wrap_sketch(ARGS) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["feature"], "feature") + + def test_geometry_read_after_releasing_rollback(self): + events = [] + definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: events.append("release"), + SourceSketch=SimpleNamespace(Name="Profile"), Type=2, Thickness=.001, + ReverseDirection=False, Face=object(), PullDirection=None) + feature = SimpleNamespace(GetTypeName2=lambda: "Emboss", GetDefinition=lambda: definition, GetFaces=lambda: []) + with patch.object(wrap, "_geometry", side_effect=lambda d: events.append("geometry") or {}): + info = wrap._wrap_info("doc", feature) + self.assertEqual(events, ["release", "geometry"]) + self.assertEqual(info["mode"], "scribe") + self.assertIn("method", info["unreadable_parameters"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_install.py b/tools/verify_install.py index b80ea31..0ae8a47 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -37,6 +37,8 @@ async def protocol(install, expected, catalog, scratch): raise RuntimeError("MCP tool count or uniqueness differs from the expected release.") async def call(name, arguments=None): output = await session.call_tool(name, arguments or {}) + if output.isError: + raise RuntimeError(f"Installed MCP protocol error: {name}: {output.content[0].text}") payload = json.loads(output.content[0].text) if not payload["ok"]: raise RuntimeError(f"Installed MCP call failed: {name}: {payload}") @@ -51,6 +53,7 @@ async def call(name, arguments=None): initial = await call("list_open_documents") original = initial["active_title"] title = None + wrap_title = None try: if scratch: title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] @@ -189,7 +192,25 @@ async def call(name, arguments=None): systems = await call("list_coordinate_systems") if scratch and systems["coordinate_systems"][0]["origin_mm"] != [2, 3, 4]: raise RuntimeError("Installed MCP coordinate-system origin readback differs.") + if scratch: + wrap_title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] + await call("create_sketch", {"plane": "front", "name": "WrapBase"}) + await call("draw_circle", {"x_mm": 0, "y_mm": 0, "radius_mm": 10}) + await call("close_sketch") + await call("boss_extrude", {"sketch_name": "WrapBase", "depth_mm": 30}) + await call("create_plane", {"mode": "offset", "selection": {"planes": ["right"]}, "distance_mm": 10, "name": "WrapPlane"}) + await call("create_sketch", {"plane_name": "WrapPlane", "name": "WrapProfile"}) + await call("draw_rectangle", {"x1_mm": -20, "y1_mm": -2, "x2_mm": -10, "y2_mm": 2}) + await call("close_sketch") + index = next(f["index"] for f in (await call("list_faces"))["faces"] if f["surface_type"] == "cylinder") + wrapped = await call("wrap_sketch", {"sketch_name": "WrapProfile", "face_index": index, "name": "InstalledWrap"}) + if not math.isclose(wrapped["volume_change_mm3"], 42, abs_tol=1e-6): + raise RuntimeError("Installed MCP wrap volume differs.") + if (await call("get_wrap_data", {"name": "InstalledWrap"}))["wrap"] != wrapped["wrap"]: + raise RuntimeError("Installed MCP wrap definition differs.") finally: + if wrap_title: + await call("close_document", {"name": wrap_title, "discard_changes": True}) if title: await call("close_document", {"name": title, "discard_changes": True}) if scratch and original: From 2b910dec0d47af72896241e78d58a9bd370f3604 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:23:21 +0800 Subject: [PATCH 17/31] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=A0=B7=E6=9D=A1?= =?UTF-8?q?=E5=8C=85=E8=A6=86=E5=87=A0=E4=BD=95=E8=AF=BB=E5=9B=9E=E7=9A=84?= =?UTF-8?q?=E6=B5=AE=E7=82=B9=E6=AF=94=E8=BE=83=E5=AE=B9=E5=B7=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/live_wrap_regression.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/live_wrap_regression.py b/tests/live_wrap_regression.py index 2f8f460..c1582fc 100644 --- a/tests/live_wrap_regression.py +++ b/tests/live_wrap_regression.py @@ -33,7 +33,13 @@ def volume(): delta = data["volume_change_mm3"] info = data["wrap"] assert data["geometry_confirmed"] and info["source_sketch"] == "WrapProfile" - assert call("get_wrap_data", name="Wrapped")["wrap"] == info + queried = call("get_wrap_data", name="Wrapped")["wrap"] + assert queried.keys() == info.keys() + for key, expected in info.items(): + if isinstance(expected, float): + assert abs(queried[key] - expected) < 1e-6, (key, queried[key], expected) + else: + assert queried[key] == expected, (key, queried[key], expected) assert abs(volume() - before - delta) < 1e-6 assert info["solid_face_count"] > before_count print(mode, method, delta, info["solid_face_count"], flush=True) From d9024a76d393a3f998c4beeae5d87fbbcbe35a34 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:36:54 +0800 Subject: [PATCH 18/31] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=A4=9A=E9=9D=A2?= =?UTF-8?q?=E5=8C=85=E8=A6=86=E5=8F=8A=E6=96=B9=E5=90=91=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=8E=9F=E7=94=9F=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 16 ++- README.md | 4 +- solidworks_mcp/sw_wrap.py | 169 ++++++++++++++++++++++---- tests/live_wrap_options_regression.py | 94 ++++++++++++++ tests/test_wrap.py | 82 ++++++++++++- tools/verify_install.py | 14 +++ 6 files changed, 352 insertions(+), 27 deletions(-) create mode 100644 tests/live_wrap_options_regression.py diff --git a/COVERAGE.md b/COVERAGE.md index 8753e22..2de9333 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 173 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 174 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 @@ -55,8 +55,18 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 小于 0.007 mm³;刻划保持体积并把实体面数从 3 增加到 4。读回原生模式、 厚度、源草图与目标/拉伸方向存在状态,读取几何前释放原生选择回滚。 IWrapSketchFeatureData 不暴露方法、网格精度和全部目标面,查询明确列为不可读取。 -目前支持单个实体面、闭合二维草图和默认方向;多个目标面、指定拉伸方向、 -反向、参数修改及其他复杂曲面仍保留为覆盖缺口。 +第一阶段支持单个实体面、闭合二维草图和默认方向。 + +随后扩展多目标面与指定拉伸方向,新增 `set_wrap_parameters`。在十二个新建零件上 +验证横跨两个分割圆柱面的三种模式及解析/样条方法:解析凸起增加 168 mm³,凹陷 +减少 152 mm³;样条误差小于 0.03 mm³。基准面、草图线、实体直边方向以体积和 +原生持久引用核验;`IsSame` 对本机两个同一基准面的 COM 引用返回不同,不能据此 +误判方向丢失。厚度从 1 改为 2 mm、模式互换、指定新方向均已重建并核验体积。 +反向参数虽能保存,但在本机案例的体积和包覆位置未变化,仍不能宣称几何反向生效。 +清除方向的 null dispatch 被原生接口接受,却仍保留旧方向;普通 None、空/Null VARIANT +被拒绝。两项均按未确认状态返回失败并保留特征。修改源草图、目标面、方法和网格 +以及其他复杂曲面、旧版软件验证仍保留为覆盖缺口。多面创建要求现代接口,旧接口 +不能降级丢弃多面或样条参数。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回。 diff --git a/README.md b/README.md index 070a9c2..edfc8f9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -173 tools: sketching with real relations and driving dimensions, the solid +174 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -198,7 +198,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `mid_surface` / `get_mid_surface_data` | 原生自动中面与只读检查;读取面配对、厚度、面积、位置和缝合结果。非零位置未生效时返回失败。 | | `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | | `boundary_surface` / `get_boundary_feature_data` | 单/双方向有序曲线的边界曲面、按方向修剪、原生形成实体选项及只读检查;相切/曲率控制按边界采样核验几何,未确认时保留特征并返回失败。 | -| `wrap_sketch` / `get_wrap_data` | 单个实体面的包覆凸起、凹陷与刻划,支持解析/样条方法;以体积变化、面分割和原生定义验证。原生接口无法读回方法和网格精度,明确标记为不可读取。 | +| `wrap_sketch` / `get_wrap_data` / `set_wrap_parameters` | 单/多实体面的包覆凸起、凹陷与刻划,解析/样条方法,基准面/草图线/直边拉伸方向及厚度/模式/方向修改。以体积、面分割、原生定义和持久引用验证。方法/网格精度不可读回;反向几何和清除方向未生效时返回失败。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | diff --git a/solidworks_mcp/sw_wrap.py b/solidworks_mcp/sw_wrap.py index 626e913..86a1843 100644 --- a/solidworks_mcp/sw_wrap.py +++ b/solidworks_mcp/sw_wrap.py @@ -3,18 +3,54 @@ # you may not use this file except in compliance with the License. # You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 -"""Native single-face wrap creation and definition/geometry inspection.""" +"""Native wrap creation, definition inspection and parameter modification.""" import math from .sw_core import ( as_list, clear_selection, exit_active_sketch, feature_manager, find_feature, flag_methods, get_bodies, nothing, require_part, require_selection, - result, safe, tool, value, + result, safe, tool, value, SELECTION_SCHEMA, rebuild, whats_wrong, ) from .sw_feature import _feature_names, _feature_created_after from .sw_multibody import _finish MODES = {"emboss": 0, "engrave": 1, "scribe": 2} METHODS = {"analytical": 0, "spline": 1} +PULL_SCHEMA = {"type": "object", "properties": { + k: SELECTION_SCHEMA["properties"][k] for k in ("planes", "edges", "sketch_segments", "sketch_name")}, + "additionalProperties": False, + "description": "Exactly one reference plane, linear solid edge or line sketch segment. The plane normal or line direction defines pull."} + + +def _pull_selection(spec): + if not isinstance(spec, dict) or any(k not in PULL_SCHEMA["properties"] for k in spec): + raise RuntimeError("Pull selection must name one plane, linear edge or sketch line.") + if sum(len(spec.get(k, [])) for k in ("planes", "edges", "sketch_segments")) != 1: + raise RuntimeError("Pull selection must resolve to exactly one direction entity.") + return spec + + +def _selected_pull(doc, spec, append=False): + if require_selection(doc, spec, mark=2, append=append) != 1: + raise RuntimeError("Pull selection did not resolve to one entity.") + manager = flag_methods(value(doc, "SelectionManager"), "GetSelectedObject6") + obj = manager.GetSelectedObject6(1, 2) + if spec.get("edges"): + curve = value(obj, "GetCurve") + if not bool(value(curve, "IsLine")): + raise RuntimeError("The pull edge must be linear.") + if spec.get("sketch_segments") and int(value(obj, "GetType")) != 0: + raise RuntimeError("The pull sketch segment must be a line.") + return obj + + +def _indices(args): + if "face_index" in args and "face_indices" in args: + raise RuntimeError("Use face_index or face_indices, not both.") + indices = args.get("face_indices", [args["face_index"]] if "face_index" in args else []) + if (not isinstance(indices, list) or not indices or any(isinstance(i, bool) or not isinstance(i, int) or i < 0 for i in indices) + or len(set(indices)) != len(indices)): + raise RuntimeError("Specify distinct nonnegative solid-face indices.") + return indices def _arguments(args): @@ -27,9 +63,13 @@ def _arguments(args): mesh = args.get("mesh_factor", 5) if isinstance(mesh, bool) or not isinstance(mesh, int) or not 1 <= mesh <= 10: raise RuntimeError("mesh_factor must be an integer from 1 to 10.") - index = args["face_index"] - if isinstance(index, bool) or not isinstance(index, int) or index < 0: - raise RuntimeError("face_index must be a nonnegative solid-face index.") + _indices(args) + if not isinstance(args.get("reverse_direction", False), bool): + raise RuntimeError("reverse_direction must be boolean.") + if "pull_selection" in args: + _pull_selection(args["pull_selection"]) + if mode == "scribe": + raise RuntimeError("A pull direction is only applicable to emboss or engrave.") if not isinstance(args["sketch_name"], str) or not args["sketch_name"].strip(): raise RuntimeError("A closed 2D sketch_name is required.") return mode, method, thickness, mesh @@ -42,7 +82,11 @@ def _geometry(doc): "solid_face_count": sum(len(as_list(value(b, "GetFaces"))) for b in bodies)} -def _wrap_info(doc, feature): +def _reference_id(doc, obj): + return tuple(as_list(flag_methods(value(doc, "Extension"), "GetPersistReference3").GetPersistReference3(obj))) + + +def _wrap_info(doc, feature, expected_pull=None): if feature is None or safe(feature, "GetTypeName2") != "Emboss": raise RuntimeError("Specify an existing native wrap feature.") definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") @@ -51,13 +95,17 @@ def _wrap_info(doc, feature): try: source = value(definition, "SourceSketch") mode = int(value(definition, "Type")) + pull = value(definition, "PullDirection") info = {"mode": next((k for k, v in MODES.items() if v == mode), "unknown"), "native_type": mode, "thickness_mm": float(value(definition, "Thickness")) * 1000, "reverse_direction": bool(value(definition, "ReverseDirection")), "source_sketch": str(value(source, "Name")) if source is not None else None, "has_target_face": value(definition, "Face") is not None, - "has_pull_direction": value(definition, "PullDirection") is not None, + "has_pull_direction": pull is not None, "unreadable_parameters": ["method", "mesh_factor", "all_target_faces"]} + if expected_pull is not None: + expected_id = _reference_id(doc, expected_pull) + info["pull_reference_confirmed"] = bool(expected_id) and pull is not None and _reference_id(doc, pull) == expected_id finally: value(definition, "ReleaseSelectionAccess") # AccessSelections rolls back the feature. Read geometry only after releasing it. @@ -67,6 +115,14 @@ def _wrap_info(doc, feature): return info +def _effect(info, before): + delta = info["solid_volume_mm3"] - before["solid_volume_mm3"] + tol = max(1e-6, before["solid_volume_mm3"] * 1e-9) + mode = info["mode"] + return delta, (delta > tol if mode == "emboss" else delta < -tol if mode == "engrave" else + abs(delta) <= tol and info["solid_face_count"] > before["solid_face_count"]) + + @tool("get_wrap_data", "Read native wrap mode, thickness in mm, reverse state, source sketch, target/pull presence and current solid geometry. Native IWrapSketchFeatureData cannot expose method, mesh factor or all target faces; these are explicitly listed as unreadable.", {"name": {"type": "string", "minLength": 1}}, ["name"]) def get_wrap_data(args): @@ -74,47 +130,118 @@ def get_wrap_data(args): return result(True, "Read wrap definition and geometry.", wrap=_wrap_info(doc, find_feature(doc, args["name"]))) -@tool("wrap_sketch", "Wrap a closed 2D sketch on one solid face: emboss adds material, engrave removes it, scribe splits the face. Thickness is mm (unused by scribe). Supports analytical or spline method and spline mesh factor 1..10. Uses normal pull direction. Multiple target faces, custom pull and reverse direction are not implemented. Checks native definition, rebuild, volume change and face splitting; failed verification retains the feature.", +@tool("wrap_sketch", "Wrap a closed 2D sketch on solid faces: emboss adds material, engrave removes it, scribe splits faces. Use face_index or face_indices. Multiple faces require InsertWrapFeature2; analytical and spline methods are supported. Thickness is mm (unused by scribe). Optional pull_selection selects one plane, linear edge or sketch line (mark 2). Checks pull reference identity, native definition, rebuild, volume and face splitting. reverse_direction is forwarded and read back, but its geometric effect remains unconfirmed and returns failure with the created feature retained.", {"sketch_name": {"type": "string", "minLength": 1}, "face_index": {"type": "integer", "minimum": 0}, + "face_indices": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True}, "mode": {"type": "string", "enum": list(MODES), "default": "emboss"}, "method": {"type": "string", "enum": list(METHODS), "default": "analytical"}, "thickness_mm": {"type": "number", "minimum": .01, "maximum": 1e7, "default": 1}, "mesh_factor": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5}, - "name": {"type": "string"}}, ["sketch_name", "face_index"]) + "pull_selection": PULL_SCHEMA, "reverse_direction": {"type": "boolean", "default": False}, + "name": {"type": "string"}}, ["sketch_name"]) def wrap_sketch(args): mode, method, thickness, mesh = _arguments(args) + indices = _indices(args) + reverse = args.get("reverse_direction", False) _, doc = require_part() exit_active_sketch(doc) manager = flag_methods(feature_manager(doc), "InsertWrapFeature2", "InsertWrapFeature") modern = getattr(manager, "InsertWrapFeature2", None) - if modern is None and method != "analytical": - raise RuntimeError("Spline wrap requires InsertWrapFeature2; the older method cannot preserve this option.") + if modern is None and (method != "analytical" or len(indices) > 1): + raise RuntimeError("Spline or multiple-face wrap requires InsertWrapFeature2; the older method cannot preserve this option.") before = _geometry(doc) names = _feature_names(doc) try: require_selection(doc, {"sketches": [args["sketch_name"]]}, mark=4) - require_selection(doc, {"faces": [args["face_index"]]}, mark=1, append=True) - feature = (modern(MODES[mode], thickness / 1000, False, METHODS[method], mesh) if modern is not None - else manager.InsertWrapFeature(MODES[mode], thickness / 1000, False)) + require_selection(doc, {"faces": indices}, mark=1, append=True) + pull = _selected_pull(doc, args["pull_selection"], append=True) if "pull_selection" in args else None + feature = (modern(MODES[mode], thickness / 1000, reverse, METHODS[method], mesh) if modern is not None + else manager.InsertWrapFeature(MODES[mode], thickness / 1000, reverse)) if feature is None: feature = _feature_created_after(doc, names) payload = _finish(doc, feature, args, "wrap feature") if feature is None or not payload["ok"]: return payload - info = _wrap_info(doc, feature) - delta = info["solid_volume_mm3"] - before["solid_volume_mm3"] - volume_tol = max(1e-6, before["solid_volume_mm3"] * 1e-9) - geometric = (delta > volume_tol if mode == "emboss" else delta < -volume_tol if mode == "engrave" - else abs(delta) <= volume_tol and info["solid_face_count"] > before["solid_face_count"]) + info = _wrap_info(doc, feature, expected_pull=pull) + delta, geometric = _effect(info, before) confirmed = (info["mode"] == mode and info["source_sketch"] == args["sketch_name"] and info["has_target_face"] - and not info["reverse_direction"] and not info["has_pull_direction"] + and info["reverse_direction"] == reverse and info["has_pull_direction"] == (pull is not None) + and (pull is None or info["pull_reference_confirmed"]) and math.isclose(info["thickness_mm"], thickness, abs_tol=1e-7) and geometric) payload["data"].update(wrap=info, volume_change_mm3=delta, - requested_parameters={"method": method, "mesh_factor": mesh}, + requested_parameters={"method": method, "mesh_factor": mesh, "face_indices": indices}, geometry_confirmed=geometric) + if reverse: + confirmed = False + payload["data"]["reverse_geometry_confirmed"] = False if not confirmed: payload["ok"] = False payload["message"] = "Wrap was created, but native definition or geometric effect could not be confirmed. Feature retained." return payload finally: clear_selection(doc) + + +@tool("set_wrap_parameters", "Modify native wrap mode, thickness in mm, reverse state or pull direction and rebuild dependent features. clear_pull_direction attempts to clear the reference, but this native option remained unchanged on the tested build and readback reports failure. Reference identity and readable parameters are checked; geometric effect is checked against the model rolled back before the wrap. Method, mesh, source sketch and target faces are not modified. Reverse-direction geometry remains unconfirmed: requesting true reports failure and retains the modified feature.", + {"name": {"type": "string", "minLength": 1}, "mode": {"type": "string", "enum": list(MODES)}, + "thickness_mm": {"type": "number", "minimum": .01, "maximum": 1e7}, + "reverse_direction": {"type": "boolean"}, "pull_selection": PULL_SCHEMA, + "clear_pull_direction": {"type": "boolean"}}, ["name"]) +def set_wrap_parameters(args): + keys = ("mode", "thickness_mm", "reverse_direction", "pull_selection", "clear_pull_direction") + if not any(k in args for k in keys) or args.get("clear_pull_direction") is False and not any(k in args for k in keys[:-1]): + raise RuntimeError("Specify at least one wrap parameter to change.") + if "mode" in args and args["mode"] not in MODES: + raise RuntimeError("Unknown wrap mode.") + if "thickness_mm" in args and (not math.isfinite(float(args["thickness_mm"])) or not .01 <= float(args["thickness_mm"]) <= 1e7): + raise RuntimeError("Invalid wrap thickness_mm.") + for k in ("reverse_direction", "clear_pull_direction"): + if k in args and not isinstance(args[k], bool): + raise RuntimeError(f"{k} must be boolean.") + if "pull_selection" in args: + _pull_selection(args["pull_selection"]) + if args.get("clear_pull_direction"): + raise RuntimeError("Do not specify pull_selection together with clear_pull_direction.") + if args.get("mode") == "scribe": + raise RuntimeError("Pull direction is not applicable to scribe.") + _, doc = require_part() + exit_active_sketch(doc) + feature = find_feature(doc, args["name"]) + previous = _wrap_info(doc, feature) + if "pull_selection" in args and args.get("mode", previous["mode"]) == "scribe": + raise RuntimeError("Pull direction is not applicable to scribe.") + definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") + if not definition.AccessSelections(doc, nothing()): + return result(False, "Could not access wrap selections.") + accepted = False + pull = None + try: + baseline = _geometry(doc) + for key, member in (("mode", "Type"), ("thickness_mm", "Thickness"), ("reverse_direction", "ReverseDirection")): + if key in args: + raw = MODES[args[key]] if key == "mode" else float(args[key]) / 1000 if key == "thickness_mm" else args[key] + setattr(definition, member, raw) + if "pull_selection" in args: + pull = _selected_pull(doc, args["pull_selection"]) + definition.PullDirection = pull + elif args.get("clear_pull_direction"): + clear_selection(doc) + definition.PullDirection = nothing() + accepted = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(definition, doc, nothing())) + finally: + if not accepted: + value(definition, "ReleaseSelectionAccess") + clear_selection(doc) + rebuild(doc) + problems = whats_wrong(doc) + info = _wrap_info(doc, feature, expected_pull=pull) + correct = all((math.isclose(info[k], float(args[k]), abs_tol=1e-7) if k == "thickness_mm" else info[k] == args[k]) + for k in ("mode", "thickness_mm", "reverse_direction") if k in args) + correct = correct and (pull is None or info["pull_reference_confirmed"]) and (not args.get("clear_pull_direction") or not info["has_pull_direction"]) + delta, geometry = _effect(info, baseline) + reverse_confirmed = not info["reverse_direction"] + ok = accepted and correct and geometry and reverse_confirmed and not problems + return result(ok, "Updated wrap parameters and verified geometry." if ok else + "Wrap modification or geometric effect could not be confirmed. Feature retained.", + feature=args["name"], wrap=info, parameters_confirmed=correct, geometry_confirmed=geometry, + reverse_geometry_confirmed=reverse_confirmed, volume_change_from_input_mm3=delta, problems=problems) diff --git a/tests/live_wrap_options_regression.py b/tests/live_wrap_options_regression.py new file mode 100644 index 0000000..b5a9ac3 --- /dev/null +++ b/tests/live_wrap_options_regression.py @@ -0,0 +1,94 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Multi-face wrap, pull references and definition edits on scratch parts.""" +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + def scene(): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + call("create_sketch", plane="front", name="Base") + call("draw_circle", x_mm=0, y_mm=0, radius_mm=10); call("close_sketch") + call("boss_extrude", sketch_name="Base", depth_mm=30) + call("create_plane", mode="offset", selection={"planes": ["right"]}, distance_mm=10, name="WrapPlane") + rectangle("Profile", (-20, -2, -10, 2)) + return next(f["index"] for f in call("list_faces")["faces"] if f["surface_type"] == "cylinder") + def rectangle(name, coords): + call("create_sketch", plane_name="WrapPlane", name=name) + call("draw_rectangle", **dict(zip(("x1_mm", "y1_mm", "x2_mm", "y2_mm"), coords))) + call("close_sketch") + def near(actual, expected, tol=.1): + assert abs(actual - expected) < tol, (actual, expected) + try: + for mode, method, expected in ((m, method, v) for method in ("analytical", "spline") + for m, v in (("emboss", 168), ("engrave", -152), ("scribe", 0))): + index = scene() + call("wrap_sketch", sketch_name="Profile", face_index=index, mode="scribe", name="Split") + rectangle("WideProfile", (-25, -4, -5, 4)) + indices = [f["index"] for f in call("list_faces")["faces"] if f["surface_type"] == "cylinder"] + assert len(indices) == 2 + data = call("wrap_sketch", sketch_name="WideProfile", face_indices=indices, mode=mode, method=method, name="MultiWrap") + near(data["volume_change_mm3"], expected) + assert data["geometry_confirmed"] and data["requested_parameters"]["face_indices"] == indices + assert call("get_wrap_data", name="MultiWrap")["wrap"]["source_sketch"] == "WideProfile" + print("MULTI", mode, method, data["volume_change_mm3"], flush=True) + + for direction, expected in (("right", 39.9754168573), ("top", 75.4935086176), ("line", 39.9754168573), ("edge", 39.9754168573)): + index = scene() + if direction == "line": + call("create_sketch", plane="front", name="Direction") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=0); call("close_sketch") + pull = {"sketch_name": "Direction", "sketch_segments": [0]} + elif direction == "edge": + call("create_sketch", plane="front", name="DirectionBlock") + call("draw_rectangle", x1_mm=30, y1_mm=0, x2_mm=40, y2_mm=10); call("close_sketch") + call("boss_extrude", sketch_name="DirectionBlock", depth_mm=30, merge=False) + edge = next(e["index"] for e in call("list_edges")["edges"] + if e.get("curve_type") == "line" and abs(e.get("direction", [0])[0]) > .99) + pull = {"edges": [edge]} + index = next(f["index"] for f in call("list_faces")["faces"] if f["surface_type"] == "cylinder") + else: + pull = {"planes": [direction]} + data = call("wrap_sketch", sketch_name="Profile", face_index=index, pull_selection=pull, name="DirectedWrap") + near(data["volume_change_mm3"], expected, .001) + assert data["wrap"]["pull_reference_confirmed"] + print("PULL", direction, data["volume_change_mm3"], flush=True) + + index = scene() + call("wrap_sketch", sketch_name="Profile", face_index=index, name="Editable") + for args, expected in (({"thickness_mm": 2}, 88), ({"mode": "engrave"}, -72), + ({"mode": "scribe"}, 0), ({"mode": "emboss"}, 88), + ({"pull_selection": {"planes": ["right"]}}, 79.9100175565)): + data = call("set_wrap_parameters", name="Editable", **args) + near(data["volume_change_from_input_mm3"], expected, .001) + assert data["parameters_confirmed"] and data["geometry_confirmed"] + assert call("get_wrap_data", name="Editable")["wrap"]["thickness_mm"] == 2 + print("EDIT", args, data["volume_change_from_input_mm3"], flush=True) + payload = HANDLERS["set_wrap_parameters"]({"name": "Editable", "clear_pull_direction": True}) + assert not payload["ok"] and not payload["data"]["parameters_confirmed"] + assert payload["data"]["wrap"]["has_pull_direction"] + payload = HANDLERS["set_wrap_parameters"]({"name": "Editable", "reverse_direction": True}) + assert not payload["ok"] and payload["data"]["parameters_confirmed"] and not payload["data"]["reverse_geometry_confirmed"] + assert payload["data"]["wrap"]["reverse_direction"] + call("set_wrap_parameters", name="Editable", reverse_direction=False) + + index = scene() + payload = HANDLERS["wrap_sketch"]({"sketch_name": "Profile", "face_index": index, "reverse_direction": True, "name": "Reverse"}) + assert not payload["ok"] and payload["data"]["wrap"]["reverse_direction"] + assert not payload["data"]["reverse_geometry_confirmed"] + print("Multi-face modes/methods, plane/line/edge pull identity, thickness/mode/pull edits verified on twelve scratch parts; reverse geometry and clearing pull remain native gaps.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_wrap.py b/tests/test_wrap.py index c181812..ee0a7b3 100644 --- a/tests/test_wrap.py +++ b/tests/test_wrap.py @@ -16,7 +16,10 @@ class WrapContracts(unittest.TestCase): def test_invalid_arguments_never_touch_com(self): for change in ({"mode": "other"}, {"method": "other"}, {"thickness_mm": float("nan")}, {"thickness_mm": .001}, {"mesh_factor": True}, {"mesh_factor": 11}, - {"face_index": -1}, {"sketch_name": " "}): + {"face_index": -1}, {"sketch_name": " "}, {"face_indices": [0, 0]}, + {"reverse_direction": 1}, {"pull_selection": {"axes": ["Axis"]}}, + {"pull_selection": {"planes": ["front", "right"]}}, + {"mode": "scribe", "pull_selection": {"planes": ["right"]}}): with patch.object(wrap, "require_part") as part: with self.assertRaises(RuntimeError): wrap.wrap_sketch({**ARGS, **change}) part.assert_not_called() @@ -55,6 +58,31 @@ def test_legacy_rejects_spline_before_mutation(self): wrap.wrap_sketch(ARGS) create.assert_called_once_with(0, .001, False) + def test_multiple_face_marks_and_reverse_forwarding(self): + create = Mock(return_value="multi") + with ExitStack() as stack: + select = self.setup_calls(stack, SimpleNamespace(InsertWrapFeature2=create)) + payload = wrap.wrap_sketch({"sketch_name": "Profile", "face_indices": [2, 4], "method": "spline", "reverse_direction": True}) + create.assert_called_once_with(0, .001, True, 1, 5) + self.assertEqual(select.call_args_list[1].args[1], {"faces": [2, 4]}) + self.assertFalse(payload["ok"]) + self.assertFalse(payload["data"]["reverse_geometry_confirmed"]) + + def test_legacy_does_not_silently_drop_multiple_targets(self): + create = Mock() + with ExitStack() as stack: + select = self.setup_calls(stack, SimpleNamespace(InsertWrapFeature=create)) + with self.assertRaisesRegex(RuntimeError, "multiple-face"): + wrap.wrap_sketch({"sketch_name": "Profile", "face_indices": [0, 1]}) + create.assert_not_called(); select.assert_not_called() + + def test_curved_pull_edge_is_rejected_before_creation(self): + curved = SimpleNamespace(GetCurve=lambda: SimpleNamespace(IsLine=lambda: False)) + doc = SimpleNamespace(SelectionManager=SimpleNamespace(GetSelectedObject6=lambda *a: curved)) + with patch.object(wrap, "require_selection", return_value=1): + with self.assertRaisesRegex(RuntimeError, "linear"): + wrap._selected_pull(doc, {"edges": [0]}) + def test_modern_errors_are_not_retried(self): legacy = Mock() with ExitStack() as stack: @@ -81,6 +109,58 @@ def test_geometry_read_after_releasing_rollback(self): self.assertEqual(info["mode"], "scribe") self.assertIn("method", info["unreadable_parameters"]) + def test_pull_identity_uses_nonempty_native_persistent_ids(self): + actual, expected = object(), object() + definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: None, + SourceSketch=SimpleNamespace(Name="Profile"), Type=0, Thickness=.001, + ReverseDirection=False, Face=object(), PullDirection=actual) + feature = SimpleNamespace(GetTypeName2=lambda: "Emboss", GetDefinition=lambda: definition, GetFaces=lambda: []) + for refs, confirmed in (([(1, 2), (1, 2)], True), ([(1, 2), (2, 1)], False), ([()], False)): + with patch.object(wrap, "_reference_id", side_effect=refs), patch.object(wrap, "_geometry", return_value={}): + self.assertEqual(wrap._wrap_info("doc", feature, expected_pull=expected)["pull_reference_confirmed"], confirmed) + + def test_edit_invalid_requests_fail_before_com(self): + for args in ({"name": "Wrap"}, {"name": "Wrap", "thickness_mm": 0}, {"name": "Wrap", "mode": "bad"}, + {"name": "Wrap", "reverse_direction": 1}, {"name": "Wrap", "clear_pull_direction": False}, + {"name": "Wrap", "pull_selection": {"planes": ["right"]}, "clear_pull_direction": True}): + with patch.object(wrap, "require_part") as part: + with self.assertRaises(RuntimeError): wrap.set_wrap_parameters(args) + part.assert_not_called() + + def test_edit_converts_units_and_checks_rolled_back_geometry(self): + events = [] + definition = SimpleNamespace(AccessSelections=lambda *a: events.append("access") or True, + ReleaseSelectionAccess=lambda: events.append("release")) + feature = SimpleNamespace(GetDefinition=lambda: definition, + ModifyDefinition=lambda *a: events.append("modify") or True) + info = {"mode": "emboss", "thickness_mm": 2, "reverse_direction": False, "has_pull_direction": False, + "solid_volume_mm3": 188, "solid_face_count": 8} + with ExitStack() as stack: + for name, returned in {"require_part": (None, "doc"), "find_feature": feature, "_wrap_info": info, + "whats_wrong": []}.items(): + stack.enter_context(patch.object(wrap, name, return_value=returned)) + for name in ("clear_selection", "exit_active_sketch", "rebuild"): + stack.enter_context(patch.object(wrap, name)) + stack.enter_context(patch.object(wrap, "_geometry", side_effect=lambda *a: events.append("baseline") or {"solid_volume_mm3": 100, "solid_face_count": 3})) + payload = wrap.set_wrap_parameters({"name": "Wrap", "thickness_mm": 2}) + self.assertTrue(payload["ok"]) + self.assertEqual(definition.Thickness, .002) + self.assertEqual(events, ["access", "baseline", "modify"]) + self.assertEqual(payload["data"]["volume_change_from_input_mm3"], 88) + + def test_edit_exception_releases_selection_access(self): + released = Mock() + definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: released()) + feature = SimpleNamespace(GetDefinition=lambda: definition, ModifyDefinition=Mock(side_effect=RuntimeError("native failure"))) + with ExitStack() as stack: + for name, returned in {"require_part": (None, "doc"), "find_feature": feature, "_wrap_info": {"mode": "emboss"}, "_geometry": {}}.items(): + stack.enter_context(patch.object(wrap, name, return_value=returned)) + for name in ("clear_selection", "exit_active_sketch"): + stack.enter_context(patch.object(wrap, name)) + with self.assertRaisesRegex(RuntimeError, "native failure"): + wrap.set_wrap_parameters({"name": "Wrap", "thickness_mm": 2}) + released.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/tools/verify_install.py b/tools/verify_install.py index 0ae8a47..921fca9 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -208,6 +208,20 @@ async def call(name, arguments=None): raise RuntimeError("Installed MCP wrap volume differs.") if (await call("get_wrap_data", {"name": "InstalledWrap"}))["wrap"] != wrapped["wrap"]: raise RuntimeError("Installed MCP wrap definition differs.") + edited = await call("set_wrap_parameters", {"name": "InstalledWrap", "thickness_mm": 2}) + if not math.isclose(edited["volume_change_from_input_mm3"], 88, abs_tol=1e-6) or edited["wrap"]["thickness_mm"] != 2: + raise RuntimeError("Installed MCP wrap thickness edit differs.") + directed = await call("set_wrap_parameters", {"name": "InstalledWrap", "pull_selection": {"planes": ["right"]}}) + if not directed["wrap"]["pull_reference_confirmed"] or not math.isclose(directed["volume_change_from_input_mm3"], 79.9100175565, abs_tol=.001): + raise RuntimeError("Installed MCP wrap pull identity or geometry differs.") + await call("set_wrap_parameters", {"name": "InstalledWrap", "mode": "scribe"}) + await call("create_sketch", {"plane_name": "WrapPlane", "name": "MultiProfile"}) + await call("draw_rectangle", {"x1_mm": -25, "y1_mm": -4, "x2_mm": -5, "y2_mm": 4}) + await call("close_sketch") + indices = [f["index"] for f in (await call("list_faces"))["faces"] if f["surface_type"] == "cylinder"] + multi = await call("wrap_sketch", {"sketch_name": "MultiProfile", "face_indices": indices, "method": "spline", "name": "InstalledMultiWrap"}) + if len(indices) != 2 or not math.isclose(multi["volume_change_mm3"], 168, abs_tol=.1): + raise RuntimeError("Installed MCP multiple-face wrap geometry differs.") finally: if wrap_title: await call("close_document", {"name": wrap_title, "discard_changes": True}) From 5a5df09805d69dc15d0d46742f8e3f3180d7150e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:51:29 +0800 Subject: [PATCH 19/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8C=85=E8=A6=86?= =?UTF-8?q?=E6=BA=90=E8=8D=89=E5=9B=BE=E6=9B=BF=E6=8D=A2=E5=B9=B6=E6=A0=B8?= =?UTF-8?q?=E9=AA=8C=E5=8E=9F=E7=94=9F=E7=9B=AE=E6=A0=87=E9=9D=A2=E5=BC=95?= =?UTF-8?q?=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 12 +++- README.md | 2 +- solidworks_mcp/sw_wrap.py | 63 ++++++++++++++++++--- tests/live_wrap_references_regression.py | 70 ++++++++++++++++++++++++ tests/test_wrap.py | 54 +++++++++++++++++- tools/verify_install.py | 10 ++++ 6 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 tests/live_wrap_references_regression.py diff --git a/COVERAGE.md b/COVERAGE.md index 2de9333..ec4a79c 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -64,9 +64,19 @@ IWrapSketchFeatureData 不暴露方法、网格精度和全部目标面,查询 误判方向丢失。厚度从 1 改为 2 mm、模式互换、指定新方向均已重建并核验体积。 反向参数虽能保存,但在本机案例的体积和包覆位置未变化,仍不能宣称几何反向生效。 清除方向的 null dispatch 被原生接口接受,却仍保留旧方向;普通 None、空/Null VARIANT -被拒绝。两项均按未确认状态返回失败并保留特征。修改源草图、目标面、方法和网格 +被拒绝。两项均按未确认状态返回失败并保留特征。修改目标面、方法和网格 以及其他复杂曲面、旧版软件验证仍保留为覆盖缺口。多面创建要求现代接口,旧接口 不能降级丢弃多面或样条参数。 + +新增源草图替换与原生单 `Face` 字段的前置几何读回;`SourceSketch` 必须传入 +`Sketch`,不能用 `Feature` 代替。原生持久引用的 memoryview 被复制为字节快照, +在回滚/重建后核验实际引用。八个新建双实体零件验证解析/样条三模式下的窄/宽 +草图切换;解析宽草图的凸起/凹陷为 +84/-76 mm³,刻划保持体积,另一个实体保持。 +目标面 setter 虽立即接受新引用,重建后仍读回旧面;旧接口也存在相同行为。 +同时改源草图与目标面时可能只有源草图生效,工具分别报告引用确认状态并返回失败。 +连接的阶梯圆柱也验证了同一实体上的面与草图联合修改:实际包覆转到小圆柱区域, +但 `Face` 字段仍是旧面;不能把该字段当作现代包覆全部影响面的清单。 +多面替换、目标面引用保存、后置草图/复杂依赖、方法/网格修改和前述原生缺口仍待补齐。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回。 diff --git a/README.md b/README.md index edfc8f9..355daa1 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `mid_surface` / `get_mid_surface_data` | 原生自动中面与只读检查;读取面配对、厚度、面积、位置和缝合结果。非零位置未生效时返回失败。 | | `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | | `boundary_surface` / `get_boundary_feature_data` | 单/双方向有序曲线的边界曲面、按方向修剪、原生形成实体选项及只读检查;相切/曲率控制按边界采样核验几何,未确认时保留特征并返回失败。 | -| `wrap_sketch` / `get_wrap_data` / `set_wrap_parameters` | 单/多实体面的包覆凸起、凹陷与刻划,解析/样条方法,基准面/草图线/直边拉伸方向及厚度/模式/方向修改。以体积、面分割、原生定义和持久引用验证。方法/网格精度不可读回;反向几何和清除方向未生效时返回失败。 | +| `wrap_sketch` / `get_wrap_data` / `set_wrap_parameters` | 单/多实体面的包覆三模式、解析/样条方法、三类方向及厚度/模式/方向/源草图修改;读回原生 `Face` 字段的前置几何。以体积、面分割、原生定义和持久引用快照验证。方法/网格不可读回;反向、清除方向及目标面修改未确认时返回失败。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | diff --git a/solidworks_mcp/sw_wrap.py b/solidworks_mcp/sw_wrap.py index 86a1843..3420b91 100644 --- a/solidworks_mcp/sw_wrap.py +++ b/solidworks_mcp/sw_wrap.py @@ -8,7 +8,7 @@ from .sw_core import ( as_list, clear_selection, exit_active_sketch, feature_manager, find_feature, flag_methods, get_bodies, nothing, require_part, require_selection, - result, safe, tool, value, SELECTION_SCHEMA, rebuild, whats_wrong, + result, safe, tool, value, SELECTION_SCHEMA, rebuild, whats_wrong, _surface_details, ) from .sw_feature import _feature_names, _feature_created_after from .sw_multibody import _finish @@ -83,10 +83,20 @@ def _geometry(doc): def _reference_id(doc, obj): - return tuple(as_list(flag_methods(value(doc, "Extension"), "GetPersistReference3").GetPersistReference3(obj))) + raw = flag_methods(value(doc, "Extension"), "GetPersistReference3").GetPersistReference3(obj) + # pywin32 can return a memoryview; freeze the native buffer before rollback/rebuild. + return bytes(raw) if raw is not None else b"" -def _wrap_info(doc, feature, expected_pull=None): +def _target_details(face): + if face is None: + return None + return {**_surface_details(value(face, "GetSurface")), + "area_mm2": float(value(face, "GetArea")) * 1e6, + "box_mm": [float(c) * 1000 for c in value(face, "GetBox")]} + + +def _wrap_info(doc, feature, expected_pull=None, expected_refs=None): if feature is None or safe(feature, "GetTypeName2") != "Emboss": raise RuntimeError("Specify an existing native wrap feature.") definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") @@ -94,18 +104,24 @@ def _wrap_info(doc, feature, expected_pull=None): raise RuntimeError("Cannot access wrap feature selections.") try: source = value(definition, "SourceSketch") + face = value(definition, "Face") mode = int(value(definition, "Type")) pull = value(definition, "PullDirection") info = {"mode": next((k for k, v in MODES.items() if v == mode), "unknown"), "native_type": mode, "thickness_mm": float(value(definition, "Thickness")) * 1000, "reverse_direction": bool(value(definition, "ReverseDirection")), "source_sketch": str(value(source, "Name")) if source is not None else None, - "has_target_face": value(definition, "Face") is not None, + "has_target_face": face is not None, + "target_face": _target_details(face), "has_pull_direction": pull is not None, "unreadable_parameters": ["method", "mesh_factor", "all_target_faces"]} if expected_pull is not None: expected_id = _reference_id(doc, expected_pull) info["pull_reference_confirmed"] = bool(expected_id) and pull is not None and _reference_id(doc, pull) == expected_id + for key, obj in (("source", source), ("target", face)): + if expected_refs and key in expected_refs: + expected_id = expected_refs[key] + info[f"{key}_reference_confirmed"] = bool(expected_id) and obj is not None and _reference_id(doc, obj) == expected_id finally: value(definition, "ReleaseSelectionAccess") # AccessSelections rolls back the feature. Read geometry only after releasing it. @@ -123,7 +139,7 @@ def _effect(info, before): abs(delta) <= tol and info["solid_face_count"] > before["solid_face_count"]) -@tool("get_wrap_data", "Read native wrap mode, thickness in mm, reverse state, source sketch, target/pull presence and current solid geometry. Native IWrapSketchFeatureData cannot expose method, mesh factor or all target faces; these are explicitly listed as unreadable.", +@tool("get_wrap_data", "Read native wrap mode, thickness in mm, reverse state, source sketch, the native single target-face geometry before the wrap, pull presence and current solid geometry. Native IWrapSketchFeatureData cannot expose method, mesh factor or all target faces; these are explicitly listed as unreadable. The legacy Face member does not necessarily describe every face affected by modern wrapping.", {"name": {"type": "string", "minLength": 1}}, ["name"]) def get_wrap_data(args): _, doc = require_part() @@ -182,19 +198,25 @@ def wrap_sketch(args): clear_selection(doc) -@tool("set_wrap_parameters", "Modify native wrap mode, thickness in mm, reverse state or pull direction and rebuild dependent features. clear_pull_direction attempts to clear the reference, but this native option remained unchanged on the tested build and readback reports failure. Reference identity and readable parameters are checked; geometric effect is checked against the model rolled back before the wrap. Method, mesh, source sketch and target faces are not modified. Reverse-direction geometry remains unconfirmed: requesting true reports failure and retains the modified feature.", +@tool("set_wrap_parameters", "Modify native wrap mode, thickness in mm, reverse state, source_sketch_name, single target_face_index or pull direction and rebuild dependent features. Source sketches are resolved to native Sketch objects, not Feature objects. Target indices refer to the current solid topology before editing. Source/target references are frozen before rollback and checked after rebuilding. The target Face setter and clear_pull_direction remained unchanged on the tested build and report failure when readback differs. Geometry is checked against the input model. Method, mesh and multiple target-face replacement are not modified. Reverse-direction geometry remains unconfirmed: requesting true reports failure with the modified feature retained.", {"name": {"type": "string", "minLength": 1}, "mode": {"type": "string", "enum": list(MODES)}, "thickness_mm": {"type": "number", "minimum": .01, "maximum": 1e7}, "reverse_direction": {"type": "boolean"}, "pull_selection": PULL_SCHEMA, + "source_sketch_name": {"type": "string", "minLength": 1}, + "target_face_index": {"type": "integer", "minimum": 0}, "clear_pull_direction": {"type": "boolean"}}, ["name"]) def set_wrap_parameters(args): - keys = ("mode", "thickness_mm", "reverse_direction", "pull_selection", "clear_pull_direction") + keys = ("mode", "thickness_mm", "reverse_direction", "pull_selection", "source_sketch_name", "target_face_index", "clear_pull_direction") if not any(k in args for k in keys) or args.get("clear_pull_direction") is False and not any(k in args for k in keys[:-1]): raise RuntimeError("Specify at least one wrap parameter to change.") if "mode" in args and args["mode"] not in MODES: raise RuntimeError("Unknown wrap mode.") if "thickness_mm" in args and (not math.isfinite(float(args["thickness_mm"])) or not .01 <= float(args["thickness_mm"]) <= 1e7): raise RuntimeError("Invalid wrap thickness_mm.") + if "source_sketch_name" in args and (not isinstance(args["source_sketch_name"], str) or not args["source_sketch_name"].strip()): + raise RuntimeError("A nonempty source_sketch_name is required.") + if "target_face_index" in args and (isinstance(args["target_face_index"], bool) or not isinstance(args["target_face_index"], int) or args["target_face_index"] < 0): + raise RuntimeError("target_face_index must be a nonnegative solid-face index.") for k in ("reverse_direction", "clear_pull_direction"): if k in args and not isinstance(args[k], bool): raise RuntimeError(f"{k} must be boolean.") @@ -208,6 +230,26 @@ def set_wrap_parameters(args): exit_active_sketch(doc) feature = find_feature(doc, args["name"]) previous = _wrap_info(doc, feature) + references = {} + expected_refs = {} + if "source_sketch_name" in args: + source_feature = find_feature(doc, args["source_sketch_name"]) + if source_feature is None or safe(source_feature, "GetTypeName2") != "ProfileFeature": + raise RuntimeError("The source must be an existing 2D sketch feature.") + references["SourceSketch"] = value(source_feature, "GetSpecificFeature2") + expected_refs["source"] = _reference_id(doc, references["SourceSketch"]) + if not expected_refs["source"]: + raise RuntimeError("Could not snapshot the source sketch persistent reference.") + if "target_face_index" in args: + try: + require_selection(doc, {"faces": [args["target_face_index"]]}, mark=1) + manager = flag_methods(value(doc, "SelectionManager"), "GetSelectedObject6") + references["Face"] = manager.GetSelectedObject6(1, 1) + expected_refs["target"] = _reference_id(doc, references["Face"]) + if not expected_refs["target"]: + raise RuntimeError("Could not snapshot the target face persistent reference.") + finally: + clear_selection(doc) if "pull_selection" in args and args.get("mode", previous["mode"]) == "scribe": raise RuntimeError("Pull direction is not applicable to scribe.") definition = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "ReleaseSelectionAccess") @@ -217,6 +259,8 @@ def set_wrap_parameters(args): pull = None try: baseline = _geometry(doc) + for member, obj in references.items(): + setattr(definition, member, obj) for key, member in (("mode", "Type"), ("thickness_mm", "Thickness"), ("reverse_direction", "ReverseDirection")): if key in args: raw = MODES[args[key]] if key == "mode" else float(args[key]) / 1000 if key == "thickness_mm" else args[key] @@ -234,10 +278,13 @@ def set_wrap_parameters(args): clear_selection(doc) rebuild(doc) problems = whats_wrong(doc) - info = _wrap_info(doc, feature, expected_pull=pull) + info = _wrap_info(doc, feature, expected_pull=pull, expected_refs=expected_refs) correct = all((math.isclose(info[k], float(args[k]), abs_tol=1e-7) if k == "thickness_mm" else info[k] == args[k]) for k in ("mode", "thickness_mm", "reverse_direction") if k in args) correct = correct and (pull is None or info["pull_reference_confirmed"]) and (not args.get("clear_pull_direction") or not info["has_pull_direction"]) + correct = correct and all(info[f"{key}_reference_confirmed"] for key in expected_refs) + if "source_sketch_name" in args: + correct = correct and info["source_sketch"] == args["source_sketch_name"] delta, geometry = _effect(info, baseline) reverse_confirmed = not info["reverse_direction"] ok = accepted and correct and geometry and reverse_confirmed and not problems diff --git a/tests/live_wrap_references_regression.py b/tests/live_wrap_references_regression.py new file mode 100644 index 0000000..92040af --- /dev/null +++ b/tests/live_wrap_references_regression.py @@ -0,0 +1,70 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Source Sketch replacement and target-reference readback on scratch parts.""" +import math +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS, require_part, get_bodies, flag_methods, value + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + def scene(): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + for name, x, radius in (("Main", 0, 10), ("Other", 30, 5)): + call("create_sketch", plane="front", name=name) + call("draw_circle", x_mm=x, y_mm=0, radius_mm=radius); call("close_sketch") + call("boss_extrude", sketch_name=name, depth_mm=30, merge=False, name=name + "Body") + call("create_plane", mode="offset", selection={"planes": ["right"]}, distance_mm=10, name="WrapPlane") + for name, half_width in (("Narrow", 2), ("Wide", 4)): + call("create_sketch", plane_name="WrapPlane", name=name) + call("draw_rectangle", x1_mm=-20, y1_mm=-half_width, x2_mm=-10, y2_mm=half_width) + call("close_sketch") + return next(f["index"] for f in call("list_faces")["faces"] if f.get("radius_mm") == 10) + try: + for method in ("analytical", "spline"): + for mode, narrow, wide in (("emboss", 42, 84), ("engrave", -38, -76), ("scribe", 0, 0)): + index = scene() + initial = call("wrap_sketch", sketch_name="Narrow", face_index=index, mode=mode, method=method, name="Editable") + target = initial["wrap"]["target_face"] + assert target["surface_type"] == "cylinder" and target["radius_mm"] == 10 + assert math.isclose(target["area_mm2"], 2 * math.pi * 10 * 30, abs_tol=1e-6) + for name, expected in (("Wide", wide), ("Narrow", narrow)): + data = call("set_wrap_parameters", name="Editable", source_sketch_name=name) + assert data["parameters_confirmed"] and data["geometry_confirmed"] + assert data["wrap"]["source_reference_confirmed"] and data["wrap"]["source_sketch"] == name + assert abs(data["volume_change_from_input_mm3"] - expected) < .1 + queried = call("get_wrap_data", name="Editable")["wrap"] + assert queried["source_sketch"] == name and queried["target_face"] == target + assert queried["solid_body_count"] == 2 + _, doc = require_part() + other = next(b for b in get_bodies(doc) if value(b, "Name") == "OtherBody") + other_volume = float(flag_methods(other, "GetMassProperties").GetMassProperties(1.)[3]) * 1e9 + assert math.isclose(other_volume, math.pi * 25 * 30, abs_tol=1e-6) + print("SOURCE", method, mode, name, data["volume_change_from_input_mm3"], flush=True) + for combined in (False, True): + index = scene() + call("wrap_sketch", sketch_name="Narrow", face_index=index, name="Editable") + target = next(f["index"] for f in call("list_faces")["faces"] if f.get("radius_mm") == 5) + args = {"name": "Editable", "target_face_index": target} + if combined: args["source_sketch_name"] = "Wide" + payload = HANDLERS["set_wrap_parameters"](args) + assert not payload["ok"] and not payload["data"]["parameters_confirmed"] + assert not payload["data"]["wrap"]["target_reference_confirmed"] + assert payload["data"]["wrap"]["target_face"]["radius_mm"] == 10 + if combined: + assert payload["data"]["wrap"]["source_reference_confirmed"] + print("TARGET remains unchanged; partial source edit is reported accurately", combined, flush=True) + print("Source edits in three modes and two methods, original-target geometry, two bodies and ignored target edits verified on eight scratch parts.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_wrap.py b/tests/test_wrap.py index ee0a7b3..2806a67 100644 --- a/tests/test_wrap.py +++ b/tests/test_wrap.py @@ -10,6 +10,8 @@ from solidworks_mcp import sw_wrap as wrap ARGS = {"sketch_name": "Profile", "face_index": 0} +FACE = SimpleNamespace(GetSurface=lambda: SimpleNamespace(Identity=lambda: 4001, PlaneParams=[0, 0, 1, 0, 0, 0]), + GetArea=lambda: .0001, GetBox=lambda: [0, 0, 0, .01, .01, 0]) class WrapContracts(unittest.TestCase): @@ -101,7 +103,7 @@ def test_geometry_read_after_releasing_rollback(self): events = [] definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: events.append("release"), SourceSketch=SimpleNamespace(Name="Profile"), Type=2, Thickness=.001, - ReverseDirection=False, Face=object(), PullDirection=None) + ReverseDirection=False, Face=FACE, PullDirection=None) feature = SimpleNamespace(GetTypeName2=lambda: "Emboss", GetDefinition=lambda: definition, GetFaces=lambda: []) with patch.object(wrap, "_geometry", side_effect=lambda d: events.append("geometry") or {}): info = wrap._wrap_info("doc", feature) @@ -113,7 +115,7 @@ def test_pull_identity_uses_nonempty_native_persistent_ids(self): actual, expected = object(), object() definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: None, SourceSketch=SimpleNamespace(Name="Profile"), Type=0, Thickness=.001, - ReverseDirection=False, Face=object(), PullDirection=actual) + ReverseDirection=False, Face=FACE, PullDirection=actual) feature = SimpleNamespace(GetTypeName2=lambda: "Emboss", GetDefinition=lambda: definition, GetFaces=lambda: []) for refs, confirmed in (([(1, 2), (1, 2)], True), ([(1, 2), (2, 1)], False), ([()], False)): with patch.object(wrap, "_reference_id", side_effect=refs), patch.object(wrap, "_geometry", return_value={}): @@ -122,7 +124,9 @@ def test_pull_identity_uses_nonempty_native_persistent_ids(self): def test_edit_invalid_requests_fail_before_com(self): for args in ({"name": "Wrap"}, {"name": "Wrap", "thickness_mm": 0}, {"name": "Wrap", "mode": "bad"}, {"name": "Wrap", "reverse_direction": 1}, {"name": "Wrap", "clear_pull_direction": False}, - {"name": "Wrap", "pull_selection": {"planes": ["right"]}, "clear_pull_direction": True}): + {"name": "Wrap", "pull_selection": {"planes": ["right"]}, "clear_pull_direction": True}, + {"name": "Wrap", "source_sketch_name": " "}, {"name": "Wrap", "target_face_index": True}, + {"name": "Wrap", "target_face_index": -1}): with patch.object(wrap, "require_part") as part: with self.assertRaises(RuntimeError): wrap.set_wrap_parameters(args) part.assert_not_called() @@ -161,6 +165,50 @@ def test_edit_exception_releases_selection_access(self): wrap.set_wrap_parameters({"name": "Wrap", "thickness_mm": 2}) released.assert_called_once() + def test_persistent_reference_freezes_native_memoryview(self): + buffer = bytearray([1, 2, 3]) + doc = SimpleNamespace(Extension=SimpleNamespace(GetPersistReference3=lambda obj: memoryview(buffer))) + snapshot = wrap._reference_id(doc, object()) + buffer[0] = 9 + self.assertEqual(snapshot, b"\x01\x02\x03") + + def test_source_edit_passes_sketch_object_and_verifies_frozen_reference(self): + definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: None) + feature = SimpleNamespace(GetDefinition=lambda: definition, ModifyDefinition=lambda *a: True) + sketch = object() + source_feature = SimpleNamespace(GetTypeName2=lambda: "ProfileFeature", GetSpecificFeature2=lambda: sketch) + info = {"mode": "emboss", "source_sketch": "Wide", "source_reference_confirmed": True, + "reverse_direction": False, "solid_volume_mm3": 184, "solid_face_count": 8} + with ExitStack() as stack: + stack.enter_context(patch.object(wrap, "find_feature", side_effect=[feature, source_feature])) + for name, returned in {"require_part": (None, "doc"), "_wrap_info": info, "_reference_id": b"source", + "_geometry": {"solid_volume_mm3": 100, "solid_face_count": 3}, "whats_wrong": []}.items(): + stack.enter_context(patch.object(wrap, name, return_value=returned)) + for name in ("clear_selection", "exit_active_sketch", "rebuild"): + stack.enter_context(patch.object(wrap, name)) + payload = wrap.set_wrap_parameters({"name": "Wrap", "source_sketch_name": "Wide"}) + self.assertIs(definition.SourceSketch, sketch) + self.assertTrue(payload["ok"]) + + def test_ignored_target_edit_cannot_report_success(self): + definition = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: None) + feature = SimpleNamespace(GetDefinition=lambda: definition, ModifyDefinition=lambda *a: True) + target = object() + doc = SimpleNamespace(SelectionManager=SimpleNamespace(GetSelectedObject6=lambda *a: target)) + info = {"mode": "emboss", "reverse_direction": False, "target_reference_confirmed": False, + "solid_volume_mm3": 142, "solid_face_count": 8} + with ExitStack() as stack: + for name, returned in {"require_part": (None, doc), "find_feature": feature, "_wrap_info": info, + "_reference_id": b"target", "require_selection": 1, "whats_wrong": [], + "_geometry": {"solid_volume_mm3": 100, "solid_face_count": 3}}.items(): + stack.enter_context(patch.object(wrap, name, return_value=returned)) + for name in ("clear_selection", "exit_active_sketch", "rebuild"): + stack.enter_context(patch.object(wrap, name)) + payload = wrap.set_wrap_parameters({"name": "Wrap", "target_face_index": 1}) + self.assertIs(definition.Face, target) + self.assertFalse(payload["ok"]) + self.assertFalse(payload["data"]["parameters_confirmed"]) + if __name__ == "__main__": unittest.main() diff --git a/tools/verify_install.py b/tools/verify_install.py index 921fca9..5d39c0b 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -202,12 +202,22 @@ async def call(name, arguments=None): await call("create_sketch", {"plane_name": "WrapPlane", "name": "WrapProfile"}) await call("draw_rectangle", {"x1_mm": -20, "y1_mm": -2, "x2_mm": -10, "y2_mm": 2}) await call("close_sketch") + await call("create_sketch", {"plane_name": "WrapPlane", "name": "WideWrapProfile"}) + await call("draw_rectangle", {"x1_mm": -20, "y1_mm": -4, "x2_mm": -10, "y2_mm": 4}) + await call("close_sketch") index = next(f["index"] for f in (await call("list_faces"))["faces"] if f["surface_type"] == "cylinder") wrapped = await call("wrap_sketch", {"sketch_name": "WrapProfile", "face_index": index, "name": "InstalledWrap"}) if not math.isclose(wrapped["volume_change_mm3"], 42, abs_tol=1e-6): raise RuntimeError("Installed MCP wrap volume differs.") if (await call("get_wrap_data", {"name": "InstalledWrap"}))["wrap"] != wrapped["wrap"]: raise RuntimeError("Installed MCP wrap definition differs.") + source_edit = await call("set_wrap_parameters", {"name": "InstalledWrap", "source_sketch_name": "WideWrapProfile"}) + if not source_edit["wrap"]["source_reference_confirmed"] or not math.isclose(source_edit["volume_change_from_input_mm3"], 84, abs_tol=1e-6): + raise RuntimeError("Installed MCP wrap source reference or geometry differs.") + target_data = source_edit["wrap"]["target_face"] + if target_data.get("radius_mm") != 10 or not math.isclose(target_data["area_mm2"], 600 * math.pi, abs_tol=1e-6): + raise RuntimeError("Installed MCP wrap original-target geometry differs.") + await call("set_wrap_parameters", {"name": "InstalledWrap", "source_sketch_name": "WrapProfile"}) edited = await call("set_wrap_parameters", {"name": "InstalledWrap", "thickness_mm": 2}) if not math.isclose(edited["volume_change_from_input_mm3"], 88, abs_tol=1e-6) or edited["wrap"]["thickness_mm"] != 2: raise RuntimeError("Installed MCP wrap thickness edit differs.") From 90d0b3df146e700f4a21974d67146f5d2bb48e18 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:25:47 +0800 Subject: [PATCH 20/31] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=9C=86=E9=A1=B6=E5=B7=A5=E5=85=B7=E5=B9=B6=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?=E5=BD=A2=E7=8A=B6=E5=92=8C=E5=8F=82=E6=95=B0=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 10 +- README.md | 3 +- solidworks_mcp/server.py | 1 + solidworks_mcp/sw_dome.py | 261 ++++++++++++++++++++++++++++++++++ tests/live_dome_regression.py | 102 +++++++++++++ tests/test_dome.py | 112 +++++++++++++++ tools/verify_install.py | 21 +++ 7 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 solidworks_mcp/sw_dome.py create mode 100644 tests/live_dome_regression.py create mode 100644 tests/test_dome.py diff --git a/COVERAGE.md b/COVERAGE.md index ec4a79c..c9fe5bc 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 174 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 177 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 @@ -174,6 +174,14 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 ## 原生 API 清点 +圆顶新增创建、定义查询与参数编辑 3 个工具。创建接口返回空时从特征树恢复 +`Dome`,随后验证原生定义、实际体积和中心高度。B-spline 包围盒会过度估计高度, +不作为高度成功依据。圆形和椭圆形边界的椭球模式另检查 24 个理论表面点, +距离容差为 0.01 mm;椭球原生曲面是近似表示,体积回归容差为 0.2%, +不能声称与理想椭球完全一致。草图点约束会使原生高度读回为 0,需检查约束点位置 +和实际曲面距离。复杂非平面、多面组合、整体草图约束、清除约束/方向及旧版软件 +仍待覆盖或专项验证。 + ```powershell python tools/build_api_inventory.py --official-modules --output D:/solidworks-mcp/.development/native-api-inventory.json ``` diff --git a/README.md b/README.md index 355daa1..cf33db2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -174 tools: sketching with real relations and driving dimensions, the solid +177 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -199,6 +199,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | | `boundary_surface` / `get_boundary_feature_data` | 单/双方向有序曲线的边界曲面、按方向修剪、原生形成实体选项及只读检查;相切/曲率控制按边界采样核验几何,未确认时保留特征并返回失败。 | | `wrap_sketch` / `get_wrap_data` / `set_wrap_parameters` | 单/多实体面的包覆三模式、解析/样条方法、三类方向及厚度/模式/方向/源草图修改;读回原生 `Face` 字段的前置几何。以体积、面分割、原生定义和持久引用快照验证。方法/网格不可读回;反向、清除方向及目标面修改未确认时返回失败。 | +| `dome` / `get_dome_data` / `set_dome_parameters` | 原生圆顶创建、定义与几何读取、高度/凹凸/椭球模式修改、目标面替换、草图点约束和方向边。以实际体积、中心高度、24 点椭球表面采样及持久引用核验;复杂面、方向导致零效果或无法确认椭球形状时返回失败并保留特征。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | diff --git a/solidworks_mcp/server.py b/solidworks_mcp/server.py index deed954..9f21ba8 100644 --- a/solidworks_mcp/server.py +++ b/solidworks_mcp/server.py @@ -63,6 +63,7 @@ from . import sw_multibody # noqa: F401 from . import sw_boundary # noqa: F401 from . import sw_wrap # noqa: F401 +from . import sw_dome # noqa: F401 from . import sw_sheetmetal # noqa: F401 from . import sw_weldment # noqa: F401 from . import sw_motion # noqa: F401 diff --git a/solidworks_mcp/sw_dome.py b/solidworks_mcp/sw_dome.py new file mode 100644 index 0000000..abe8e18 --- /dev/null +++ b/solidworks_mcp/sw_dome.py @@ -0,0 +1,261 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Native dome creation, geometry inspection and definition edits.""" +import math +from .sw_core import ( + SELECTION_SCHEMA, apply_transform, as_list, clear_selection, dispatch_array, + exit_active_sketch, find_feature, flag_methods, nothing, rebuild, require_part, + require_selection, result, safe, tool, value, whats_wrong, +) +from .sw_feature import _feature_names, _feature_created_after +from .sw_multibody import _finish +from .sw_wrap import _geometry, _indices, _reference_id, _target_details + +HEIGHT = {"type": "number", "exclusiveMinimum": 0} +FACES = {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 1, "uniqueItems": True} +CONSTRAINT = {"type": "object", "properties": {k: SELECTION_SCHEMA["properties"][k] for k in ("sketch_name", "sketch_points")}, + "required": ["sketch_name", "sketch_points"], "additionalProperties": False} + + +def _validate(args, creating=False): + if creating or "height_mm" in args: + height = float(args["height_mm"]) + if not math.isfinite(height) or height <= 0: + raise RuntimeError("Dome height_mm must be finite and positive.") + for k in ("reverse_direction", "elliptical"): + if k in args and not isinstance(args[k], bool): + raise RuntimeError(f"{k} must be boolean.") + if creating or "face_index" in args or "face_indices" in args: + _indices(args) + if "direction_edge_index" in args: + index = args["direction_edge_index"] + if isinstance(index, bool) or not isinstance(index, int) or index < 0: + raise RuntimeError("direction_edge_index must be a nonnegative solid-edge index.") + if "constraint_selection" in args: + spec = args["constraint_selection"] + if (not isinstance(spec, dict) or set(spec) != {"sketch_name", "sketch_points"} + or not isinstance(spec["sketch_name"], str) or not spec["sketch_name"].strip() + or not isinstance(spec["sketch_points"], list) or len(spec["sketch_points"]) != 1 + or isinstance(spec["sketch_points"][0], bool) or not isinstance(spec["sketch_points"][0], int) + or spec["sketch_points"][0] < 0): + raise RuntimeError("Select exactly one point from a named sketch as the dome constraint.") + if "height_mm" in args: + raise RuntimeError("The constraint point controls height; do not supply height_mm with constraint_selection.") + + +def _selected(doc, spec): + count = require_selection(doc, spec, mark=1) + manager = flag_methods(value(doc, "SelectionManager"), "GetSelectedObject6") + return [manager.GetSelectedObject6(i, 1) for i in range(1, count + 1)] + + +def _point_position(point): + sketch = value(point, "GetSketch") + inverse = value(value(sketch, "ModelToSketchTransform"), "Inverse") + return apply_transform([float(value(point, k)) for k in ("X", "Y", "Z")], value(inverse, "ArrayData")) + + +def _ellipsoid_queries(face, height, reverse): + edges = as_list(value(face, "GetEdges")) + if len(edges) != 1: + return None + curve = value(edges[0], "GetCurve") + normal = [float(c) for c in value(face, "Normal")] + if bool(value(curve, "IsCircle")): + params = value(curve, "CircleParams") + center, major, minor = list(params[:3]), float(params[6]), float(params[6]) + seed = [1, 0, 0] if abs(normal[0]) < .9 else [0, 1, 0] + a = [normal[1] * seed[2] - normal[2] * seed[1], normal[2] * seed[0] - normal[0] * seed[2], normal[0] * seed[1] - normal[1] * seed[0]] + length = math.sqrt(sum(c * c for c in a)); a = [c / length for c in a] + b = [normal[1] * a[2] - normal[2] * a[1], normal[2] * a[0] - normal[0] * a[2], normal[0] * a[1] - normal[1] * a[0]] + elif bool(value(curve, "IsEllipse")): + params = value(curve, "GetEllipseParams") + center, major, minor = list(params[:3]), float(params[3]), float(params[7]) + a, b = list(params[4:7]), list(params[8:11]) + else: + return None + signed_height = height / 1000 * (-1 if reverse else 1) + return [[center[i] + a[i] * major * rho * math.cos(angle) + b[i] * minor * rho * math.sin(angle) + + normal[i] * signed_height * math.sqrt(1 - rho * rho) for i in range(3)] + for rho in (.25, .5, .75) for angle in [k * math.pi / 4 for k in range(8)]] + + +def _dome_info(doc, feature, expected=None): + if feature is None or safe(feature, "GetTypeName2") != "Dome": + raise RuntimeError("Specify an existing native dome feature.") + data = flag_methods(value(feature, "GetDefinition"), "AccessSelections", "GetFaceCount") + if not data.AccessSelections(doc, nothing()): + raise RuntimeError("Cannot access dome selections.") + probes, ellipsoid_queries = [], [] + try: + faces = as_list(value(data, "Faces")) + count = int(data.GetFaceCount()) + if len(faces) != count: + raise RuntimeError("Native dome face count and references differ.") + direction = value(data, "Direction") + constraint = value(data, "ConstraintPointOrSketch") + height, reverse = float(value(data, "Height")) * 1000, bool(value(data, "ReverseDir")) + info = {"height_mm": height, "reverse_direction": reverse, "elliptical": bool(value(data, "Elliptical")), + "input_face_count": count, "input_faces": [_target_details(f) for f in faces], + "has_direction": direction is not None, "has_constraint": constraint is not None, + "input_geometry": _geometry(doc)} + for face, meta in zip(faces, info["input_faces"]): + if meta["surface_type"] == "plane": + normal = [float(c) for c in value(face, "Normal")] + box = meta["box_mm"] + center = [(box[i] + box[i + 3]) / 2000 for i in range(3)] + probes.append((center, [center[i] + normal[i] * height / 1000 * (-1 if reverse else 1) for i in range(3)], normal)) + if info["elliptical"] and constraint is None: + ellipsoid_queries.append(_ellipsoid_queries(face, height, reverse) if meta["surface_type"] == "plane" else None) + if expected: + for key, obj in (("direction", direction), ("constraint", constraint)): + if key in expected: + info[f"{key}_reference_confirmed"] = obj is not None and bool(expected[key]) and _reference_id(doc, obj) == expected[key] + if "faces" in expected: + actual = [_reference_id(doc, f) for f in faces] + info["face_references_confirmed"] = bool(actual) and len(actual) == len(expected["faces"]) and set(actual) == set(expected["faces"]) + point = None + if constraint is not None and safe(constraint, "GetSketch") is not None: + point = _point_position(constraint) + info["constraint_point_mm"] = [c * 1000 for c in point] + finally: + value(data, "ReleaseSelectionAccess") + output = as_list(value(feature, "GetFaces")) + info.update(_geometry(doc), feature_faces=[_target_details(f) for f in output]) + samples = [] + for center, query, normal in probes: + candidates = [list(flag_methods(f, "GetClosestPointOn").GetClosestPointOn(*query)[:3]) for f in output] + if not candidates: + continue + closest = min(candidates, key=lambda q: sum((a - b) ** 2 for a, b in zip(q, query))) + samples.append({"point_mm": [c * 1000 for c in closest], + "normal_displacement_mm": sum((closest[i] - center[i]) * normal[i] for i in range(3)) * 1000}) + info["center_probes"] = samples + if point is not None: + distances = [math.dist(point, flag_methods(f, "GetClosestPointOn").GetClosestPointOn(*point)[:3]) * 1000 for f in output] + info["constraint_surface_gap_mm"] = min(distances) if distances else None + if info["elliptical"] and not info["has_constraint"]: + gaps = [] + supported = bool(ellipsoid_queries) and all(q is not None for q in ellipsoid_queries) and bool(output) + if supported: + for queries in ellipsoid_queries: + for query in queries: + gaps.append(min(math.dist(query, flag_methods(f, "GetClosestPointOn").GetClosestPointOn(*query)[:3]) * 1000 for f in output)) + info["ellipsoid_check"] = {"confirmed": supported and max(gaps) <= .01, + "maximum_surface_gap_mm": max(gaps) if gaps else None, + "sample_count": len(gaps)} + return info + + +def _geometry_confirmed(info): + before = info["input_geometry"]["solid_volume_mm3"] + delta = info["solid_volume_mm3"] - before + tolerance = max(1e-6, abs(before) * 1e-9) + signed = delta < -tolerance if info["reverse_direction"] else delta > tolerance + if info["has_constraint"]: + # A constrained point drives the shape, and native Height becomes zero. + height = info.get("constraint_surface_gap_mm") is not None and info["constraint_surface_gap_mm"] <= .01 + else: + sign = -1 if info["reverse_direction"] else 1 + height = bool(info["center_probes"]) and all(abs(p["normal_displacement_mm"] - sign * info["height_mm"]) <= .01 for p in info["center_probes"]) + return delta, signed and height and info.get("ellipsoid_check", {"confirmed": True})["confirmed"] + + +@tool("get_dome_data", "Read native dome height (mm), convex/concave and elliptical state, input faces, constraint/direction presence, input/current solid geometry and closest-point center probes. Native B-spline boxes are approximate and do not prove height. Point-constrained domes report native height zero and actual constraint position/gap.", + {"name": {"type": "string", "minLength": 1}}, ["name"]) +def get_dome_data(args): + _, doc = require_part() + return result(True, "Read dome definition and geometry.", dome=_dome_info(doc, find_feature(doc, args["name"]))) + + +@tool("dome", "Create a native dome on selected solid faces using mark 1. Height is mm; reverse_direction creates a concave dome, elliptical requests a half ellipsoid. Use face_index or face_indices. Checks native face count, parameters, actual signed volume, planar-face center height and 24-point half-ellipsoid surface gaps for conic boundaries. Native creation can reject disconnected multiple faces; no fallback drops faces. More complex/non-planar height verification remains pending and returns unconfirmed with the feature retained.", + {"face_index": {"type": "integer", "minimum": 0}, "face_indices": FACES, "height_mm": HEIGHT, + "reverse_direction": {"type": "boolean", "default": False}, "elliptical": {"type": "boolean", "default": False}, + "name": {"type": "string"}}, ["height_mm"]) +def dome(args): + _validate(args, creating=True) + _, doc = require_part() + exit_active_sketch(doc) + indices = _indices(args) + before = _feature_names(doc) + try: + require_selection(doc, {"faces": indices}, mark=1) + flag_methods(doc, "InsertDome").InsertDome(float(args["height_mm"]) / 1000, args.get("reverse_direction", False), args.get("elliptical", False)) + feature = _feature_created_after(doc, before) + payload = _finish(doc, feature, args, "dome") + if feature is None or not payload["ok"]: + return payload + info = _dome_info(doc, feature) + delta, geometry = _geometry_confirmed(info) + correct = (info["input_face_count"] == len(indices) and math.isclose(info["height_mm"], float(args["height_mm"]), abs_tol=1e-7) + and info["reverse_direction"] == args.get("reverse_direction", False) and info["elliptical"] == args.get("elliptical", False)) + payload["data"].update(dome=info, volume_change_mm3=delta, geometry_confirmed=geometry) + if not correct or not geometry: + payload.update(ok=False, message="Dome created, but native parameters or geometric effect could not be confirmed. Feature retained.") + return payload + finally: + clear_selection(doc) + + +@tool("set_dome_parameters", "Modify dome height, convex/concave state, elliptical state, target faces, a constraint sketch point or direction edge. Constraint and height are mutually exclusive: the point drives shape and native Height becomes zero. Checks frozen references, readback, signed volume and height/constraint geometry. On the tested build a direction edge can flatten the dome despite successful native readback; zero-effect edits return failure with the feature retained. Whole-sketch constraints and clearing controls are not implemented yet.", + {"name": {"type": "string", "minLength": 1}, "height_mm": HEIGHT, "reverse_direction": {"type": "boolean"}, + "elliptical": {"type": "boolean"}, "face_index": {"type": "integer", "minimum": 0}, "face_indices": FACES, + "constraint_selection": CONSTRAINT, "direction_edge_index": {"type": "integer", "minimum": 0}}, ["name"]) +def set_dome_parameters(args): + if not any(k in args for k in ("height_mm", "reverse_direction", "elliptical", "face_index", "face_indices", "constraint_selection", "direction_edge_index")): + raise RuntimeError("Specify at least one dome parameter to change.") + _validate(args) + _, doc = require_part() + exit_active_sketch(doc) + feature = find_feature(doc, args["name"]) + _dome_info(doc, feature) + references, expected = {}, {} + try: + if "face_index" in args or "face_indices" in args: + faces = _selected(doc, {"faces": _indices(args)}) + references["Faces"] = dispatch_array(faces) + expected["faces"] = [_reference_id(doc, f) for f in faces] + if "constraint_selection" in args: + point = _selected(doc, args["constraint_selection"])[0] + references["ConstraintPointOrSketch"] = point + expected["constraint"] = _reference_id(doc, point) + if "direction_edge_index" in args: + edge = _selected(doc, {"edges": [args["direction_edge_index"]]})[0] + if not bool(value(value(edge, "GetCurve"), "IsLine")): + raise RuntimeError("The dome direction edge must be linear.") + references["Direction"] = edge + expected["direction"] = _reference_id(doc, edge) + if any(not ref for refs in expected.values() for ref in (refs if isinstance(refs, list) else [refs])): + raise RuntimeError("Cannot snapshot dome reference identities.") + finally: + clear_selection(doc) + data = flag_methods(value(feature, "GetDefinition"), "AccessSelections") + if not data.AccessSelections(doc, nothing()): + return result(False, "Cannot access dome selections.") + accepted = False + try: + for member, obj in references.items(): + setattr(data, member, obj) + for key, member in (("height_mm", "Height"), ("reverse_direction", "ReverseDir"), ("elliptical", "Elliptical")): + if key in args: + setattr(data, member, float(args[key]) / 1000 if key == "height_mm" else args[key]) + accepted = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(data, doc, nothing())) + finally: + if not accepted: + value(data, "ReleaseSelectionAccess") + clear_selection(doc) + rebuild(doc) + info = _dome_info(doc, feature, expected) + delta, geometry = _geometry_confirmed(info) + correct = all((math.isclose(info[k], float(args[k]), abs_tol=1e-7) if k == "height_mm" else info[k] == args[k]) + for k in ("height_mm", "reverse_direction", "elliptical") if k in args) + correct = correct and all(info["face_references_confirmed" if k == "faces" else f"{k}_reference_confirmed"] for k in expected) + problems = whats_wrong(doc) + return result(accepted and correct and geometry and not problems, + "Updated dome parameters and verified geometry." if accepted and correct and geometry and not problems else + "Dome modification or geometric effect could not be confirmed. Feature retained.", + feature=args["name"], dome=info, parameters_confirmed=correct, geometry_confirmed=geometry, + volume_change_from_input_mm3=delta, problems=problems) diff --git a/tests/live_dome_regression.py b/tests/live_dome_regression.py new file mode 100644 index 0000000..3ea0284 --- /dev/null +++ b/tests/live_dome_regression.py @@ -0,0 +1,102 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Native domes, shape checks and definition edits on scratch parts.""" +import math +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS + + +def run(shapes=True): + original = call("list_open_documents")["active_title"] + scratch = [] + def scene(shape="circle", two=False): + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + call("create_sketch", plane="front", name="Base") + if shape == "rectangle": call("draw_rectangle", x1_mm=-10, y1_mm=-5, x2_mm=10, y2_mm=5) + elif shape == "ellipse": call("draw_ellipse", center_x_mm=0, center_y_mm=0, major_x_mm=10, major_y_mm=0, minor_x_mm=0, minor_y_mm=5) + else: call("draw_circle", x_mm=0, y_mm=0, radius_mm=10) + if two: call("draw_circle", x_mm=30, y_mm=0, radius_mm=5) + call("close_sketch"); call("boss_extrude", sketch_name="Base", depth_mm=30, merge=False) + return [f["index"] for f in call("list_faces")["faces"] if f.get("normal", [0, 0, 0])[2] > .99] + def near(actual, expected, tol=.2): + assert abs(actual - expected) < tol, (actual, expected) + try: + for shape in (("circle", "ellipse") if shapes else ()): + for reverse in (False, True): + for elliptical in (False, True) if shape == "circle" else (True,): + index = scene(shape)[0] + data = call("dome", face_index=index, height_mm=5, reverse_direction=reverse, elliptical=elliptical, name="Dome") + expected = (2 / 3 * math.pi * 10 * (10 if shape == "circle" else 5) * 5 if elliptical else math.pi * 5 * (300 + 25) / 6) + # Elliptical native surfaces are spline approximations; also + # require the independent 24-point surface-gap check below. + near(data["volume_change_mm3"], expected * (-1 if reverse else 1), + max(.2, expected * .002) if elliptical else .2) + assert data["geometry_confirmed"] + if elliptical: assert data["dome"]["ellipsoid_check"]["confirmed"] and data["dome"]["ellipsoid_check"]["sample_count"] == 24 + queried = call("get_dome_data", name="Dome")["dome"] + assert queried["height_mm"] == 5 and queried["reverse_direction"] == reverse + near(queried["solid_volume_mm3"], data["dome"]["solid_volume_mm3"], 1e-6) + print("SHAPE", shape, reverse, elliptical, data["volume_change_mm3"], + data["dome"].get("ellipsoid_check"), flush=True) + if shape == "circle" and not reverse and not elliptical: + for args, expected in (({"height_mm": 8}, math.pi * 8 * (300 + 64) / 6), + ({"elliptical": True}, 2 / 3 * math.pi * 100 * 8), + ({"reverse_direction": True}, -2 / 3 * math.pi * 100 * 8)): + edited = call("set_dome_parameters", name="Dome", **args) + near(edited["volume_change_from_input_mm3"], expected) + assert edited["parameters_confirmed"] and edited["geometry_confirmed"] + + index = scene("rectangle")[0] + call("create_plane", mode="offset", selection={"planes": ["front"]}, distance_mm=35, name="ApexPlane") + call("create_sketch", plane_name="ApexPlane", name="Apex") + call("draw_point", x_mm=3, y_mm=0); call("close_sketch") + data = call("dome", face_index=index, height_mm=5, name="RectDome") + near(data["dome"]["center_probes"][0]["normal_displacement_mm"], 5, .001) + # Native B-spline boxes overestimate the height; center samples remain exact. + assert data["dome"]["feature_faces"][0]["box_mm"][5] > 38 + constrained = call("set_dome_parameters", name="RectDome", constraint_selection={"sketch_name": "Apex", "sketch_points": [0]}) + assert constrained["dome"]["height_mm"] == 0 and constrained["dome"]["constraint_reference_confirmed"] + near(constrained["dome"]["constraint_point_mm"][2], 35, .001) + assert constrained["dome"]["constraint_surface_gap_mm"] < .01 + + index = scene("rectangle")[0] + call("dome", face_index=index, height_mm=5, name="FaceEdit") + bottom = next(f["index"] for f in call("list_faces")["faces"] if f.get("normal", [0, 0, 0])[2] < -.99) + edited = call("set_dome_parameters", name="FaceEdit", face_index=bottom) + assert edited["dome"]["face_references_confirmed"] and edited["geometry_confirmed"] + near(edited["dome"]["center_probes"][0]["point_mm"][2], -5, .001) + + index = scene("rectangle")[0] + call("dome", face_index=index, height_mm=5, name="DirectionEdit") + edge = next(e["index"] for e in call("list_edges")["edges"] if e.get("curve_type") == "line" and abs(e.get("direction", [0])[0]) > .99) + payload = HANDLERS["set_dome_parameters"]({"name": "DirectionEdit", "direction_edge_index": edge}) + print("DIRECTION", payload, flush=True) + assert payload["data"]["dome"]["has_direction"] + # Selecting an output edge can change its native persistent identity + # during rollback. Presence alone must never count as identity proof. + assert not payload["ok"] and not payload["data"]["geometry_confirmed"] + near(payload["data"]["volume_change_from_input_mm3"], 0, 1e-6) + + index = scene("rectangle")[0] + payload = HANDLERS["dome"]({"face_index": index, "height_mm": 5, "elliptical": True, "name": "UnconfirmedEllipsoid"}) + assert not payload["ok"] and not payload["data"]["dome"]["ellipsoid_check"]["confirmed"] + indices = scene(two=True) + assert len(indices) == 2 + payload = HANDLERS["dome"]({"face_indices": indices, "height_mm": 5, "name": "Disconnected"}) + assert not payload["ok"] + print("Dome regression passed; scratch parts:", len(scratch), "shape cases:", shapes, flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + import argparse + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--controls-only", action="store_true") + run(shapes=not parser.parse_args().controls_only) diff --git a/tests/test_dome.py b/tests/test_dome.py new file mode 100644 index 0000000..89422c1 --- /dev/null +++ b/tests/test_dome.py @@ -0,0 +1,112 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +import unittest +from solidworks_mcp import sw_dome as dome + + +def geometry(**changes): + return {"input_geometry": {"solid_volume_mm3": 100}, "solid_volume_mm3": 110, + "reverse_direction": False, "has_constraint": False, "height_mm": 5, + "center_probes": [{"normal_displacement_mm": 5}], **changes} + + +class DomeContracts(unittest.TestCase): + def test_invalid_inputs_do_not_access_com(self): + for changes in ({"height_mm": 0}, {"height_mm": float("nan")}, {"face_index": -1}, + {"face_indices": [0, 0]}, {"reverse_direction": 1}, {"elliptical": 1}, + {"direction_edge_index": True}, + {"constraint_selection": {"sketch_name": "Apex", "sketch_points": [0]}}): + with patch.object(dome, "require_part") as part: + with self.assertRaises(RuntimeError): + dome.dome({"height_mm": 5, "face_index": 0, **changes}) + part.assert_not_called() + + def test_void_creation_recovers_feature_and_uses_native_units(self): + create = Mock(return_value=None) + doc = SimpleNamespace(InsertDome=create) + info = geometry(input_face_count=1, elliptical=False) + with ExitStack() as stack: + for name, returned in (("require_part", (None, doc)), ("_feature_names", []), + ("_feature_created_after", "recovered"), ("_dome_info", info)): + stack.enter_context(patch.object(dome, name, return_value=returned)) + for name in ("exit_active_sketch", "clear_selection"): + stack.enter_context(patch.object(dome, name)) + select = stack.enter_context(patch.object(dome, "require_selection")) + stack.enter_context(patch.object(dome, "_finish", return_value={"ok": True, "data": {"feature": "recovered"}})) + payload = dome.dome({"height_mm": 5, "face_index": 2}) + create.assert_called_once_with(.005, False, False) + select.assert_called_once_with(doc, {"faces": [2]}, mark=1) + self.assertTrue(payload["ok"]) + + def test_zero_effect_and_incorrect_height_are_unconfirmed(self): + for info in (geometry(solid_volume_mm3=100), geometry(center_probes=[]), + geometry(center_probes=[{"normal_displacement_mm": 8.888}]), + geometry(ellipsoid_check={"confirmed": False})): + self.assertFalse(dome._geometry_confirmed(info)[1]) + + def test_concave_requires_negative_volume_and_displacement(self): + self.assertTrue(dome._geometry_confirmed(geometry(reverse_direction=True, solid_volume_mm3=90, + center_probes=[{"normal_displacement_mm": -5}]))[1]) + self.assertFalse(dome._geometry_confirmed(geometry(reverse_direction=True))[1]) + + def test_constraint_controls_height_when_native_height_is_zero(self): + for gap, expected in ((.001, True), (.1, False), (None, False)): + self.assertEqual(dome._geometry_confirmed(geometry(has_constraint=True, height_mm=0, + constraint_surface_gap_mm=gap))[1], expected) + + def test_rollback_is_released_before_output_geometry(self): + events = [] + data = SimpleNamespace(AccessSelections=lambda *a: True, GetFaceCount=lambda: 0, Faces=[], + Height=.005, ReverseDir=False, Elliptical=False, Direction=None, + ConstraintPointOrSketch=None, ReleaseSelectionAccess=lambda: events.append("release")) + feature = SimpleNamespace(GetTypeName2=lambda: "Dome", GetDefinition=lambda: data, GetFaces=lambda: []) + with patch.object(dome, "_geometry", side_effect=lambda d: events.append("geometry") or {}): + dome._dome_info("doc", feature) + self.assertEqual(events, ["geometry", "release", "geometry"]) + + def test_face_count_error_still_releases_rollback(self): + released = Mock() + data = SimpleNamespace(AccessSelections=lambda *a: True, GetFaceCount=lambda: 1, + Faces=[], ReleaseSelectionAccess=lambda: released()) + feature = SimpleNamespace(GetTypeName2=lambda: "Dome", GetDefinition=lambda: data) + with self.assertRaisesRegex(RuntimeError, "references differ"): + dome._dome_info("doc", feature) + released.assert_called_once() + + def test_unsupported_boundary_cannot_prove_ellipsoid(self): + self.assertIsNone(dome._ellipsoid_queries(SimpleNamespace(GetEdges=lambda: []), 5, False)) + + def test_failed_edit_releases_native_selection_access(self): + released = Mock() + data = SimpleNamespace(AccessSelections=lambda *a: True, ReleaseSelectionAccess=lambda: released()) + feature = SimpleNamespace(GetDefinition=lambda: data, ModifyDefinition=lambda *a: False) + info = geometry() + with ExitStack() as stack: + for name, returned in (("require_part", (None, "doc")), ("find_feature", feature), + ("_dome_info", info), ("whats_wrong", [])): + stack.enter_context(patch.object(dome, name, return_value=returned)) + for name in ("exit_active_sketch", "clear_selection", "rebuild"): + stack.enter_context(patch.object(dome, name)) + payload = dome.set_dome_parameters({"name": "Dome", "height_mm": 5}) + released.assert_called_once() + self.assertFalse(payload["ok"]) + + def test_constraint_requires_exactly_one_named_sketch_point(self): + for spec in ({"sketch_name": "A", "sketch_points": []}, + {"sketch_name": "A", "sketch_points": [0, 1]}, + {"sketch_name": "", "sketch_points": [0]}, + {"sketch_name": "A", "sketch_points": [True]}): + with patch.object(dome, "require_part") as part: + with self.assertRaises(RuntimeError): + dome.set_dome_parameters({"name": "Dome", "constraint_selection": spec}) + part.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_install.py b/tools/verify_install.py index 5d39c0b..871d1ee 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -54,6 +54,7 @@ async def call(name, arguments=None): original = initial["active_title"] title = None wrap_title = None + dome_title = None try: if scratch: title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] @@ -232,7 +233,27 @@ async def call(name, arguments=None): multi = await call("wrap_sketch", {"sketch_name": "MultiProfile", "face_indices": indices, "method": "spline", "name": "InstalledMultiWrap"}) if len(indices) != 2 or not math.isclose(multi["volume_change_mm3"], 168, abs_tol=.1): raise RuntimeError("Installed MCP multiple-face wrap geometry differs.") + if scratch: + dome_title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] + await call("create_sketch", {"plane": "front", "name": "DomeBase"}) + await call("draw_circle", {"x_mm": 0, "y_mm": 0, "radius_mm": 10}) + await call("close_sketch") + await call("boss_extrude", {"sketch_name": "DomeBase", "depth_mm": 30}) + index = next(f["index"] for f in (await call("list_faces"))["faces"] if f.get("normal", [0, 0, 0])[2] > .99) + created = await call("dome", {"face_index": index, "height_mm": 5, "name": "InstalledDome"}) + if not math.isclose(created["volume_change_mm3"], math.pi * 5 * 325 / 6, abs_tol=.001): + raise RuntimeError("Installed MCP dome spherical volume differs.") + queried = (await call("get_dome_data", {"name": "InstalledDome"}))["dome"] + if queried["height_mm"] != 5 or queried["reverse_direction"]: + raise RuntimeError("Installed MCP dome definition differs.") + await call("set_dome_parameters", {"name": "InstalledDome", "height_mm": 8}) + await call("set_dome_parameters", {"name": "InstalledDome", "elliptical": True}) + edited = await call("set_dome_parameters", {"name": "InstalledDome", "reverse_direction": True}) + if not edited["dome"]["ellipsoid_check"]["confirmed"] or not math.isclose(edited["volume_change_from_input_mm3"], -2 * math.pi * 100 * 8 / 3, abs_tol=.2): + raise RuntimeError("Installed MCP dome ellipsoid geometry differs.") finally: + if dome_title: + await call("close_document", {"name": dome_title, "discard_changes": True}) if wrap_title: await call("close_document", {"name": wrap_title, "discard_changes": True}) if title: From dc0f1380738a86f280fe94d56df32ab8b5c7d8f2 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:00:31 +0800 Subject: [PATCH 21/31] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=9C=86=E9=A1=B6?= =?UTF-8?q?=E7=82=B9=E8=8D=89=E5=9B=BE=E7=BA=A6=E6=9D=9F=E5=B9=B6=E6=A0=B8?= =?UTF-8?q?=E9=AA=8C=E6=8E=A7=E5=88=B6=E6=B8=85=E9=99=A4=E4=B8=8E=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=87=A0=E4=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 12 +- README.md | 2 +- solidworks_mcp/sw_dome.py | 150 ++++++++++++++++++++----- tests/live_dome_controls_regression.py | 96 ++++++++++++++++ tests/test_dome.py | 112 ++++++++++++++++++ tools/verify_install.py | 23 ++++ 6 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 tests/live_dome_controls_regression.py diff --git a/COVERAGE.md b/COVERAGE.md index c9fe5bc..88d06ea 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -179,7 +179,17 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 不作为高度成功依据。圆形和椭圆形边界的椭球模式另检查 24 个理论表面点, 距离容差为 0.01 mm;椭球原生曲面是近似表示,体积回归容差为 0.2%, 不能声称与理想椭球完全一致。草图点约束会使原生高度读回为 0,需检查约束点位置 -和实际曲面距离。复杂非平面、多面组合、整体草图约束、清除约束/方向及旧版软件 +和实际曲面距离。整体点草图约束支持单点、多点以及混合几何中的显式用户点, +逐点转换为模型坐标并检查最大点到曲面距离;线端点、圆心等内部生成点不作为 +隐含约束。纯曲线、空草图会在修改前拒绝,避免原生无有效约束点时产生默认圆顶。 +`clear_constraint` 要求同时指定恢复的高度,`clear_direction` 与指定新方向互斥; +已有控制的清除用带类型的空引用尝试并检查引用是否实际消失。SW 2026 SP3.2 +仍保留控制,工具返回失败;普通 None/空 Variant 可被原生拒绝,明确类型的原生 +属性调用及 PROPERTYPUTREF 同样未清除约束。不以原生接受请求作为成功依据。 +方向边按原生直线向量读取方向,并沿该向量检查距离、最近点误差和体积变化符号。 +例如 −Z 方向的体积减少不能按默认面法向误判;本机该方向的几何虽符合要求, +输出边在回滚前后的持久引用未确认,整次修改仍返回失败。平行目标面的方向仍可能 +压平圆顶。复杂非平面、多面组合、控制实际清除、方向引用与倾斜椭球及旧版软件 仍待覆盖或专项验证。 ```powershell diff --git a/README.md b/README.md index cf33db2..8c59516 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Parametrics: `add_relation`, `add_dimension`, `set_dimension`, `list_dimensions` | `preview_surface_trim` / `trim_surface` / `get_surface_trim_data` | 标准与相互曲面修剪;预览区域面积、边界与选择点,按区域索引保留或删除,并读取原生特征数据。 | | `boundary_surface` / `get_boundary_feature_data` | 单/双方向有序曲线的边界曲面、按方向修剪、原生形成实体选项及只读检查;相切/曲率控制按边界采样核验几何,未确认时保留特征并返回失败。 | | `wrap_sketch` / `get_wrap_data` / `set_wrap_parameters` | 单/多实体面的包覆三模式、解析/样条方法、三类方向及厚度/模式/方向/源草图修改;读回原生 `Face` 字段的前置几何。以体积、面分割、原生定义和持久引用快照验证。方法/网格不可读回;反向、清除方向及目标面修改未确认时返回失败。 | -| `dome` / `get_dome_data` / `set_dome_parameters` | 原生圆顶创建、定义与几何读取、高度/凹凸/椭球模式修改、目标面替换、草图点约束和方向边。以实际体积、中心高度、24 点椭球表面采样及持久引用核验;复杂面、方向导致零效果或无法确认椭球形状时返回失败并保留特征。 | +| `dome` / `get_dome_data` / `set_dome_parameters` | 原生圆顶创建、定义与几何读取、高度/凹凸/椭球模式修改、目标面替换、单点/整体点草图约束和方向边。每个显式草图点转换到模型坐标,检查实际曲面距离;纯曲线草图不作为隐含点约束。清除约束/方向核验引用是否实际消失,本机请求被忽略时返回失败并保留特征。另以体积、中心高度、24 点椭球采样及持久引用核验。 | | `surface_sweep` | 开口/闭合草图或圆截面扫描;导引线、法向控制、扭转、第一/第二/双向扫描,读回原生参数。要求 SW 2018+。 | | `delete_surface_holes` | 删除选定曲面孔边界,保留未选孔;支持单孔与多孔。 | | `surface_extrude` / `planar_surface` | 从草图生成拉伸曲面或平面曲面。 | diff --git a/solidworks_mcp/sw_dome.py b/solidworks_mcp/sw_dome.py index abe8e18..57a4e6b 100644 --- a/solidworks_mcp/sw_dome.py +++ b/solidworks_mcp/sw_dome.py @@ -25,7 +25,7 @@ def _validate(args, creating=False): height = float(args["height_mm"]) if not math.isfinite(height) or height <= 0: raise RuntimeError("Dome height_mm must be finite and positive.") - for k in ("reverse_direction", "elliptical"): + for k in ("reverse_direction", "elliptical", "clear_constraint", "clear_direction"): if k in args and not isinstance(args[k], bool): raise RuntimeError(f"{k} must be boolean.") if creating or "face_index" in args or "face_indices" in args: @@ -44,6 +44,19 @@ def _validate(args, creating=False): raise RuntimeError("Select exactly one point from a named sketch as the dome constraint.") if "height_mm" in args: raise RuntimeError("The constraint point controls height; do not supply height_mm with constraint_selection.") + if "constraint_sketch_name" in args: + name = args["constraint_sketch_name"] + if not isinstance(name, str) or not name.strip(): + raise RuntimeError("constraint_sketch_name must name an existing sketch.") + if "constraint_selection" in args or "height_mm" in args: + raise RuntimeError("A whole-sketch constraint cannot be combined with a point constraint or height_mm.") + if args.get("clear_constraint"): + if "constraint_selection" in args or "constraint_sketch_name" in args: + raise RuntimeError("Do not set and clear a dome constraint in the same edit.") + if "height_mm" not in args: + raise RuntimeError("Supply height_mm when clearing a dome constraint to restore height-driven geometry.") + if args.get("clear_direction") and "direction_edge_index" in args: + raise RuntimeError("Do not set and clear the dome direction in the same edit.") def _selected(doc, spec): @@ -58,12 +71,25 @@ def _point_position(point): return apply_transform([float(value(point, k)) for k in ("X", "Y", "Z")], value(inverse, "ArrayData")) -def _ellipsoid_queries(face, height, reverse): +def _constraint_samples(obj): + if safe(obj, "GetSketch") is not None: + return [_point_position(obj)], {"kind": "point", "sampling_complete": True} + points = as_list(value(obj, "GetSketchPoints2")) + # The native whole-sketch control uses explicit points, not the underlying + # curves or their generated center/end points (swSketchPointType_Internal). + samples = [_point_position(p) for p in points if int(value(p, "Type")) == 1] + return samples, {"kind": "sketch", "sampling_complete": bool(samples), "user_point_count": len(samples), + "other_sketch_point_count": len(points) - len(samples)} + + +def _ellipsoid_queries(face, height, reverse, direction=None): edges = as_list(value(face, "GetEdges")) if len(edges) != 1: return None curve = value(edges[0], "GetCurve") normal = [float(c) for c in value(face, "Normal")] + if direction is not None and abs(sum(a * b for a, b in zip(normal, direction))) < 1 - 1e-9: + return None if bool(value(curve, "IsCircle")): params = value(curve, "CircleParams") center, major, minor = list(params[:3]), float(params[6]), float(params[6]) @@ -79,7 +105,7 @@ def _ellipsoid_queries(face, height, reverse): return None signed_height = height / 1000 * (-1 if reverse else 1) return [[center[i] + a[i] * major * rho * math.cos(angle) + b[i] * minor * rho * math.sin(angle) - + normal[i] * signed_height * math.sqrt(1 - rho * rho) for i in range(3)] + + (direction or normal)[i] * signed_height * math.sqrt(1 - rho * rho) for i in range(3)] for rho in (.25, .5, .75) for angle in [k * math.pi / 4 for k in range(8)]] @@ -102,41 +128,68 @@ def _dome_info(doc, feature, expected=None): "input_face_count": count, "input_faces": [_target_details(f) for f in faces], "has_direction": direction is not None, "has_constraint": constraint is not None, "input_geometry": _geometry(doc)} + direction_vector = None + if direction is not None: + params = safe(safe(direction, "GetCurve"), "LineParams") + if params is not None and len(params) >= 6: + vector = [float(c) for c in params[3:6]] + length = math.sqrt(sum(c * c for c in vector)) + if length > 0 and math.isfinite(length): + direction_vector = [c / length for c in vector] + info["direction_vector"] = direction_vector + info["direction_geometry_verifiable"] = direction_vector is not None + volume_signs = [] for face, meta in zip(faces, info["input_faces"]): if meta["surface_type"] == "plane": normal = [float(c) for c in value(face, "Normal")] box = meta["box_mm"] center = [(box[i] + box[i + 3]) / 2000 for i in range(3)] - probes.append((center, [center[i] + normal[i] * height / 1000 * (-1 if reverse else 1) for i in range(3)], normal)) + vector = direction_vector or normal + if direction_vector is not None: + alignment = sum(vector[i] * normal[i] for i in range(3)) * (-1 if reverse else 1) + volume_signs.append(1 if alignment > 1e-9 else -1 if alignment < -1e-9 else 0) + probes.append((center, [center[i] + vector[i] * height / 1000 * (-1 if reverse else 1) for i in range(3)], normal, vector)) if info["elliptical"] and constraint is None: - ellipsoid_queries.append(_ellipsoid_queries(face, height, reverse) if meta["surface_type"] == "plane" else None) + ellipsoid_queries.append(_ellipsoid_queries(face, height, reverse, direction_vector) if meta["surface_type"] == "plane" else None) + if direction is not None: + info["expected_volume_sign"] = volume_signs[0] if volume_signs and all(s == volume_signs[0] for s in volume_signs) else None if expected: for key, obj in (("direction", direction), ("constraint", constraint)): if key in expected: - info[f"{key}_reference_confirmed"] = obj is not None and bool(expected[key]) and _reference_id(doc, obj) == expected[key] + info[f"{key}_reference_confirmed"] = (obj is None if expected[key] is None else + obj is not None and bool(expected[key]) and _reference_id(doc, obj) == expected[key]) if "faces" in expected: actual = [_reference_id(doc, f) for f in faces] info["face_references_confirmed"] = bool(actual) and len(actual) == len(expected["faces"]) and set(actual) == set(expected["faces"]) - point = None - if constraint is not None and safe(constraint, "GetSketch") is not None: - point = _point_position(constraint) - info["constraint_point_mm"] = [c * 1000 for c in point] + constraint_queries, constraint_meta = [], None + if constraint is not None: + constraint_queries, constraint_meta = _constraint_samples(constraint) + if constraint_meta["kind"] == "point": + info["constraint_point_mm"] = [c * 1000 for c in constraint_queries[0]] finally: value(data, "ReleaseSelectionAccess") output = as_list(value(feature, "GetFaces")) info.update(_geometry(doc), feature_faces=[_target_details(f) for f in output]) samples = [] - for center, query, normal in probes: + for center, query, normal, vector in probes: candidates = [list(flag_methods(f, "GetClosestPointOn").GetClosestPointOn(*query)[:3]) for f in output] if not candidates: continue closest = min(candidates, key=lambda q: sum((a - b) ** 2 for a, b in zip(q, query))) samples.append({"point_mm": [c * 1000 for c in closest], - "normal_displacement_mm": sum((closest[i] - center[i]) * normal[i] for i in range(3)) * 1000}) + "normal_displacement_mm": sum((closest[i] - center[i]) * normal[i] for i in range(3)) * 1000, + "direction_displacement_mm": sum((closest[i] - center[i]) * vector[i] for i in range(3)) * 1000, + "surface_gap_mm": math.dist(closest, query) * 1000}) info["center_probes"] = samples - if point is not None: - distances = [math.dist(point, flag_methods(f, "GetClosestPointOn").GetClosestPointOn(*point)[:3]) * 1000 for f in output] - info["constraint_surface_gap_mm"] = min(distances) if distances else None + if constraint_meta is not None: + gaps = [min(math.dist(query, flag_methods(f, "GetClosestPointOn").GetClosestPointOn(*query)[:3]) * 1000 + for f in output) for query in constraint_queries] if output else [] + maximum = max(gaps) if gaps else None + info["constraint_surface_gap_mm"] = maximum + info["constraint_check"] = {**constraint_meta, "sample_count": len(gaps), + "samples_mm": [[c * 1000 for c in q] for q in constraint_queries], + "maximum_surface_gap_mm": maximum, + "confirmed": constraint_meta["sampling_complete"] and maximum is not None and maximum <= .01} if info["elliptical"] and not info["has_constraint"]: gaps = [] supported = bool(ellipsoid_queries) and all(q is not None for q in ellipsoid_queries) and bool(output) @@ -154,17 +207,25 @@ def _geometry_confirmed(info): before = info["input_geometry"]["solid_volume_mm3"] delta = info["solid_volume_mm3"] - before tolerance = max(1e-6, abs(before) * 1e-9) - signed = delta < -tolerance if info["reverse_direction"] else delta > tolerance + if info.get("has_direction") and "expected_volume_sign" in info: + expected_sign = info["expected_volume_sign"] + signed = expected_sign is not None and expected_sign != 0 and delta * expected_sign > tolerance + else: + signed = delta < -tolerance if info["reverse_direction"] else delta > tolerance if info["has_constraint"]: # A constrained point drives the shape, and native Height becomes zero. - height = info.get("constraint_surface_gap_mm") is not None and info["constraint_surface_gap_mm"] <= .01 + height = (info.get("constraint_surface_gap_mm") is not None and info["constraint_surface_gap_mm"] <= .01 + and info.get("constraint_check", {"confirmed": True})["confirmed"]) else: sign = -1 if info["reverse_direction"] else 1 - height = bool(info["center_probes"]) and all(abs(p["normal_displacement_mm"] - sign * info["height_mm"]) <= .01 for p in info["center_probes"]) + directed = info.get("has_direction") and "direction_vector" in info + height = bool(info["center_probes"]) and all(abs(p["direction_displacement_mm" if directed else "normal_displacement_mm"] - sign * info["height_mm"]) <= .01 + and (not directed or p["surface_gap_mm"] <= .01) for p in info["center_probes"]) + height = height and info.get("direction_geometry_verifiable", True) return delta, signed and height and info.get("ellipsoid_check", {"confirmed": True})["confirmed"] -@tool("get_dome_data", "Read native dome height (mm), convex/concave and elliptical state, input faces, constraint/direction presence, input/current solid geometry and closest-point center probes. Native B-spline boxes are approximate and do not prove height. Point-constrained domes report native height zero and actual constraint position/gap.", +@tool("get_dome_data", "Read native dome height (mm), convex/concave and elliptical state, input faces, constraint/direction presence, native direction vector, input/current solid geometry and closest-point center probes. Native B-spline boxes are approximate and do not prove height. Point and whole point-sketch constraints report native height zero, model-space point positions and the maximum actual point-to-dome gap; generated curve centers/endpoints are not implicit constraint points.", {"name": {"type": "string", "minLength": 1}}, ["name"]) def get_dome_data(args): _, doc = require_part() @@ -200,18 +261,20 @@ def dome(args): clear_selection(doc) -@tool("set_dome_parameters", "Modify dome height, convex/concave state, elliptical state, target faces, a constraint sketch point or direction edge. Constraint and height are mutually exclusive: the point drives shape and native Height becomes zero. Checks frozen references, readback, signed volume and height/constraint geometry. On the tested build a direction edge can flatten the dome despite successful native readback; zero-effect edits return failure with the feature retained. Whole-sketch constraints and clearing controls are not implemented yet.", +@tool("set_dome_parameters", "Modify dome height, convex/concave state, elliptical state, target faces, a constraint point/whole point sketch or direction edge. Constraints and height are mutually exclusive. Whole-sketch constraints require explicit user sketch points; each point is transformed to model space and checked against the output dome. Generated curve-center/end points are not implicit constraints. clear_constraint requires height_mm; clear_direction cannot accompany a new direction. Clearing uses typed native null and checks actual reference absence. Native clear requests may be ignored or rejected and direction edges may flatten geometry; incomplete edits return failure with the feature retained.", {"name": {"type": "string", "minLength": 1}, "height_mm": HEIGHT, "reverse_direction": {"type": "boolean"}, "elliptical": {"type": "boolean"}, "face_index": {"type": "integer", "minimum": 0}, "face_indices": FACES, - "constraint_selection": CONSTRAINT, "direction_edge_index": {"type": "integer", "minimum": 0}}, ["name"]) + "constraint_selection": CONSTRAINT, "constraint_sketch_name": {"type": "string", "minLength": 1}, + "clear_constraint": {"type": "boolean"}, "clear_direction": {"type": "boolean"}, + "direction_edge_index": {"type": "integer", "minimum": 0}}, ["name"]) def set_dome_parameters(args): - if not any(k in args for k in ("height_mm", "reverse_direction", "elliptical", "face_index", "face_indices", "constraint_selection", "direction_edge_index")): + if not any(k in args for k in ("height_mm", "reverse_direction", "elliptical", "face_index", "face_indices", "constraint_selection", "constraint_sketch_name", "direction_edge_index")) and not any(args.get(k) for k in ("clear_constraint", "clear_direction")): raise RuntimeError("Specify at least one dome parameter to change.") _validate(args) _, doc = require_part() exit_active_sketch(doc) feature = find_feature(doc, args["name"]) - _dome_info(doc, feature) + previous = _dome_info(doc, feature) references, expected = {}, {} try: if "face_index" in args or "face_indices" in args: @@ -222,27 +285,52 @@ def set_dome_parameters(args): point = _selected(doc, args["constraint_selection"])[0] references["ConstraintPointOrSketch"] = point expected["constraint"] = _reference_id(doc, point) + if "constraint_sketch_name" in args: + selected = find_feature(doc, args["constraint_sketch_name"]) + if safe(selected, "GetTypeName2") not in ("ProfileFeature", "3DProfileFeature"): + raise RuntimeError("constraint_sketch_name must identify a native sketch feature.") + sketch = value(selected, "GetSpecificFeature2") + samples, _ = _constraint_samples(sketch) + if not samples: + raise RuntimeError("constraint_sketch_name requires at least one explicit user sketch point.") + references["ConstraintPointOrSketch"] = sketch + expected["constraint"] = _reference_id(doc, sketch) + for key, member, control in (("clear_constraint", "ConstraintPointOrSketch", "constraint"), + ("clear_direction", "Direction", "direction")): + if args.get(key): + if previous[f"has_{control}"]: + references[member] = nothing() + expected[control] = None if "direction_edge_index" in args: edge = _selected(doc, {"edges": [args["direction_edge_index"]]})[0] if not bool(value(value(edge, "GetCurve"), "IsLine")): raise RuntimeError("The dome direction edge must be linear.") references["Direction"] = edge expected["direction"] = _reference_id(doc, edge) - if any(not ref for refs in expected.values() for ref in (refs if isinstance(refs, list) else [refs])): + if any(not ref for refs in expected.values() if refs is not None for ref in (refs if isinstance(refs, list) else [refs])): raise RuntimeError("Cannot snapshot dome reference identities.") finally: clear_selection(doc) data = flag_methods(value(feature, "GetDefinition"), "AccessSelections") if not data.AccessSelections(doc, nothing()): return result(False, "Cannot access dome selections.") - accepted = False + accepted, native_error = False, None try: for member, obj in references.items(): - setattr(data, member, obj) - for key, member in (("height_mm", "Height"), ("reverse_direction", "ReverseDir"), ("elliptical", "Elliptical")): - if key in args: - setattr(data, member, float(args[key]) / 1000 if key == "height_mm" else args[key]) - accepted = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(data, doc, nothing())) + try: + setattr(data, member, obj) + except Exception as exc: + clearing = ((member == "ConstraintPointOrSketch" and args.get("clear_constraint")) or + (member == "Direction" and args.get("clear_direction"))) + if not clearing: + raise + native_error = str(exc) + break + if native_error is None: + for key, member in (("height_mm", "Height"), ("reverse_direction", "ReverseDir"), ("elliptical", "Elliptical")): + if key in args: + setattr(data, member, float(args[key]) / 1000 if key == "height_mm" else args[key]) + accepted = bool(flag_methods(feature, "ModifyDefinition").ModifyDefinition(data, doc, nothing())) finally: if not accepted: value(data, "ReleaseSelectionAccess") @@ -258,4 +346,4 @@ def set_dome_parameters(args): "Updated dome parameters and verified geometry." if accepted and correct and geometry and not problems else "Dome modification or geometric effect could not be confirmed. Feature retained.", feature=args["name"], dome=info, parameters_confirmed=correct, geometry_confirmed=geometry, - volume_change_from_input_mm3=delta, problems=problems) + volume_change_from_input_mm3=delta, problems=problems, native_clear_error=native_error) diff --git a/tests/live_dome_controls_regression.py b/tests/live_dome_controls_regression.py new file mode 100644 index 0000000..bbd1813 --- /dev/null +++ b/tests/live_dome_controls_regression.py @@ -0,0 +1,96 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Whole-sketch dome constraints and native control-clear readback.""" +import math +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + try: + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + call("create_sketch", plane="front", name="Base") + call("draw_circle", x_mm=0, y_mm=0, radius_mm=10); call("close_sketch") + call("boss_extrude", sketch_name="Base", depth_mm=30) + call("create_plane", mode="offset", selection={"planes": ["front"]}, distance_mm=35, name="ApexPlane") + for name, shape in (("Apex", "point"), ("ApexTwo", "two"), ("ApexMixed", "mixed"), ("ApexLine", "line"), ("ApexCircle", "circle")): + call("create_sketch", plane_name="ApexPlane", name=name) + if shape in ("point", "two", "mixed"): + call("draw_point", x_mm=3, y_mm=0) + if shape != "point": call("draw_point", x_mm=-3, y_mm=0) + if shape == "mixed": call("draw_line", x1_mm=-2, y1_mm=-2, x2_mm=2, y2_mm=-2) + elif shape == "line": call("draw_line", x1_mm=-3, y1_mm=0, x2_mm=3, y2_mm=0) + else: call("draw_circle", x_mm=0, y_mm=0, radius_mm=2) + call("close_sketch") + top = next(f["index"] for f in call("list_faces")["faces"] if f.get("normal", [0, 0, 0])[2] > .99) + call("dome", face_index=top, height_mm=5, name="SketchDome") + point = call("set_dome_parameters", name="SketchDome", constraint_sketch_name="Apex") + check = point["dome"]["constraint_check"] + assert point["dome"]["constraint_reference_confirmed"] and point["dome"]["height_mm"] == 0 + assert check["kind"] == "sketch" and check["sample_count"] == 1 and check["confirmed"] + assert math.dist(check["samples_mm"][0], [3, 0, 35]) < .001 + print("WHOLE_POINT", check, flush=True) + clear = HANDLERS["set_dome_parameters"]({"name": "SketchDome", "clear_constraint": True, "height_mm": 5}) + assert not clear["ok"] and not clear["data"]["parameters_confirmed"] + assert clear["data"]["dome"]["has_constraint"] and not clear["data"]["dome"]["constraint_reference_confirmed"] + assert clear["data"]["dome"]["height_mm"] == 0 + assert abs(clear["data"]["dome"]["solid_volume_mm3"] - point["dome"]["solid_volume_mm3"]) < 1e-6 + print("CLEAR_CONSTRAINT_IGNORED", flush=True) + for name in ("ApexTwo", "ApexMixed"): + edited = call("set_dome_parameters", name="SketchDome", constraint_sketch_name=name) + check = edited["dome"]["constraint_check"] + assert edited["dome"]["constraint_reference_confirmed"] + assert check["confirmed"] and check["sample_count"] == 2 + assert sorted(round(p[0]) for p in check["samples_mm"]) == [-3, 3] + assert all(abs(p[2] - 35) < .001 for p in check["samples_mm"]) + print("WHOLE_POINT_SKETCH", name, check, flush=True) + volume = call("get_dome_data", name="SketchDome")["dome"]["solid_volume_mm3"] + for name in ("ApexLine", "ApexCircle"): + try: + HANDLERS["set_dome_parameters"]({"name": "SketchDome", "constraint_sketch_name": name}) + except RuntimeError as exc: + assert "explicit user sketch point" in str(exc) + else: + raise AssertionError("A curve-only constraint must be rejected before native modification.") + assert abs(call("get_dome_data", name="SketchDome")["dome"]["solid_volume_mm3"] - volume) < 1e-6 + print("CURVE_ONLY_SKETCH_REJECTED", name, flush=True) + + scratch.append(call("create_new_document", kind="part")["document"]["title"]) + call("create_sketch", plane="front", name="RectBase") + call("draw_rectangle", x1_mm=-10, y1_mm=-5, x2_mm=10, y2_mm=5); call("close_sketch") + call("boss_extrude", sketch_name="RectBase", depth_mm=30) + top = next(f["index"] for f in call("list_faces")["faces"] if f.get("normal", [0, 0, 0])[2] > .99) + call("dome", face_index=top, height_mm=5, name="DirectionDome") + absent = call("set_dome_parameters", name="DirectionDome", clear_direction=True) + assert absent["dome"]["direction_reference_confirmed"] and not absent["dome"]["has_direction"] + vertical = next(e["index"] for e in call("list_edges")["edges"] if e.get("curve_type") == "line" and abs(e.get("direction", [0, 0, 0])[2]) > .99) + directed = HANDLERS["set_dome_parameters"]({"name": "DirectionDome", "direction_edge_index": vertical}) + print("VERTICAL_DIRECTION", directed, flush=True) + assert not directed["ok"] and not directed["data"]["dome"]["direction_reference_confirmed"] + assert directed["data"]["geometry_confirmed"] + assert abs(directed["data"]["volume_change_from_input_mm3"] + 416.07447704119) < .001 + assert directed["data"]["dome"]["direction_vector"][2] < -.99 + assert directed["data"]["dome"]["center_probes"][0]["surface_gap_mm"] < .01 + edge = next(e["index"] for e in call("list_edges")["edges"] if e.get("curve_type") == "line" and abs(e.get("direction", [0])[0]) > .99) + edited = HANDLERS["set_dome_parameters"]({"name": "DirectionDome", "direction_edge_index": edge}) + assert not edited["ok"] and edited["data"]["dome"]["has_direction"] + cleared = HANDLERS["set_dome_parameters"]({"name": "DirectionDome", "clear_direction": True}) + print("CLEAR_DIRECTION", cleared, flush=True) + assert not cleared["ok"] and cleared["data"]["dome"]["has_direction"] + assert not cleared["data"]["dome"]["direction_reference_confirmed"] + assert abs(cleared["data"]["volume_change_from_input_mm3"]) < 1e-6 + print("Single/multiple/mixed point-sketch constraints, curve-only rejection and native control clears verified on two scratch parts.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_dome.py b/tests/test_dome.py index 89422c1..9927dba 100644 --- a/tests/test_dome.py +++ b/tests/test_dome.py @@ -7,6 +7,7 @@ from types import SimpleNamespace from unittest.mock import Mock, patch import unittest +import pythoncom from solidworks_mcp import sw_dome as dome @@ -17,6 +18,17 @@ def geometry(**changes): class DomeContracts(unittest.TestCase): + def test_conflicting_or_invalid_controls_never_access_com(self): + for changes in ({"constraint_sketch_name": " "}, {"constraint_sketch_name": "A", "height_mm": 5}, + {"constraint_sketch_name": "A", "constraint_selection": {"sketch_name": "A", "sketch_points": [0]}}, + {"clear_constraint": True}, {"clear_direction": 1}, {"clear_direction": False}, + {"clear_direction": True, "direction_edge_index": 0}, + {"clear_constraint": True, "height_mm": 5, "constraint_sketch_name": "A"}): + with patch.object(dome, "require_part") as part: + with self.assertRaises(RuntimeError): + dome.set_dome_parameters({"name": "Dome", **changes}) + part.assert_not_called() + def test_invalid_inputs_do_not_access_com(self): for changes in ({"height_mm": 0}, {"height_mm": float("nan")}, {"face_index": -1}, {"face_indices": [0, 0]}, {"reverse_direction": 1}, {"elliptical": 1}, @@ -55,10 +67,110 @@ def test_concave_requires_negative_volume_and_displacement(self): center_probes=[{"normal_displacement_mm": -5}]))[1]) self.assertFalse(dome._geometry_confirmed(geometry(reverse_direction=True))[1]) + def test_direction_sign_and_distance_are_checked_in_native_vector_space(self): + info = geometry(has_direction=True, direction_vector=[0, 0, -1], expected_volume_sign=-1, + solid_volume_mm3=90, center_probes=[{"normal_displacement_mm": -5, + "direction_displacement_mm": 5, "surface_gap_mm": 0}]) + self.assertTrue(dome._geometry_confirmed(info)[1]) + self.assertFalse(dome._geometry_confirmed({**info, "expected_volume_sign": 0})[1]) + self.assertFalse(dome._geometry_confirmed({**info, "solid_volume_mm3": 110})[1]) + self.assertFalse(dome._geometry_confirmed({**info, "center_probes": [{"direction_displacement_mm": 5, + "surface_gap_mm": 1}]})[1]) + def test_constraint_controls_height_when_native_height_is_zero(self): for gap, expected in ((.001, True), (.1, False), (None, False)): self.assertEqual(dome._geometry_confirmed(geometry(has_constraint=True, height_mm=0, constraint_surface_gap_mm=gap))[1], expected) + self.assertFalse(dome._geometry_confirmed(geometry(has_constraint=True, height_mm=0, + constraint_surface_gap_mm=0, constraint_check={"confirmed": False}))[1]) + + def test_whole_sketch_samples_user_points_in_model_coordinates(self): + matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, .035, 1, 0, 0, 0] + sketch = SimpleNamespace(ModelToSketchTransform=SimpleNamespace(Inverse=SimpleNamespace(ArrayData=matrix))) + point = lambda x, kind: SimpleNamespace(X=x, Y=0, Z=0, Type=kind, GetSketch=lambda: sketch) + user, internal = point(.003, 1), point(0, 0) + line = SimpleNamespace(GetType=lambda: 0, ConstructionGeometry=False, + GetStartPoint2=lambda: point(-.003, 0), GetEndPoint2=lambda: point(.003, 0)) + sketch.GetSketchSegments = lambda: [line] + sketch.GetSketchPoints2 = lambda: [user, internal] + samples, meta = dome._constraint_samples(sketch) + self.assertTrue(meta["sampling_complete"]) + self.assertEqual(len(samples), 1) + self.assertEqual(samples[0], [.003, 0, .035]) + self.assertEqual(meta["other_sketch_point_count"], 1) + + def test_curve_centers_and_endpoints_are_not_implicit_user_constraints(self): + internal = SimpleNamespace(Type=0) + sketch = SimpleNamespace(GetSketchPoints2=lambda: [internal, internal]) + samples, meta = dome._constraint_samples(sketch) + self.assertFalse(meta["sampling_complete"]) + self.assertEqual(samples, []) + self.assertEqual(meta["other_sketch_point_count"], 2) + + def test_empty_sketch_cannot_prove_constraint_geometry(self): + sketch = SimpleNamespace(GetSketchPoints2=lambda: []) + samples, meta = dome._constraint_samples(sketch) + self.assertFalse(meta["sampling_complete"]) + self.assertEqual(samples, []) + self.assertEqual(meta["user_point_count"], 0) + + def test_clear_controls_uses_typed_null_and_checks_absence(self): + data = SimpleNamespace(AccessSelections=lambda *a: True) + feature = SimpleNamespace(GetDefinition=lambda: data, ModifyDefinition=lambda *a: True) + for cleared in (True, False): + info = geometry(has_direction=True, has_constraint=True, constraint_surface_gap_mm=.001, + direction_reference_confirmed=cleared, constraint_reference_confirmed=cleared) + with ExitStack() as stack: + for name, returned in (("require_part", (None, "doc")), ("find_feature", feature), + ("_dome_info", info), ("whats_wrong", [])): + mocked = stack.enter_context(patch.object(dome, name, return_value=returned)) + if name == "_dome_info": readback = mocked + for name in ("exit_active_sketch", "clear_selection", "rebuild"): + stack.enter_context(patch.object(dome, name)) + payload = dome.set_dome_parameters({"name": "Dome", "height_mm": 5, + "clear_constraint": True, "clear_direction": True}) + self.assertEqual(data.ConstraintPointOrSketch.varianttype, pythoncom.VT_DISPATCH) + self.assertIsNone(data.Direction.value) + self.assertEqual(data.Height, .005) + self.assertEqual(readback.call_args.args[-1], {"constraint": None, "direction": None}) + self.assertEqual(payload["ok"], cleared) + + def test_native_clear_rejection_releases_rollback_and_does_not_modify(self): + released, modify = Mock(), Mock() + class Definition: + def AccessSelections(self, *args): return True + def ReleaseSelectionAccess(self): released() + @property + def Direction(self): return "edge" + @Direction.setter + def Direction(self, value): raise AttributeError("Native direction clear rejected") + feature = SimpleNamespace(GetDefinition=lambda: Definition(), ModifyDefinition=modify) + info = geometry(has_direction=True, direction_reference_confirmed=False) + with ExitStack() as stack: + for name, returned in (("require_part", (None, "doc")), ("find_feature", feature), + ("_dome_info", info), ("whats_wrong", [])): + stack.enter_context(patch.object(dome, name, return_value=returned)) + for name in ("exit_active_sketch", "clear_selection", "rebuild"): + stack.enter_context(patch.object(dome, name)) + payload = dome.set_dome_parameters({"name": "Dome", "clear_direction": True}) + released.assert_called_once() + modify.assert_not_called() + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["native_clear_error"], "Native direction clear rejected") + + def test_already_absent_direction_is_verified_without_null_setter(self): + data = SimpleNamespace(AccessSelections=lambda *a: True) + feature = SimpleNamespace(GetDefinition=lambda: data, ModifyDefinition=lambda *a: True) + info = geometry(has_direction=False, direction_reference_confirmed=True) + with ExitStack() as stack: + for name, returned in (("require_part", (None, "doc")), ("find_feature", feature), + ("_dome_info", info), ("whats_wrong", [])): + stack.enter_context(patch.object(dome, name, return_value=returned)) + for name in ("exit_active_sketch", "clear_selection", "rebuild"): + stack.enter_context(patch.object(dome, name)) + payload = dome.set_dome_parameters({"name": "Dome", "clear_direction": True}) + self.assertFalse(hasattr(data, "Direction")) + self.assertTrue(payload["ok"]) def test_rollback_is_released_before_output_geometry(self): events = [] diff --git a/tools/verify_install.py b/tools/verify_install.py index 871d1ee..abffb09 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -43,6 +43,14 @@ async def call(name, arguments=None): if not payload["ok"]: raise RuntimeError(f"Installed MCP call failed: {name}: {payload}") return payload.get("data", {}) + async def rejected_call(name, arguments): + output = await session.call_tool(name, arguments) + if output.isError: + raise RuntimeError(f"Expected a structured native failure, got a protocol error: {name}.") + payload = json.loads(output.content[0].text) + if payload["ok"]: + raise RuntimeError(f"Installed MCP falsely accepted an ignored native edit: {name}.") + return payload.get("data", {}) connections = await call("list_solidworks_sessions") attached = connections["attached_process_id"] if attached is None: @@ -239,6 +247,11 @@ async def call(name, arguments=None): await call("draw_circle", {"x_mm": 0, "y_mm": 0, "radius_mm": 10}) await call("close_sketch") await call("boss_extrude", {"sketch_name": "DomeBase", "depth_mm": 30}) + await call("create_plane", {"mode": "offset", "selection": {"planes": ["front"]}, "distance_mm": 35, "name": "InstalledApexPlane"}) + await call("create_sketch", {"plane_name": "InstalledApexPlane", "name": "InstalledApex"}) + await call("draw_point", {"x_mm": 3, "y_mm": 0}) + await call("draw_point", {"x_mm": -3, "y_mm": 0}) + await call("close_sketch") index = next(f["index"] for f in (await call("list_faces"))["faces"] if f.get("normal", [0, 0, 0])[2] > .99) created = await call("dome", {"face_index": index, "height_mm": 5, "name": "InstalledDome"}) if not math.isclose(created["volume_change_mm3"], math.pi * 5 * 325 / 6, abs_tol=.001): @@ -251,6 +264,16 @@ async def call(name, arguments=None): edited = await call("set_dome_parameters", {"name": "InstalledDome", "reverse_direction": True}) if not edited["dome"]["ellipsoid_check"]["confirmed"] or not math.isclose(edited["volume_change_from_input_mm3"], -2 * math.pi * 100 * 8 / 3, abs_tol=.2): raise RuntimeError("Installed MCP dome ellipsoid geometry differs.") + await call("set_dome_parameters", {"name": "InstalledDome", "height_mm": 5, "reverse_direction": False, "elliptical": False}) + constrained = await call("set_dome_parameters", {"name": "InstalledDome", "constraint_sketch_name": "InstalledApex"}) + check = constrained["dome"]["constraint_check"] + if not constrained["dome"]["constraint_reference_confirmed"] or not check["confirmed"] or check["sample_count"] != 2: + raise RuntimeError("Installed MCP whole point-sketch constraint differs.") + if sorted(round(p[0]) for p in check["samples_mm"]) != [-3, 3] or any(abs(p[2] - 35) > .001 for p in check["samples_mm"]): + raise RuntimeError("Installed MCP constraint model coordinates differ.") + rejected = await rejected_call("set_dome_parameters", {"name": "InstalledDome", "clear_constraint": True, "height_mm": 5}) + if rejected["parameters_confirmed"] or not rejected["dome"]["has_constraint"] or rejected["dome"]["constraint_reference_confirmed"]: + raise RuntimeError("Installed MCP ignored-constraint-clear readback differs.") finally: if dome_title: await call("close_document", {"name": dome_title, "discard_changes": True}) From c94a4c3545cef8cdb984079c3fb16c0981222eaa Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:30:32 +0800 Subject: [PATCH 22/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=89=E7=BB=B4?= =?UTF-8?q?=E8=8D=89=E5=9B=BE=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=B8=8E?= =?UTF-8?q?=20XYZ=20=E5=87=A0=E4=BD=95=E5=B9=B6=E6=A0=B8=E9=AA=8C=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 21 ++++- README.md | 13 ++- solidworks_mcp/sw_core.py | 44 +++++++--- solidworks_mcp/sw_sketch.py | 126 ++++++++++++++++++++++++---- solidworks_mcp/sw_wrap.py | 7 +- tests/live_sketch3d_regression.py | 94 +++++++++++++++++++++ tests/test_sketch3d.py | 135 ++++++++++++++++++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 35 ++++++++ 9 files changed, 435 insertions(+), 42 deletions(-) create mode 100644 tests/live_sketch3d_regression.py create mode 100644 tests/test_sketch3d.py diff --git a/COVERAGE.md b/COVERAGE.md index 88d06ea..197728f 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 177 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 179 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 @@ -172,7 +172,7 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 控制柄修改与方向向量、中心线、更多开放轮廓法向条件、闭合方向和复杂曲线组合仍待覆盖或专项验证。 探索 `ModifyDefinition` 曾改变输出实体类型,未用它校正曲面控制。旧创建方法仅离线调度验证,旧软件未实测。 -## 原生 API 清点 +## 圆顶验证 圆顶新增创建、定义查询与参数编辑 3 个工具。创建接口返回空时从特征树恢复 `Dome`,随后验证原生定义、实际体积和中心高度。B-spline 包围盒会过度估计高度, @@ -192,6 +192,23 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 压平圆顶。复杂非平面、多面组合、控制实际清除、方向引用与倾斜椭球及旧版软件 仍待覆盖或专项验证。 +## 3D 草图验证 + +3D 草图新增创建和点读取 2 个工具,并扩展现有线、点和样条的 XYZ 参数。 +`ProfileFeature` 与 `3DProfileFeature` 均可解析;编辑、关闭和建模前自动退出 +根据 `ISketch.Is3D` 使用对应原生接口,不以 2D 插入接口切换 3D 编辑状态。 +重新编辑较早草图时,以冻结的持久引用识别活动草图及名称,不假定活动草图始终最新。 +零件和装配体实测 XYZ 线端点 (10,20,30) mm、长度 37.416574 mm;样条通过 +(20,0,0)、(25,5,8)、(30,0,15) mm,原生长度 21.025226 mm,插值点到曲线 +距离另行核验。点读取保留内部/用户/样条点与原生索引,同时返回草图和模型坐标。 +2D 非零 Z 在创建前拒绝;已有编辑状态时拒绝再次创建,避免误关闭已有草图。 +零件与装配体另验证 3D 线的构造几何切换和固定约束添加,未据此宣称完整约束覆盖。 +3D 直线路径圆截面扫描半径 1 mm、路径长度 √1400 mm,实际面积 +235.095267 mm²,并验证创建特征前自动退出 3D 草图。3D 圆弧/圆等其他几何、 +工作平面、完整约束/驱动尺寸、复杂扫描/放样组合与旧版软件仍待覆盖或专项验证。 + +## 原生 API 清点 + ```powershell python tools/build_api_inventory.py --official-modules --output D:/solidworks-mcp/.development/native-api-inventory.json ``` diff --git a/README.md b/README.md index 8c59516..4c23985 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -177 tools: sketching with real relations and driving dimensions, the solid +179 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -145,8 +145,15 @@ parallel-through-point), `create_axis`. `composite_curve` 将相连的草图、边或参考曲线生成组合曲线。 ### Sketching -`create_sketch` (on a plane **or a model face**), `edit_sketch`, `close_sketch`, -`list_sketches`, `list_sketch_segments`, `get_sketch_status`. +`create_sketch` (on a plane **or a model face**), `create_3d_sketch`, `edit_sketch`, `close_sketch`, +`list_sketches`, `list_sketch_segments`, `list_sketch_points`, `get_sketch_status`. + +`create_3d_sketch` 在零件或装配体中创建原生 3D 草图;`draw_line` 支持 `z1_mm/z2_mm`, +`draw_point` 和 `draw_spline` 支持 `z_mm`。3D 坐标以模型坐标为准,单位为 mm; +2D 草图拒绝非零 Z,避免原生接口忽略坐标。编辑与关闭根据原生 `Is3D` 选用对应接口, +编辑时核验持久引用;读取较早草图时不误报为最新草图名称。 +`list_sketch_points` 包含内部点与用户点,返回选择索引、类型和草图/模型坐标。 +其他 3D 曲线、工作平面、完整约束/尺寸与更多建模组合仍有缺口。 Geometry: `draw_line`, `draw_centerline`, `draw_circle`, `draw_rectangle`, `draw_arc`, `draw_3point_arc`, `draw_ellipse`, `draw_polygon`, `draw_slot`, diff --git a/solidworks_mcp/sw_core.py b/solidworks_mcp/sw_core.py index 2e7c5b0..847cfcf 100644 --- a/solidworks_mcp/sw_core.py +++ b/solidworks_mcp/sw_core.py @@ -372,6 +372,7 @@ def flag_methods(obj: Any, *names: str) -> Any: # IModelDoc2 "ClearSelection2", "InsertSketch2", "SketchFillet2", "SketchChamfer", "SketchMirror", "SketchOffset2", "InsertFeatureShell", "InsertAxis2", "ShowNamedView2", "Parameter", "Save3", "SaveAs", + "Insert3DSketch2", "Insert3DSketch", # IPartDoc "SetMaterialPropertyName2", "GetMaterialPropertyName2", "GetPartBox", "GetBodies2", # IAssemblyDoc @@ -470,6 +471,12 @@ def byref_dispatch() -> Any: return win32com.client.VARIANT(pythoncom.VT_BYREF | pythoncom.VT_DISPATCH, None) +def persistent_reference_id(doc: Any, obj: Any) -> bytes: + raw = flag_methods(value(doc, "Extension"), "GetPersistReference3").GetPersistReference3(obj) + # Freeze native memoryviews before a rollback, rebuild or edit changes them. + return bytes(raw) if raw is not None else b"" + + def as_list(com_array: Any) -> list[Any]: """Normalise the several shapes SOLIDWORKS uses for 'array or nothing'.""" if com_array is None: @@ -673,7 +680,7 @@ def reference_axes(doc: Any) -> list[str]: def sketch_features(doc: Any) -> list[Any]: - return [f for f in iter_feature_objects(doc) if feature_property(f, "GetTypeName2", "") == "ProfileFeature"] + return [f for f in iter_feature_objects(doc) if feature_property(f, "GetTypeName2", "") in ("ProfileFeature", "3DProfileFeature")] def sketch_names(doc: Any) -> list[str]: @@ -689,27 +696,42 @@ def latest_sketch(doc: Any) -> tuple[str, Any]: return str(feature_property(feature, "Name", "")), feature +def sketch_name_for_object(doc: Any, sketch: Any) -> str: + reference = persistent_reference_id(doc, sketch) + if reference: + for feature in sketch_features(doc): + specific = value(feature, "GetSpecificFeature2") + if specific is not None and persistent_reference_id(doc, specific) == reference: + return str(value(feature, "Name")) + raise RuntimeError("Cannot identify the active sketch feature from its native reference.") + + def resolve_sketch(doc: Any, sketch_name: str | None) -> tuple[str, Any]: """Resolve an explicit sketch name, or fall back to the newest sketch.""" if sketch_name: feature = find_feature(doc, sketch_name) if feature is None: raise RuntimeError(f"No feature named '{sketch_name}' exists in this document.") - if feature_property(feature, "GetTypeName2", "") != "ProfileFeature": + if feature_property(feature, "GetTypeName2", "") not in ("ProfileFeature", "3DProfileFeature"): raise RuntimeError(f"Feature '{sketch_name}' is not a sketch.") return sketch_name, feature return latest_sketch(doc) +def toggle_sketch(doc: Any, is_3d: bool) -> None: + if is_3d: + flag_methods(doc, "Insert3DSketch2", "Insert3DSketch") + call_versioned(doc, ("Insert3DSketch2", (True,)), ("Insert3DSketch", ())) + else: + sketch_manager(doc).InsertSketch(True) + + def exit_active_sketch(doc: Any) -> None: - try: + active = doc.SketchManager.ActiveSketch + if active is not None: + toggle_sketch(doc, bool(value(active, "Is3D"))) if doc.SketchManager.ActiveSketch is not None: - sketch_manager(doc).InsertSketch(True) - except Exception: - try: - doc.InsertSketch2(True) - except Exception: - pass + raise RuntimeError("SOLIDWORKS did not close the active sketch.") def select_sketch_for_feature(doc: Any, sketch_name: str | None) -> str: @@ -1155,9 +1177,7 @@ def enumerate_sketch_segments(doc: Any, sketch_name: str | None = None) -> tuple raise RuntimeError("The drawing has no active sketch. Activate a view first.") resolved_name = "" elif sketch is not None and not sketch_name: - # ISketch has no accessor back to its feature in this type library, and - # the open sketch is always the newest ProfileFeature in the tree. - resolved_name, _ = latest_sketch(doc) + resolved_name = sketch_name_for_object(doc, sketch) else: resolved_name, feature = resolve_sketch(doc, sketch_name) sketch = value(feature, "GetSpecificFeature2") diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 85adc63..7af9371 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -22,9 +22,16 @@ from __future__ import annotations from typing import Any +import math from .sw_core import ( active_document, + apply_transform, + document_type, + exit_active_sketch, + sketch_features, + sketch_name_for_object, + persistent_reference_id, sketch_point_objects, dispatch_array, empty_variant, @@ -51,6 +58,7 @@ select_by_id, sketch_manager, sketch_names, + toggle_sketch, sketch_segment_objects, tool, to_deg, @@ -138,6 +146,8 @@ def _require_open_sketch() -> tuple[Any, Any]: ) def create_sketch(args: dict[str, Any]) -> dict[str, Any]: _, doc = require_part() + if doc.SketchManager.ActiveSketch is not None: + return result(False, "A sketch is already open. Close it before creating another sketch.") face_index = args.get("face_index") if face_index is not None: require_selection(doc, {"faces": [int(face_index)]}) @@ -166,6 +176,31 @@ def create_sketch(args: dict[str, Any]) -> dict[str, Any]: return result(True, f"Opened a sketch on {target}.", sketch=created, target=target) +@tool("create_3d_sketch", "Create and open a new native 3D sketch in the active part or assembly. No plane is needed; geometry coordinates are model-space millimetres. Refuses to toggle an already-open sketch. Verifies native Is3D and the new feature before reporting success.", + {"name": {"type": "string", "minLength": 1}}) +def create_3d_sketch(args): + _, doc = active_document() + if document_type(doc) not in (1, 2): + raise RuntimeError("3D sketches require a part or assembly document.") + if doc.SketchManager.ActiveSketch is not None: + return result(False, "A sketch is already open. Close it before creating another sketch.") + before = set(sketch_names(doc)) + clear_selection(doc) + toggle_sketch(doc, True) + active = doc.SketchManager.ActiveSketch + if active is None or not bool(value(active, "Is3D")): + return result(False, "SOLIDWORKS did not open a native 3D sketch.") + name, feature = latest_sketch(doc) + if name in before or safe(feature, "GetTypeName2") != "3DProfileFeature": + return result(False, "Could not confirm a new native 3D sketch feature.", sketch=name) + if args.get("name"): + feature.Name = args["name"] + if str(value(feature, "Name")) != args["name"]: + return result(False, "3D sketch created, but its requested name was not retained.", sketch=str(value(feature, "Name"))) + name = args["name"] + return result(True, "Opened a native 3D sketch.", sketch=name, is_3d=True) + + @tool( "edit_sketch", "Reopen an existing sketch for editing by name.", @@ -173,13 +208,23 @@ def create_sketch(args: dict[str, Any]) -> dict[str, Any]: ["sketch_name"], ) def edit_sketch(args: dict[str, Any]) -> dict[str, Any]: - _, doc = require_part() - name, _ = resolve_sketch(doc, str(args["sketch_name"])) + _, doc = active_document() + if document_type(doc) not in (1, 2): + raise RuntimeError("Named sketch editing currently requires a part or assembly.") + if doc.SketchManager.ActiveSketch is not None: + return result(False, "A sketch is already open. Close it before editing another sketch.") + name, feature = resolve_sketch(doc, str(args["sketch_name"])) + specific = value(feature, "GetSpecificFeature2") + is_3d = bool(value(specific, "Is3D")) + expected = persistent_reference_id(doc, specific) clear_selection(doc) if not select_by_id(doc, name, "SKETCH"): return result(False, f"Could not select sketch '{name}'.") - sketch_manager(doc).InsertSketch(True) - return result(True, f"Reopened sketch '{name}' for editing.", sketch=name) + toggle_sketch(doc, is_3d) + active = doc.SketchManager.ActiveSketch + confirmed = active is not None and bool(value(active, "Is3D")) == is_3d and bool(expected) and persistent_reference_id(doc, active) == expected + return result(confirmed, f"Reopened sketch '{name}' for editing." if confirmed else "Could not confirm the requested sketch edit context.", + sketch=name, is_3d=is_3d, reference_confirmed=confirmed) @tool("close_sketch", "Exit the open sketch without creating a feature.", {}) @@ -189,18 +234,42 @@ def close_sketch(args: dict[str, Any]) -> dict[str, Any]: return result(False, "No sketch is open.") name = "" try: - name, _ = latest_sketch(doc) + name = sketch_name_for_object(doc, doc.SketchManager.ActiveSketch) except Exception: pass - sketch_manager(doc).InsertSketch(True) - return result(True, "Closed the open sketch.", sketch=name) + is_3d = bool(value(doc.SketchManager.ActiveSketch, "Is3D")) + exit_active_sketch(doc) + return result(True, "Closed the open sketch.", sketch=name, is_3d=is_3d) @tool("list_sketches", "Read-only: list every sketch feature in the active document.", {}) def list_sketches(args: dict[str, Any]) -> dict[str, Any]: _, doc = active_document() open_sketch = doc.SketchManager.ActiveSketch is not None - return result(True, "Read sketches.", sketches=sketch_names(doc), sketch_open=open_sketch) + details = [{"name": str(value(f, "Name")), "is_3d": bool(value(value(f, "GetSpecificFeature2"), "Is3D"))} for f in sketch_features(doc)] + return result(True, "Read sketches.", sketches=sketch_names(doc), sketch_details=details, sketch_open=open_sketch) + + +@tool("list_sketch_points", "Read all native points of the open or named 2D/3D sketch with selection indices, native point types and sketch/model-space coordinates in mm. Includes generated endpoints/centers, so point indices match selection specs; user points have native type 1. Drawing-sketch enumeration remains pending.", + {"sketch_name": {"type": "string"}}) +def list_sketch_points(args): + _, doc = active_document() + if document_type(doc) not in (1, 2): + raise RuntimeError("Point enumeration currently requires a part or assembly.") + active = doc.SketchManager.ActiveSketch + if args.get("sketch_name") or active is None: + name, feature = resolve_sketch(doc, args.get("sketch_name")) + sketch = value(feature, "GetSpecificFeature2") + else: + name = sketch_name_for_object(doc, active) + sketch = active + matrix = value(value(value(sketch, "ModelToSketchTransform"), "Inverse"), "ArrayData") + points = [] + for index, point in enumerate(as_list(value(sketch, "GetSketchPoints2"))): + coords = [float(value(point, k)) for k in ("X", "Y", "Z")] + points.append({"index": index, "native_type": int(value(point, "Type")), "point_mm": [c * 1000 for c in coords], + "model_point_mm": [c * 1000 for c in apply_transform(coords, matrix)]}) + return result(True, "Read native sketch points.", sketch=name, is_3d=bool(value(sketch, "Is3D")), points=points) @tool( @@ -222,22 +291,36 @@ def list_sketch_segments(args: dict[str, Any]) -> dict[str, Any]: _XY = {"x_mm": {"type": "number"}, "y_mm": {"type": "number"}} +def _xyz(args, x="x_mm", y="y_mm", z="z_mm"): + coords = [float(args[x]), float(args[y]), float(args.get(z, 0))] + if not all(math.isfinite(c) for c in coords): + raise RuntimeError("Sketch coordinates must be finite.") + return [c / 1000 for c in coords] + + +def _check_z(doc, points): + if any(p[2] != 0 for p in points) and not bool(value(_active_sketch(doc), "Is3D")): + raise RuntimeError("Nonzero Z requires an open 3D sketch; 2D sketch coordinates lie in its XY plane.") + + @tool( "draw_line", - "Add a line to the open sketch. Coordinates are millimetres in sketch space.", + "Add a line to the open 2D/3D sketch. Coordinates are mm in sketch space (model space for 3D). Nonzero z1_mm/z2_mm requires a 3D sketch.", { "x1_mm": {"type": "number"}, "y1_mm": {"type": "number"}, "x2_mm": {"type": "number"}, "y2_mm": {"type": "number"}, + "z1_mm": {"type": "number", "default": 0}, "z2_mm": {"type": "number", "default": 0}, "construction": {"type": "boolean", "default": False}, }, ["x1_mm", "y1_mm", "x2_mm", "y2_mm"], ) def draw_line(args: dict[str, Any]) -> dict[str, Any]: + a, b = _xyz(args, "x1_mm", "y1_mm", "z1_mm"), _xyz(args, "x2_mm", "y2_mm", "z2_mm") doc, manager = _require_open_sketch() + _check_z(doc, [a, b]) before = _segment_count(doc) segment = manager.CreateLine( - to_m(args["x1_mm"]), to_m(args["y1_mm"]), 0.0, - to_m(args["x2_mm"]), to_m(args["y2_mm"]), 0.0, + *a, *b, ) if segment is not None and bool(args.get("construction", False)): segment.ConstructionGeometry = True @@ -423,14 +506,19 @@ def draw_slot(args: dict[str, Any]) -> dict[str, Any]: @tool( "draw_point", - "Add a sketch point, useful as a pierce/coincident reference. Millimetres.", - dict(_XY), + "Add a point to the open 2D/3D sketch. Coordinates are mm in sketch space (model space for 3D). Nonzero z_mm requires a 3D sketch; reports actual coordinates.", + {**_XY, "z_mm": {"type": "number", "default": 0}}, ["x_mm", "y_mm"], ) def draw_point(args: dict[str, Any]) -> dict[str, Any]: + coords = _xyz(args) doc, manager = _require_open_sketch() - point = manager.CreatePoint(to_m(args["x_mm"]), to_m(args["y_mm"]), 0.0) - return result(bool(point), "Added a sketch point." if point else "SOLIDWORKS did not create the point.") + _check_z(doc, [coords]) + point = manager.CreatePoint(*coords) + actual = [float(value(point, k)) for k in ("X", "Y", "Z")] if point is not None else None + confirmed = actual is not None and math.dist(actual, coords) <= 1e-5 + return result(confirmed, "Added a sketch point." if confirmed else "Sketch point creation or coordinates could not be confirmed.", + point_mm=[c * 1000 for c in actual] if actual is not None else None) @tool( @@ -442,7 +530,7 @@ def draw_point(args: dict[str, Any]) -> dict[str, Any]: "minItems": 2, "items": { "type": "object", - "properties": {"x_mm": {"type": "number"}, "y_mm": {"type": "number"}}, + "properties": {"x_mm": {"type": "number"}, "y_mm": {"type": "number"}, "z_mm": {"type": "number", "default": 0}}, "required": ["x_mm", "y_mm"], }, } @@ -450,10 +538,12 @@ def draw_point(args: dict[str, Any]) -> dict[str, Any]: ["points"], ) def draw_spline(args: dict[str, Any]) -> dict[str, Any]: + points = [_xyz(p) for p in args["points"]] doc, manager = _require_open_sketch() + _check_z(doc, points) flat: list[float] = [] - for point in args["points"]: - flat.extend([to_m(point["x_mm"]), to_m(point["y_mm"]), 0.0]) + for point in points: + flat.extend(point) from .sw_core import double_array before = _segment_count(doc) diff --git a/solidworks_mcp/sw_wrap.py b/solidworks_mcp/sw_wrap.py index 3420b91..273f140 100644 --- a/solidworks_mcp/sw_wrap.py +++ b/solidworks_mcp/sw_wrap.py @@ -9,6 +9,7 @@ as_list, clear_selection, exit_active_sketch, feature_manager, find_feature, flag_methods, get_bodies, nothing, require_part, require_selection, result, safe, tool, value, SELECTION_SCHEMA, rebuild, whats_wrong, _surface_details, + persistent_reference_id as _reference_id, ) from .sw_feature import _feature_names, _feature_created_after from .sw_multibody import _finish @@ -82,12 +83,6 @@ def _geometry(doc): "solid_face_count": sum(len(as_list(value(b, "GetFaces"))) for b in bodies)} -def _reference_id(doc, obj): - raw = flag_methods(value(doc, "Extension"), "GetPersistReference3").GetPersistReference3(obj) - # pywin32 can return a memoryview; freeze the native buffer before rollback/rebuild. - return bytes(raw) if raw is not None else b"" - - def _target_details(face): if face is None: return None diff --git a/tests/live_sketch3d_regression.py b/tests/live_sketch3d_regression.py new file mode 100644 index 0000000..7155c1e --- /dev/null +++ b/tests/live_sketch3d_regression.py @@ -0,0 +1,94 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Native XYZ geometry and 2D/3D sketch lifecycle on scratch documents.""" +import math +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS, require_part, sketch_segment_objects, value, flag_methods + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + try: + for kind in ("part", "assembly"): + scratch.append(call("create_new_document", kind=kind)["document"]["title"]) + assert call("create_3d_sketch", name="Spatial")["is_3d"] + call("draw_line", x1_mm=0, y1_mm=0, z1_mm=0, x2_mm=10, y2_mm=20, z2_mm=30) + point = call("draw_point", x_mm=5, y_mm=-4, z_mm=7) + assert math.dist(point["point_mm"], [5, -4, 7]) < .001 + curve_points = [[20, 0, 0], [25, 5, 8], [30, 0, 15]] + call("draw_spline", points=[dict(zip(("x_mm", "y_mm", "z_mm"), p)) for p in curve_points]) + segments = call("list_sketch_segments") + assert segments["sketch"] == "Spatial" and len(segments["segments"]) == 2 + line = next(s for s in segments["segments"] if s["type"] == "line") + assert math.dist(line["start_mm"], [0, 0, 0]) < .001 + assert math.dist(line["end_mm"], [10, 20, 30]) < .001 + assert abs(line["length_mm"] - math.sqrt(1400)) < .001 + points = call("list_sketch_points") + assert points["is_3d"] and points["sketch"] == "Spatial" + assert len(points["points"]) == 6 + assert any(p["native_type"] == 1 and math.dist(p["model_point_mm"], [5, -4, 7]) < .001 for p in points["points"]) + if kind == "part": + _, doc = require_part() + spline = next(s for s in sketch_segment_objects(doc, "Spatial") if value(s, "GetType") == 3) + curve = flag_methods(value(spline, "GetCurve"), "GetClosestPointOn") + gaps = [math.dist([c / 1000 for c in p], curve.GetClosestPointOn(*[c / 1000 for c in p])[:3]) * 1000 for p in curve_points] + assert max(gaps) < .01 + assert call("close_sketch")["is_3d"] + if kind == "part": + call("create_sketch", plane="front", name="Planar") + for tool, args in (("draw_line", {"x1_mm": 0, "y1_mm": 0, "z1_mm": 1, "x2_mm": 1, "y2_mm": 1}), + ("draw_point", {"x_mm": 1, "y_mm": 1, "z_mm": 1}), + ("draw_spline", {"points": [{"x_mm": 0, "y_mm": 0}, {"x_mm": 1, "y_mm": 1, "z_mm": 1}]})): + try: HANDLERS[tool](args) + except RuntimeError as exc: assert "Nonzero Z" in str(exc) + else: raise AssertionError("2D geometry silently ignored Z.") + assert call("list_sketch_segments")["segments"] == [] + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + assert not call("close_sketch")["is_3d"] + edited = call("edit_sketch", sketch_name="Spatial") + assert edited["is_3d"] and edited["reference_confirmed"] + assert call("list_sketch_points")["sketch"] == "Spatial" + assert call("list_sketch_segments")["sketch"] == "Spatial" + call("set_construction_geometry", selection={"sketch_name": "Spatial", "sketch_segments": [0]}, construction=True) + assert call("list_sketch_segments")["segments"][0]["construction"] + call("set_construction_geometry", selection={"sketch_name": "Spatial", "sketch_segments": [0]}, construction=False) + assert not call("list_sketch_segments")["segments"][0]["construction"] + relation = call("add_relation", relation="fixed", selection={"sketch_name": "Spatial", "sketch_segments": [0]}) + print("XYZ_FIXED_RELATION", kind, relation, flush=True) + rejected = HANDLERS["create_3d_sketch"]({"name": "Unexpected"}) + assert not rejected["ok"] + if kind == "part": assert not HANDLERS["create_sketch"]({"plane": "front"})["ok"] + assert call("list_sketch_points")["is_3d"] + call("draw_point", x_mm=-5, y_mm=4, z_mm=15) + closed = call("close_sketch") + assert closed["sketch"] == "Spatial" and closed["is_3d"] + points = call("list_sketch_points", sketch_name="Spatial") + assert sum(p["native_type"] == 1 for p in points["points"]) == 2 + sketches = call("list_sketches") + assert {d["name"]: d["is_3d"] for d in sketches["sketch_details"]}["Spatial"] + if kind == "part": + call("edit_sketch", sketch_name="Spatial") + call("create_plane", mode="offset", selection={"planes": ["front"]}, distance_mm=5, name="AutoExitPlane") + assert not call("list_sketches")["sketch_open"] + call("create_3d_sketch", name="TubePath") + call("draw_line", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=20, z2_mm=30) + call("surface_sweep", path_sketch="TubePath", circular_diameter_mm=2, name="SpatialTube") + assert not call("list_sketches")["sketch_open"] + area = sum(b["area_mm2"] for b in call("list_surface_bodies")["surface_bodies"]) + assert abs(area - 2 * math.pi * math.sqrt(1400)) < .01 + print("XYZ_SWEEP_AREA", area, flush=True) + print("XYZ/LIFECYCLE", kind, segments["segments"], points["points"], flush=True) + print("3D part/assembly line, point, spline, readback, reopen identity and 2D Z guards passed.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original in opened: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_sketch3d.py b/tests/test_sketch3d.py new file mode 100644 index 0000000..78278a4 --- /dev/null +++ b/tests/test_sketch3d.py @@ -0,0 +1,135 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +from contextlib import ExitStack +from types import SimpleNamespace +from unittest.mock import Mock, patch +import unittest +import pythoncom +from solidworks_mcp import sw_core as core, sw_sketch as sketch + + +class Sketch3DContracts(unittest.TestCase): + def test_3d_features_are_resolved_and_listed_with_2d_features(self): + planar = SimpleNamespace(Name="Planar", GetTypeName2=lambda: "ProfileFeature") + spatial = SimpleNamespace(Name="Spatial", GetTypeName2=lambda: "3DProfileFeature") + with patch.object(core, "iter_feature_objects", return_value=[planar, spatial]): + self.assertEqual(core.sketch_names("doc"), ["Planar", "Spatial"]) + self.assertEqual(core.latest_sketch("doc"), ("Spatial", spatial)) + with patch.object(core, "find_feature", return_value=spatial): + self.assertEqual(core.resolve_sketch("doc", "Spatial"), ("Spatial", spatial)) + + def test_reopened_older_sketch_is_not_named_after_newest_feature(self): + older, newer = object(), object() + features = [SimpleNamespace(Name="Spatial", GetSpecificFeature2=lambda: older), + SimpleNamespace(Name="Planar", GetSpecificFeature2=lambda: newer)] + with patch.object(core, "sketch_features", return_value=features), patch.object(core, "persistent_reference_id", side_effect=lambda d, o: b"old" if o is older else b"new"): + self.assertEqual(core.sketch_name_for_object("doc", older), "Spatial") + + def test_modern_void_toggle_never_falls_back_or_toggles_2d(self): + modern, legacy = Mock(return_value=None), Mock() + doc = SimpleNamespace(Insert3DSketch2=modern, Insert3DSketch=legacy) + core.toggle_sketch(doc, True) + modern.assert_called_once_with(True) + legacy.assert_not_called() + + def test_legacy_toggle_only_when_modern_member_is_absent(self): + legacy = Mock() + core.toggle_sketch(SimpleNamespace(Insert3DSketch=legacy), True) + legacy.assert_called_once_with() + + def test_native_toggle_exception_is_not_retried(self): + legacy = Mock() + doc = SimpleNamespace(Insert3DSketch2=Mock(side_effect=RuntimeError("native error")), Insert3DSketch=legacy) + with self.assertRaisesRegex(RuntimeError, "native error"): + core.toggle_sketch(doc, True) + legacy.assert_not_called() + + def test_auto_exit_uses_the_native_active_sketch_mode(self): + manager = SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True), InsertSketch=Mock()) + close = Mock(side_effect=lambda update: setattr(manager, "ActiveSketch", None)) + doc = SimpleNamespace(SketchManager=manager, Insert3DSketch2=close) + core.exit_active_sketch(doc) + close.assert_called_once_with(True) + manager.InsertSketch.assert_not_called() + + def test_close_must_actually_exit_the_sketch(self): + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True)), Insert3DSketch2=lambda *a: None) + with self.assertRaisesRegex(RuntimeError, "did not close"): + core.exit_active_sketch(doc) + + def test_creators_refuse_to_toggle_an_existing_edit_context(self): + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=object())) + with patch.object(sketch, "active_document", return_value=(None, doc)), patch.object(sketch, "document_type", return_value=1), patch.object(sketch, "require_part", return_value=(None, doc)), patch.object(sketch, "toggle_sketch") as toggle: + self.assertFalse(sketch.create_3d_sketch({})["ok"]) + self.assertFalse(sketch.create_sketch({"plane": "front"})["ok"]) + toggle.assert_not_called() + + def test_same_dimension_wrong_sketch_reference_is_not_success(self): + expected = SimpleNamespace(Is3D=lambda: True) + other = SimpleNamespace(Is3D=lambda: True) + feature = SimpleNamespace(GetSpecificFeature2=lambda: expected) + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=None)) + with ExitStack() as stack: + for name, returned in (("active_document", (None, doc)), ("document_type", 1), + ("resolve_sketch", ("Spatial", feature)), ("select_by_id", True)): + stack.enter_context(patch.object(sketch, name, return_value=returned)) + stack.enter_context(patch.object(sketch, "clear_selection")) + stack.enter_context(patch.object(sketch, "persistent_reference_id", side_effect=[b"wanted", b"other"])) + stack.enter_context(patch.object(sketch, "toggle_sketch", side_effect=lambda *a: setattr(doc.SketchManager, "ActiveSketch", other))) + payload = sketch.edit_sketch({"sketch_name": "Spatial"}) + self.assertFalse(payload["ok"]) + self.assertFalse(payload["data"]["reference_confirmed"]) + + def test_2d_z_rejection_happens_before_native_geometry_creation(self): + manager = SimpleNamespace(CreateLine=Mock(), CreatePoint=Mock(), CreateSpline=Mock()) + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: False))) + for handler, args in ((sketch.draw_line, {"x1_mm": 0, "y1_mm": 0, "x2_mm": 1, "y2_mm": 1, "z2_mm": 1}), + (sketch.draw_point, {"x_mm": 1, "y_mm": 1, "z_mm": 1}), + (sketch.draw_spline, {"points": [{"x_mm": 0, "y_mm": 0}, {"x_mm": 1, "y_mm": 1, "z_mm": 1}]})): + with patch.object(sketch, "_require_open_sketch", return_value=(doc, manager)): + with self.assertRaisesRegex(RuntimeError, "Nonzero Z"): + handler(args) + for method in (manager.CreateLine, manager.CreatePoint, manager.CreateSpline): method.assert_not_called() + + def test_line_preserves_xyz_and_converts_mm_to_native_metres(self): + create = Mock(return_value=SimpleNamespace()) + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True))) + with patch.object(sketch, "_require_open_sketch", return_value=(doc, SimpleNamespace(CreateLine=create))), patch.object(sketch, "_segment_count", return_value=0), patch.object(sketch, "_drawn", return_value={"ok": True}): + sketch.draw_line({"x1_mm": 1, "y1_mm": 2, "z1_mm": 3, "x2_mm": 4, "y2_mm": 5, "z2_mm": 6}) + create.assert_called_once_with(.001, .002, .003, .004, .005, .006) + + def test_spline_uses_ordered_xyz_r8_array(self): + create = Mock() + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True))) + with patch.object(sketch, "_require_open_sketch", return_value=(doc, SimpleNamespace(CreateSpline=create))), patch.object(sketch, "_segment_count", return_value=0), patch.object(sketch, "_drawn", return_value={"ok": True}): + sketch.draw_spline({"points": [{"x_mm": 1, "y_mm": 2, "z_mm": 3}, {"x_mm": 4, "y_mm": 5, "z_mm": 6}]}) + array = create.call_args.args[0] + self.assertEqual(array.varianttype, pythoncom.VT_ARRAY | pythoncom.VT_R8) + self.assertEqual(tuple(array.value), (.001, .002, .003, .004, .005, .006)) + + def test_nonnull_point_with_wrong_coordinates_is_not_success(self): + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True))) + manager = SimpleNamespace(CreatePoint=lambda *a: SimpleNamespace(X=.005, Y=0, Z=0)) + with patch.object(sketch, "_require_open_sketch", return_value=(doc, manager)): + payload = sketch.draw_point({"x_mm": 5, "y_mm": -4, "z_mm": 7}) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["point_mm"], [5, 0, 0]) + + def test_named_point_enumeration_reports_local_and_model_positions(self): + matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, .035, 1, 0, 0, 0] + specific = SimpleNamespace(Is3D=lambda: False, GetSketchPoints2=lambda: [SimpleNamespace(X=.003, Y=0, Z=0, Type=1)], + ModelToSketchTransform=SimpleNamespace(Inverse=SimpleNamespace(ArrayData=matrix))) + feature = SimpleNamespace(GetSpecificFeature2=lambda: specific) + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=None)) + with patch.object(sketch, "active_document", return_value=(None, doc)), patch.object(sketch, "document_type", return_value=1), patch.object(sketch, "resolve_sketch", return_value=("Apex", feature)): + points = sketch.list_sketch_points({"sketch_name": "Apex"})["data"]["points"] + self.assertEqual(points[0]["index"], 0) + self.assertEqual(points[0]["point_mm"], [3, 0, 0]) + self.assertEqual(points[0]["model_point_mm"], [3, 0, 35]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index f27de2e..d6b336c 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -31,7 +31,7 @@ "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data", "preview_surface_trim", "trim_surface", "get_surface_trim_data", "boundary_surface", "get_boundary_feature_data"], - "swFmProfileFeature": ["create_sketch"], "swFmWeldMemberFeat": ["insert_structural_member"], + "swFmProfileFeature": ["create_sketch", "create_3d_sketch", "list_sketch_points"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], } diff --git a/tools/verify_install.py b/tools/verify_install.py index abffb09..ee18be3 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -63,6 +63,7 @@ async def rejected_call(name, arguments): title = None wrap_title = None dome_title = None + sketch3d_title = None try: if scratch: title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] @@ -274,7 +275,41 @@ async def rejected_call(name, arguments): rejected = await rejected_call("set_dome_parameters", {"name": "InstalledDome", "clear_constraint": True, "height_mm": 5}) if rejected["parameters_confirmed"] or not rejected["dome"]["has_constraint"] or rejected["dome"]["constraint_reference_confirmed"]: raise RuntimeError("Installed MCP ignored-constraint-clear readback differs.") + if scratch: + sketch3d_title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] + await call("create_3d_sketch", {"name": "InstalledSpatial"}) + await call("draw_line", {"x1_mm": 0, "y1_mm": 0, "x2_mm": 10, "y2_mm": 20, "z2_mm": 30}) + point = await call("draw_point", {"x_mm": 5, "y_mm": -4, "z_mm": 7}) + if math.dist(point["point_mm"], [5, -4, 7]) > .001: + raise RuntimeError("Installed MCP XYZ point differs.") + await call("draw_spline", {"points": [{"x_mm": 20, "y_mm": 0, "z_mm": 0}, + {"x_mm": 25, "y_mm": 5, "z_mm": 8}, {"x_mm": 30, "y_mm": 0, "z_mm": 15}]}) + segments = (await call("list_sketch_segments"))["segments"] + line = next(s for s in segments if s["type"] == "line") + if math.dist(line["end_mm"], [10, 20, 30]) > .001 or abs(line["length_mm"] - math.sqrt(1400)) > .001: + raise RuntimeError("Installed MCP XYZ line geometry differs.") + points = await call("list_sketch_points") + if not points["is_3d"] or points["sketch"] != "InstalledSpatial" or len(points["points"]) != 6: + raise RuntimeError("Installed MCP 3D sketch point enumeration differs.") + if not (await call("close_sketch"))["is_3d"]: + raise RuntimeError("Installed MCP closed the wrong sketch mode.") + await call("create_sketch", {"plane": "front", "name": "InstalledPlanar"}) + await call("draw_point", {"x_mm": 1, "y_mm": 2}) + await call("close_sketch") + reopened = await call("edit_sketch", {"sketch_name": "InstalledSpatial"}) + if not reopened["is_3d"] or not reopened["reference_confirmed"] or (await call("list_sketch_points"))["sketch"] != "InstalledSpatial": + raise RuntimeError("Installed MCP reopened-sketch identity or name differs.") + await rejected_call("create_3d_sketch", {"name": "Unexpected"}) + await call("close_sketch") + await call("create_3d_sketch", {"name": "InstalledTubePath"}) + await call("draw_line", {"x1_mm": 0, "y1_mm": 0, "x2_mm": 10, "y2_mm": 20, "z2_mm": 30}) + await call("surface_sweep", {"path_sketch": "InstalledTubePath", "circular_diameter_mm": 2}) + area = sum(b["area_mm2"] for b in (await call("list_surface_bodies"))["surface_bodies"]) + if abs(area - 2 * math.pi * math.sqrt(1400)) > .01 or (await call("list_sketches"))["sketch_open"]: + raise RuntimeError("Installed MCP 3D path sweep geometry or automatic sketch exit differs.") finally: + if sketch3d_title: + await call("close_document", {"name": sketch3d_title, "discard_changes": True}) if dome_title: await call("close_document", {"name": dome_title, "discard_changes": True}) if wrap_title: From faa516f1aae811ab9d2a7f7a0127b82e9e1fe50e Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:34:42 +0800 Subject: [PATCH 23/31] =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=9C=86=E5=88=9B=E5=BB=BA=E5=8F=97=E8=87=AA=E5=8A=A8=E6=8E=A8?= =?UTF-8?q?=E6=96=AD=E5=BD=B1=E5=93=8D=E5=B9=B6=E6=A0=B8=E9=AA=8C=E5=9C=86?= =?UTF-8?q?=E5=BF=83=E5=8D=8A=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 2 ++ solidworks_mcp/sw_sketch.py | 26 ++++++++++++++++++++++---- tests/test_sketch3d.py | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index 197728f..bc9b21a 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -203,6 +203,8 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 距离另行核验。点读取保留内部/用户/样条点与原生索引,同时返回草图和模型坐标。 2D 非零 Z 在创建前拒绝;已有编辑状态时拒绝再次创建,避免误关闭已有草图。 零件与装配体另验证 3D 线的构造几何切换和固定约束添加,未据此宣称完整约束覆盖。 +安装回归另发现原生圆创建在自动推断模式下偶发返回空;圆创建局部使用 `AddToDB` +并在成功或异常后恢复原值,随后核验圆心、半径及新增几何,不盲目重试创建。 3D 直线路径圆截面扫描半径 1 mm、路径长度 √1400 mm,实际面积 235.095267 mm²,并验证创建特征前自动退出 3D 草图。3D 圆弧/圆等其他几何、 工作平面、完整约束/驱动尺寸、复杂扫描/放样组合与旧版软件仍待覆盖或专项验证。 diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 7af9371..92856a6 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -359,12 +359,30 @@ def draw_centerline(args: dict[str, Any]) -> dict[str, Any]: def draw_circle(args: dict[str, Any]) -> dict[str, Any]: doc, manager = _require_open_sketch() before = _segment_count(doc) - segment = manager.CreateCircleByRadius( - to_m(args["x_mm"]), to_m(args["y_mm"]), 0.0, to_m(args["radius_mm"]), - ) + previous = manager.AddToDB + try: + # Native documentation recommends bypassing UI inference for this API. + manager.AddToDB = True + segment = manager.CreateCircleByRadius( + to_m(args["x_mm"]), to_m(args["y_mm"]), 0.0, to_m(args["radius_mm"]), + ) + finally: + manager.AddToDB = previous if segment is not None and bool(args.get("construction", False)): segment.ConstructionGeometry = True - return _drawn(doc, before, "a circle") + payload = _drawn(doc, before, "a circle") + if payload["ok"]: + if segment is None: + return result(False, "Circle geometry appeared, but its native reference could not be confirmed.") + center = value(segment, "GetCenterPoint2") + coords = [float(value(center, k)) * 1000 for k in ("X", "Y", "Z")] + radius = float(value(segment, "GetRadius")) * 1000 + confirmed = math.dist(coords, [float(args["x_mm"]), float(args["y_mm"]), 0]) <= .01 and math.isclose(radius, float(args["radius_mm"]), abs_tol=.01, rel_tol=0) + payload.update(ok=confirmed) + payload["data"].update(center_mm=coords, radius_mm=radius) + if not confirmed: + payload["message"] = "Circle created, but requested center or radius could not be confirmed." + return payload @tool( diff --git a/tests/test_sketch3d.py b/tests/test_sketch3d.py index 78278a4..620a5b0 100644 --- a/tests/test_sketch3d.py +++ b/tests/test_sketch3d.py @@ -118,6 +118,31 @@ def test_nonnull_point_with_wrong_coordinates_is_not_success(self): self.assertFalse(payload["ok"]) self.assertEqual(payload["data"]["point_mm"], [5, 0, 0]) + def test_circle_bypasses_inference_and_restores_original_state(self): + for original in (False, True): + manager = SimpleNamespace(AddToDB=original) + def create(*coords): + self.assertTrue(manager.AddToDB) + self.assertEqual(coords, (.093, .095, 0, .001)) + return SimpleNamespace(GetCenterPoint2=lambda: SimpleNamespace(X=.093, Y=.095, Z=0), GetRadius=lambda: .001) + manager.CreateCircleByRadius = create + with patch.object(sketch, "_require_open_sketch", return_value=(None, manager)), patch.object(sketch, "_segment_count", side_effect=[0, 1]): + self.assertTrue(sketch.draw_circle({"x_mm": 93, "y_mm": 95, "radius_mm": 1})["ok"]) + self.assertEqual(manager.AddToDB, original) + + def test_circle_restores_inference_after_native_exception(self): + manager = SimpleNamespace(AddToDB=False, CreateCircleByRadius=Mock(side_effect=RuntimeError("native rejection"))) + with patch.object(sketch, "_require_open_sketch", return_value=(None, manager)), patch.object(sketch, "_segment_count", return_value=0): + with self.assertRaisesRegex(RuntimeError, "native rejection"): + sketch.draw_circle({"x_mm": 93, "y_mm": 95, "radius_mm": 1}) + self.assertFalse(manager.AddToDB) + + def test_circle_wrong_radius_is_not_success(self): + segment = SimpleNamespace(GetCenterPoint2=lambda: SimpleNamespace(X=.093, Y=.095, Z=0), GetRadius=lambda: .002) + manager = SimpleNamespace(AddToDB=False, CreateCircleByRadius=lambda *a: segment) + with patch.object(sketch, "_require_open_sketch", return_value=(None, manager)), patch.object(sketch, "_segment_count", side_effect=[0, 1]): + self.assertFalse(sketch.draw_circle({"x_mm": 93, "y_mm": 95, "radius_mm": 1})["ok"]) + def test_named_point_enumeration_reports_local_and_model_positions(self): matrix = [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, .035, 1, 0, 0, 0] specific = SimpleNamespace(Is3D=lambda: False, GetSketchPoints2=lambda: [SimpleNamespace(X=.003, Y=0, Z=0, Type=1)], From b446ccbdf2304548c3659b8ae0e0370878e60f9f Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:38:24 +0800 Subject: [PATCH 24/31] =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=B1=80=E9=83=A8?= =?UTF-8?q?=E6=8E=A8=E6=96=AD=E6=8E=A7=E5=88=B6=E5=B9=B6=E8=AF=BB=E5=9B=9E?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E7=BA=BF=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 4 ++-- solidworks_mcp/sw_sketch.py | 38 ++++++++++++++++++++++++------------- tests/test_sketch3d.py | 20 +++++++++++++++++-- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index bc9b21a..45dce44 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -203,8 +203,8 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 距离另行核验。点读取保留内部/用户/样条点与原生索引,同时返回草图和模型坐标。 2D 非零 Z 在创建前拒绝;已有编辑状态时拒绝再次创建,避免误关闭已有草图。 零件与装配体另验证 3D 线的构造几何切换和固定约束添加,未据此宣称完整约束覆盖。 -安装回归另发现原生圆创建在自动推断模式下偶发返回空;圆创建局部使用 `AddToDB` -并在成功或异常后恢复原值,随后核验圆心、半径及新增几何,不盲目重试创建。 +安装回归另发现原生圆和线创建在自动推断模式下偶发返回空;创建局部使用 `AddToDB` +并在成功或异常后恢复原值,随后核验线端点、圆心、半径及新增几何,不盲目重试创建。 3D 直线路径圆截面扫描半径 1 mm、路径长度 √1400 mm,实际面积 235.095267 mm²,并验证创建特征前自动退出 3D 草图。3D 圆弧/圆等其他几何、 工作平面、完整约束/驱动尺寸、复杂扫描/放样组合与旧版软件仍待覆盖或专项验证。 diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 92856a6..b8a21fd 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -303,6 +303,15 @@ def _check_z(doc, points): raise RuntimeError("Nonzero Z requires an open 3D sketch; 2D sketch coordinates lie in its XY plane.") +def _create_without_inference(manager, method, *coords): + previous = manager.AddToDB + try: + manager.AddToDB = True + return getattr(manager, method)(*coords) + finally: + manager.AddToDB = previous + + @tool( "draw_line", "Add a line to the open 2D/3D sketch. Coordinates are mm in sketch space (model space for 3D). Nonzero z1_mm/z2_mm requires a 3D sketch.", @@ -319,12 +328,22 @@ def draw_line(args: dict[str, Any]) -> dict[str, Any]: doc, manager = _require_open_sketch() _check_z(doc, [a, b]) before = _segment_count(doc) - segment = manager.CreateLine( - *a, *b, - ) + segment = _create_without_inference(manager, "CreateLine", *a, *b) if segment is not None and bool(args.get("construction", False)): segment.ConstructionGeometry = True - return _drawn(doc, before, "a line") + payload = _drawn(doc, before, "a line") + if payload["ok"]: + if segment is None: + return result(False, "Line geometry appeared, but its native reference could not be confirmed.") + endpoints = [[float(value(value(segment, method), k)) for k in ("X", "Y", "Z")] + for method in ("GetStartPoint2", "GetEndPoint2")] + confirmed = min(max(math.dist(endpoints[0], a), math.dist(endpoints[1], b)), + max(math.dist(endpoints[0], b), math.dist(endpoints[1], a))) <= 1e-5 + payload.update(ok=confirmed) + payload["data"].update(start_mm=[c * 1000 for c in endpoints[0]], end_mm=[c * 1000 for c in endpoints[1]]) + if not confirmed: + payload["message"] = "Line created, but requested endpoints could not be confirmed." + return payload @tool( @@ -359,15 +378,8 @@ def draw_centerline(args: dict[str, Any]) -> dict[str, Any]: def draw_circle(args: dict[str, Any]) -> dict[str, Any]: doc, manager = _require_open_sketch() before = _segment_count(doc) - previous = manager.AddToDB - try: - # Native documentation recommends bypassing UI inference for this API. - manager.AddToDB = True - segment = manager.CreateCircleByRadius( - to_m(args["x_mm"]), to_m(args["y_mm"]), 0.0, to_m(args["radius_mm"]), - ) - finally: - manager.AddToDB = previous + segment = _create_without_inference(manager, "CreateCircleByRadius", + to_m(args["x_mm"]), to_m(args["y_mm"]), 0.0, to_m(args["radius_mm"])) if segment is not None and bool(args.get("construction", False)): segment.ConstructionGeometry = True payload = _drawn(doc, before, "a circle") diff --git a/tests/test_sketch3d.py b/tests/test_sketch3d.py index 620a5b0..968432b 100644 --- a/tests/test_sketch3d.py +++ b/tests/test_sketch3d.py @@ -95,9 +95,9 @@ def test_2d_z_rejection_happens_before_native_geometry_creation(self): for method in (manager.CreateLine, manager.CreatePoint, manager.CreateSpline): method.assert_not_called() def test_line_preserves_xyz_and_converts_mm_to_native_metres(self): - create = Mock(return_value=SimpleNamespace()) + create = Mock(return_value=SimpleNamespace(GetStartPoint2=lambda: SimpleNamespace(X=.001, Y=.002, Z=.003), GetEndPoint2=lambda: SimpleNamespace(X=.004, Y=.005, Z=.006))) doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True))) - with patch.object(sketch, "_require_open_sketch", return_value=(doc, SimpleNamespace(CreateLine=create))), patch.object(sketch, "_segment_count", return_value=0), patch.object(sketch, "_drawn", return_value={"ok": True}): + with patch.object(sketch, "_require_open_sketch", return_value=(doc, SimpleNamespace(AddToDB=False, CreateLine=create))), patch.object(sketch, "_segment_count", return_value=0), patch.object(sketch, "_drawn", return_value={"ok": True, "data": {}}): sketch.draw_line({"x1_mm": 1, "y1_mm": 2, "z1_mm": 3, "x2_mm": 4, "y2_mm": 5, "z2_mm": 6}) create.assert_called_once_with(.001, .002, .003, .004, .005, .006) @@ -137,6 +137,22 @@ def test_circle_restores_inference_after_native_exception(self): sketch.draw_circle({"x_mm": 93, "y_mm": 95, "radius_mm": 1}) self.assertFalse(manager.AddToDB) + def test_line_wrong_endpoint_is_not_success(self): + segment = SimpleNamespace(GetStartPoint2=lambda: SimpleNamespace(X=0, Y=0, Z=0), GetEndPoint2=lambda: SimpleNamespace(X=.009, Y=.020, Z=.030)) + manager = SimpleNamespace(AddToDB=False, CreateLine=lambda *a: segment) + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True))) + with patch.object(sketch, "_require_open_sketch", return_value=(doc, manager)), patch.object(sketch, "_segment_count", side_effect=[0, 1]): + self.assertFalse(sketch.draw_line({"x1_mm": 0, "y1_mm": 0, "x2_mm": 10, "y2_mm": 20, "z2_mm": 30})["ok"]) + self.assertFalse(manager.AddToDB) + + def test_line_restores_inference_after_native_exception(self): + manager = SimpleNamespace(AddToDB=True, CreateLine=Mock(side_effect=RuntimeError("native rejection"))) + doc = SimpleNamespace(SketchManager=SimpleNamespace(ActiveSketch=SimpleNamespace(Is3D=lambda: True))) + with patch.object(sketch, "_require_open_sketch", return_value=(doc, manager)), patch.object(sketch, "_segment_count", return_value=0): + with self.assertRaisesRegex(RuntimeError, "native rejection"): + sketch.draw_line({"x1_mm": 0, "y1_mm": 0, "x2_mm": 10, "y2_mm": 20, "z2_mm": 30}) + self.assertTrue(manager.AddToDB) + def test_circle_wrong_radius_is_not_success(self): segment = SimpleNamespace(GetCenterPoint2=lambda: SimpleNamespace(X=.093, Y=.095, Z=0), GetRadius=lambda: .002) manager = SimpleNamespace(AddToDB=False, CreateCircleByRadius=lambda *a: segment) From ad510ba79d29ab94902ad6d82d6f2bbba934081f Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:49:43 +0800 Subject: [PATCH 25/31] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=B8=89=E7=BB=B4?= =?UTF-8?q?=E6=9E=84=E9=80=A0=E7=BA=BF=E4=B8=8E=E4=B8=89=E7=82=B9=E5=9C=86?= =?UTF-8?q?=E5=BC=A7=E5=B9=B6=E6=A0=B8=E9=AA=8C=E5=8E=9F=E7=94=9F=E7=A9=BA?= =?UTF-8?q?=E9=97=B4=E5=87=A0=E4=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 8 +++- README.md | 3 ++ solidworks_mcp/sw_sketch.py | 61 ++++++++++++++++++++-------- tests/live_spatial_arc_regression.py | 48 ++++++++++++++++++++++ tests/test_spatial_arc.py | 53 ++++++++++++++++++++++++ tools/verify_install.py | 12 ++++++ 6 files changed, 168 insertions(+), 17 deletions(-) create mode 100644 tests/live_spatial_arc_regression.py create mode 100644 tests/test_spatial_arc.py diff --git a/COVERAGE.md b/COVERAGE.md index 45dce44..b0e3b09 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -206,7 +206,13 @@ Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 安装回归另发现原生圆和线创建在自动推断模式下偶发返回空;创建局部使用 `AddToDB` 并在成功或异常后恢复原值,随后核验线端点、圆心、半径及新增几何,不盲目重试创建。 3D 直线路径圆截面扫描半径 1 mm、路径长度 √1400 mm,实际面积 -235.095267 mm²,并验证创建特征前自动退出 3D 草图。3D 圆弧/圆等其他几何、 +235.095267 mm²,并验证创建特征前自动退出 3D 草图。 +XYZ 构造线绕开本机原生 `CreateCenterLine` 的 Z 压平行为,复用 `CreateLine` 并核验 +实际构造属性。三点圆弧在零件/装配体中验证 YZ 平面半径 5 mm 半圆和 270° 大圆弧, +弧长分别 15.707963/23.561945 mm;半径、弧长、端点和三个点的原生曲线距离均核验。 +重合/共线输入前置拒绝。重叠共端点组合原生结果偏离输入,返回失败并保留特征; +倾斜 3D 椭圆虽返回对象,但原生长度为 -1 且 GetCurve 为空,尚未宣称支持。 +3D 圆/其他圆弧变体等其他几何、 工作平面、完整约束/驱动尺寸、复杂扫描/放样组合与旧版软件仍待覆盖或专项验证。 ## 原生 API 清点 diff --git a/README.md b/README.md index 4c23985..6e89387 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,9 @@ parallel-through-point), `create_axis`. 2D 草图拒绝非零 Z,避免原生接口忽略坐标。编辑与关闭根据原生 `Is3D` 选用对应接口, 编辑时核验持久引用;读取较早草图时不误报为最新草图名称。 `list_sketch_points` 包含内部点与用户点,返回选择索引、类型和草图/模型坐标。 +`draw_centerline` 支持 XYZ 构造线,并核验端点与构造属性;`draw_3point_arc` 支持 +三点 XYZ 圆弧,核验半径、弧长、端点和点到曲线距离,共线/重合输入在创建前拒绝。 +重叠/共端点圆弧原生结果曾偏离输入,返回未确认状态。3D 椭圆原生对象未得到有效曲线, 其他 3D 曲线、工作平面、完整约束/尺寸与更多建模组合仍有缺口。 Geometry: `draw_line`, `draw_centerline`, `draw_circle`, `draw_rectangle`, diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index b8a21fd..a0e9de8 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -339,30 +339,27 @@ def draw_line(args: dict[str, Any]) -> dict[str, Any]: for method in ("GetStartPoint2", "GetEndPoint2")] confirmed = min(max(math.dist(endpoints[0], a), math.dist(endpoints[1], b)), max(math.dist(endpoints[0], b), math.dist(endpoints[1], a))) <= 1e-5 + if args.get("construction", False): + confirmed = confirmed and bool(value(segment, "ConstructionGeometry")) payload.update(ok=confirmed) payload["data"].update(start_mm=[c * 1000 for c in endpoints[0]], end_mm=[c * 1000 for c in endpoints[1]]) if not confirmed: - payload["message"] = "Line created, but requested endpoints could not be confirmed." + payload["message"] = "Line created, but requested endpoints or construction state could not be confirmed." return payload @tool( "draw_centerline", - "Add a construction centerline to the open sketch, for revolve axes and symmetry. Millimetres.", + "Add a verified construction line to the open 2D/3D sketch. XYZ coordinates are millimetres; nonzero Z requires 3D. Uses native CreateLine because CreateCenterLine may flatten Z.", { "x1_mm": {"type": "number"}, "y1_mm": {"type": "number"}, "x2_mm": {"type": "number"}, "y2_mm": {"type": "number"}, + "z1_mm": {"type": "number", "default": 0}, "z2_mm": {"type": "number", "default": 0}, }, ["x1_mm", "y1_mm", "x2_mm", "y2_mm"], ) def draw_centerline(args: dict[str, Any]) -> dict[str, Any]: - doc, manager = _require_open_sketch() - before = _segment_count(doc) - manager.CreateCenterLine( - to_m(args["x1_mm"]), to_m(args["y1_mm"]), 0.0, - to_m(args["x2_mm"]), to_m(args["y2_mm"]), 0.0, - ) - return _drawn(doc, before, "a centerline") + return draw_line({**args, "construction": True}) @tool( @@ -442,23 +439,55 @@ def draw_arc(args: dict[str, Any]) -> dict[str, Any]: @tool( "draw_3point_arc", - "Add an arc through three points: start, end, and a point on the arc. Millimetres.", + "Add a verified 2D/3D arc through start, end and a point on the arc, in mm. Nonzero Z requires 3D. Rejects coincident or collinear input before creation and checks native endpoints, radius, length and curve distance.", { "x1_mm": {"type": "number"}, "y1_mm": {"type": "number"}, "x2_mm": {"type": "number"}, "y2_mm": {"type": "number"}, "x3_mm": {"type": "number"}, "y3_mm": {"type": "number"}, + "z1_mm": {"type": "number", "default": 0}, "z2_mm": {"type": "number", "default": 0}, "z3_mm": {"type": "number", "default": 0}, }, ["x1_mm", "y1_mm", "x2_mm", "y2_mm", "x3_mm", "y3_mm"], ) def draw_3point_arc(args: dict[str, Any]) -> dict[str, Any]: + points = [_xyz(args, f"x{i}_mm", f"y{i}_mm", f"z{i}_mm") for i in (1, 2, 3)] + a, b, c = points + u, v = [[q - p for p, q in zip(a, point)] for point in (b, c)] + def cross(x, y): + return [x[1]*y[2]-x[2]*y[1], x[2]*y[0]-x[0]*y[2], x[0]*y[1]-x[1]*y[0]] + def dot(x, y): + return sum(p*q for p, q in zip(x, y)) + w = cross(u, v) + area_squared = dot(w, w) + if min(math.dist(a, b), math.dist(a, c), math.dist(b, c)) <= 1e-9 or area_squared <= dot(u,u)*dot(v,v)*1e-16: + raise RuntimeError("Three-point arcs require distinct, non-collinear points.") + center = [a[i] + (dot(u,u)*cross(v,w)[i] + dot(v,v)*cross(w,u)[i])/(2*area_squared) for i in range(3)] + radial = [[p[i]-center[i] for i in range(3)] for p in points] + radius = math.sqrt(dot(radial[0], radial[0])) + normal = [x/math.sqrt(area_squared) for x in w] + def angle(target): + return math.atan2(dot(normal, cross(radial[0], target)), dot(radial[0], target)) % (2*math.pi) + end_angle, through_angle = angle(radial[1]), angle(radial[2]) + expected_length = radius*(end_angle if through_angle <= end_angle else 2*math.pi-end_angle) doc, manager = _require_open_sketch() + _check_z(doc, points) before = _segment_count(doc) - manager.Create3PointArc( - to_m(args["x1_mm"]), to_m(args["y1_mm"]), 0.0, - to_m(args["x2_mm"]), to_m(args["y2_mm"]), 0.0, - to_m(args["x3_mm"]), to_m(args["y3_mm"]), 0.0, - ) - return _drawn(doc, before, "a 3-point arc") + segment = _create_without_inference(manager, "Create3PointArc", *(q for p in points for q in p)) + payload = _drawn(doc, before, "a 3-point arc") + if not payload["ok"]: + return payload + if segment is None: + return result(False, "Arc appeared, but its native reference could not be confirmed.") + curve = value(segment, "GetCurve") + if curve is None: + return result(False, "Arc appeared, but its native curve could not be confirmed.") + flag_methods(curve, "GetClosestPointOn") + gaps = [math.dist(p, [float(q) for q in as_list(curve.GetClosestPointOn(*p))[:3]]) for p in points] + endpoints = [[float(value(value(segment, method), k)) for k in ("X", "Y", "Z")] for method in ("GetStartPoint2", "GetEndPoint2")] + endpoint_gap = min(max(math.dist(endpoints[0],a), math.dist(endpoints[1],b)), max(math.dist(endpoints[0],b), math.dist(endpoints[1],a))) + actual_radius, actual_length = float(value(segment,"GetRadius")), float(value(segment,"GetLength")) + confirmed = max(gaps+[endpoint_gap]) <= 1e-5 and abs(actual_radius-radius) <= 1e-5 and abs(actual_length-expected_length) <= 1e-5 + return result(confirmed, "Created and verified a 3-point arc." if confirmed else "Arc created, but requested geometry could not be confirmed.", + radius_mm=actual_radius*1000, length_mm=actual_length*1000, max_point_gap_mm=max(gaps+[endpoint_gap])*1000) @tool( diff --git a/tests/live_spatial_arc_regression.py b/tests/live_spatial_arc_regression.py new file mode 100644 index 0000000..e64a38d --- /dev/null +++ b/tests/live_spatial_arc_regression.py @@ -0,0 +1,48 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +"""Check native spatial construction lines and minor/major three-point arcs.""" +import math +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS + + +def run(): + original = call("list_open_documents")["active_title"] + scratch = [] + try: + for kind in ("part", "assembly"): + scratch.append(call("create_new_document", kind=kind)["document"]["title"]) + call("create_3d_sketch", name="SpatialArcs") + call("draw_centerline", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=20, z2_mm=30) + line = call("list_sketch_segments")["segments"][0] + assert line["construction"] and math.dist(line["end_mm"], [10,20,30]) < .001 + for middle, expected in (([20,5,5], math.pi*5), ([40,-5,5], 1.5*math.pi*5)): + end = [20,10,0] if middle[0] == 20 else [40,5,5] + start = [middle[0],0,0] + args = {f"{k}{i}_mm": v for i,p in enumerate((start,end,middle),1) for k,v in zip("xyz",p)} + arc = call("draw_3point_arc", **args) + assert math.isclose(arc["radius_mm"], 5, abs_tol=.01), arc + assert math.isclose(arc["length_mm"], expected, abs_tol=.01), arc + assert arc["max_point_gap_mm"] < .01 + count = len(call("list_sketch_segments")["segments"]) + try: + HANDLERS["draw_3point_arc"]({"x1_mm":0,"y1_mm":0,"x2_mm":1,"y2_mm":1,"x3_mm":2,"y3_mm":2}) + except RuntimeError as exc: + assert "non-collinear" in str(exc) + else: + raise AssertionError("Collinear arc input was not rejected.") + assert len(call("list_sketch_segments")["segments"]) == count + call("close_sketch") + print("Spatial centerlines and minor/major arcs passed for part/assembly.", flush=True) + finally: + opened = {d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened: call("close_document", name=title, discard_changes=True) + if original: call("activate_document", name=original) + + +if __name__ == "__main__": + run() diff --git a/tests/test_spatial_arc.py b/tests/test_spatial_arc.py new file mode 100644 index 0000000..443a565 --- /dev/null +++ b/tests/test_spatial_arc.py @@ -0,0 +1,53 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + +import math +import unittest +from types import SimpleNamespace as NS +from unittest.mock import Mock, patch +from solidworks_mcp import sw_sketch as sketch + + +class SpatialArcTests(unittest.TestCase): + def test_centerline_preserves_xyz_and_requires_construction(self): + args = dict(x1_mm=0,y1_mm=0,x2_mm=10,y2_mm=20,z2_mm=30) + with patch.object(sketch,"draw_line",return_value={"ok":True}) as draw: + sketch.draw_centerline(args) + draw.assert_called_once_with({**args,"construction":True}) + + def test_collinear_and_coincident_rejected_before_native_access(self): + for third in ((2,2,2),(0,0,0)): + args={f"{k}{i}_mm":v for i,p in enumerate(((0,0,0),(1,1,1),third),1) for k,v in zip("xyz",p)} + with patch.object(sketch,"_require_open_sketch") as native: + with self.assertRaisesRegex(RuntimeError,"non-collinear"): + sketch.draw_3point_arc(args) + native.assert_not_called() + + def test_nonzero_z_rejected_in_2d_before_creation(self): + doc=NS(SketchManager=NS(ActiveSketch=NS(Is3D=lambda:False))) + manager=NS(Create3PointArc=Mock()) + with patch.object(sketch,"_require_open_sketch",return_value=(doc,manager)): + with self.assertRaisesRegex(RuntimeError,"Nonzero Z"): + sketch.draw_3point_arc(dict(x1_mm=20,y1_mm=0,x2_mm=20,y2_mm=10,x3_mm=20,y3_mm=5,z3_mm=5)) + manager.Create3PointArc.assert_not_called() + + def test_major_arc_length_and_wrong_span_are_distinguished(self): + points=((.04,0,0),(.04,.005,.005),(.04,-.005,.005)) + args={f"{k}{i}_mm":v*1000 for i,p in enumerate(points,1) for k,v in zip("xyz",p)} + curve=NS(GetClosestPointOn=lambda *p:p) + doc=NS(SketchManager=NS(ActiveSketch=NS(Is3D=lambda:True))) + for length,ok in ((.005*1.5*math.pi,True),(.005*.5*math.pi,False)): + segment=NS(GetCurve=lambda:curve,GetRadius=lambda:.005,GetLength=lambda:length, + GetStartPoint2=lambda:NS(X=points[0][0],Y=points[0][1],Z=points[0][2]), + GetEndPoint2=lambda:NS(X=points[1][0],Y=points[1][1],Z=points[1][2])) + manager=NS(AddToDB=False,Create3PointArc=Mock(return_value=segment)) + with patch.object(sketch,"_require_open_sketch",return_value=(doc,manager)), patch.object(sketch,"_segment_count",side_effect=[0,1]), patch.object(sketch,"flag_methods",side_effect=lambda obj,*a:obj): + self.assertEqual(sketch.draw_3point_arc(args)["ok"],ok) + manager.Create3PointArc.assert_called_once_with(*(v for p in points for v in p)) + self.assertFalse(manager.AddToDB) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/verify_install.py b/tools/verify_install.py index ee18be3..80ffffb 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -307,6 +307,18 @@ async def rejected_call(name, arguments): area = sum(b["area_mm2"] for b in (await call("list_surface_bodies"))["surface_bodies"]) if abs(area - 2 * math.pi * math.sqrt(1400)) > .01 or (await call("list_sketches"))["sketch_open"]: raise RuntimeError("Installed MCP 3D path sweep geometry or automatic sketch exit differs.") + await call("create_3d_sketch", {"name": "InstalledSpatialArcs"}) + await call("draw_centerline", {"x1_mm": 80, "y1_mm": 0, "x2_mm": 90, "y2_mm": 20, "z2_mm": 30}) + construction = (await call("list_sketch_segments"))["segments"][0] + if not construction["construction"] or math.dist(construction["end_mm"], [90,20,30]) > .001: + raise RuntimeError("Installed MCP spatial construction line differs.") + for start, end, middle, angle in (([100,0,0],[100,10,0],[100,5,5],math.pi), + ([120,0,0],[120,5,5],[120,-5,5],1.5*math.pi)): + coordinates = {f"{k}{i}_mm": v for i,p in enumerate((start,end,middle),1) for k,v in zip("xyz",p)} + arc = await call("draw_3point_arc", coordinates) + if abs(arc["radius_mm"]-5) > .01 or abs(arc["length_mm"]-5*angle) > .01 or arc["max_point_gap_mm"] > .01: + raise RuntimeError("Installed MCP spatial arc geometry differs.") + await call("close_sketch") finally: if sketch3d_title: await call("close_document", {"name": sketch3d_title, "discard_changes": True}) From a076b5862dc0038fc835f341a57140a79c9a4592 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:06:59 +0800 Subject: [PATCH 26/31] =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E6=A4=AD=E5=9C=86?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=B8=8E=E7=A9=BA=E9=97=B4=E6=9B=B2=E7=BA=BF?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=E7=9A=84=E5=AE=9E=E9=99=85=E5=87=A0=E4=BD=95?= =?UTF-8?q?=E5=AF=B9=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 16 ++++ README.md | 6 +- solidworks_mcp/sw_sketch.py | 103 +++++++++++++++++++---- tests/live_spatial_convert_regression.py | 52 ++++++++++++ tests/test_spatial_convert.py | 68 +++++++++++++++ tools/verify_install.py | 11 +++ 6 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 tests/live_spatial_convert_regression.py create mode 100644 tests/test_spatial_convert.py diff --git a/COVERAGE.md b/COVERAGE.md index b0e3b09..188799f 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -215,6 +215,22 @@ XYZ 构造线绕开本机原生 `CreateCenterLine` 的 Z 压平行为,复用 ` 3D 圆/其他圆弧变体等其他几何、 工作平面、完整约束/驱动尺寸、复杂扫描/放样组合与旧版软件仍待覆盖或专项验证。 +## 空间圆和椭圆转换 + +2D 椭圆创建局部使用 `AddToDB` 并恢复原值,核验 24 个理论点和独立积分周长; +轴方向、非零半轴和长短轴关系前置检查。直接 3D 椭圆接口会产生无有效曲线的对象, +在修改前拒绝,并提供平面草图再转换的路径。 +`convert_entities` 不再仅相信原生布尔返回值,检查新增段、有效曲线和长度。 +支持的单独草图段选择另外读取带类型的 BYREF 参数范围,每段 25 个源点转换至目标 +草图坐标后比较输出曲线;关闭链时核验总长度,开启链时输出总长度不得缩短。 +部分圆弧、混合模型边/面等其他选择仍只核验有效输出,对应几何标为未核验。 +前视/上视/右视和偏移 5 mm 平面四个零件实测通过;空间圆保持圆类型,椭圆原生 +转换为样条,周长 48.442241 mm,最大采样误差约 5.46e-11 mm。 +SetWorkingPlaneOrientation 虽返回 True,但原生圆仍为 XY 法向、椭圆仍无曲线; +CreateSketchPlane 返回 True、选中类型 133,但未读到有效对象或确认后续方向。 +这些接口的接受状态不作为工作平面支持证据。任意工作平面编辑/持久化、直接 3D 椭圆、 +部分圆弧/混合选择完整对应核验、复杂依赖和旧软件继续保留缺口。 + ## 原生 API 清点 ```powershell diff --git a/README.md b/README.md index 6e89387..b7b7332 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,11 @@ parallel-through-point), `create_axis`. `list_sketch_points` 包含内部点与用户点,返回选择索引、类型和草图/模型坐标。 `draw_centerline` 支持 XYZ 构造线,并核验端点与构造属性;`draw_3point_arc` 支持 三点 XYZ 圆弧,核验半径、弧长、端点和点到曲线距离,共线/重合输入在创建前拒绝。 -重叠/共端点圆弧原生结果曾偏离输入,返回未确认状态。3D 椭圆原生对象未得到有效曲线, +重叠/共端点圆弧原生结果曾偏离输入,返回未确认状态。圆/椭圆可在 2D 平面草图创建后 +用 `convert_entities` 转换成空间曲线;转换逐点比较源曲线经坐标变换后的采样点与输出, +并读取实际类型,椭圆在本机变为样条。`draw_ellipse` 核验理论点和周长,直接 3D 创建会 +产生无有效曲线的原生对象,因此在修改前拒绝。模型边/面与部分圆弧等输入的完整对应 +几何核验仍未覆盖;返回的 `geometry_correspondence_confirmed` 明确区分已核验与未核验。 其他 3D 曲线、工作平面、完整约束/尺寸与更多建模组合仍有缺口。 Geometry: `draw_line`, `draw_centerline`, `draw_circle`, `draw_rectangle`, diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index a0e9de8..98b9c00 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -492,7 +492,7 @@ def angle(target): @tool( "draw_ellipse", - "Add an ellipse from its centre, a major-axis point, and a minor-axis point. Millimetres.", + "Add a verified full ellipse in an open 2D sketch. Center and perpendicular major/minor axis points are mm. Verifies 24 theoretical points and perimeter; direct native 3D creation is unsupported, so use a plane sketch and convert_entities for spatial geometry (native spline representation).", { "center_x_mm": {"type": "number"}, "center_y_mm": {"type": "number"}, "major_x_mm": {"type": "number"}, "major_y_mm": {"type": "number"}, @@ -501,14 +501,38 @@ def angle(target): ["center_x_mm", "center_y_mm", "major_x_mm", "major_y_mm", "minor_x_mm", "minor_y_mm"], ) def draw_ellipse(args: dict[str, Any]) -> dict[str, Any]: + points = [_xyz(args, f"{p}_x_mm", f"{p}_y_mm") for p in ("center", "major", "minor")] + center, major, minor = points + u, v = [[q-p for p,q in zip(center,point)] for point in (major,minor)] + a, b = math.dist(center, major), math.dist(center, minor) + if min(a,b) <= 1e-9 or a < b or abs(sum(x*y for x,y in zip(u,v))) > a*b*1e-8: + raise RuntimeError("Ellipse axes must be nonzero, perpendicular, with major radius at least the minor radius.") doc, manager = _require_open_sketch() + if bool(value(_active_sketch(doc), "Is3D")): + raise RuntimeError("Native CreateEllipse requires a 2D sketch. Create it on a plane and convert the segment into a 3D sketch instead.") before = _segment_count(doc) - manager.CreateEllipse( - to_m(args["center_x_mm"]), to_m(args["center_y_mm"]), 0.0, - to_m(args["major_x_mm"]), to_m(args["major_y_mm"]), 0.0, - to_m(args["minor_x_mm"]), to_m(args["minor_y_mm"]), 0.0, - ) - return _drawn(doc, before, "an ellipse") + segment = _create_without_inference(manager,"CreateEllipse",*(q for p in points for q in p)) + payload = _drawn(doc, before, "an ellipse") + if not payload["ok"]: + return payload + curve = value(segment,"GetCurve") if segment is not None else None + if curve is None: + return result(False,"Ellipse appeared, but its native curve could not be confirmed.") + flag_methods(curve,"GetClosestPointOn") + gaps=[] + for i in range(24): + angle=2*math.pi*i/24 + p=[center[k]+u[k]*math.cos(angle)+v[k]*math.sin(angle) for k in range(3)] + gaps.append(math.dist(p,[float(q) for q in as_list(curve.GetClosestPointOn(*p))[:3]])) + # Simpson integration of the independent theoretical perimeter. + n=256 + h=2*math.pi/n + speeds=[math.sqrt(a*a*math.sin(i*h)**2+b*b*math.cos(i*h)**2) for i in range(n+1)] + expected=h/3*(speeds[0]+speeds[-1]+4*sum(speeds[1:-1:2])+2*sum(speeds[2:-1:2])) + length=float(value(segment,"GetLength")) + confirmed=max(gaps)<=1e-5 and abs(length-expected)<=1e-5 + return result(confirmed,"Created and verified an ellipse." if confirmed else "Ellipse created, but requested geometry could not be confirmed.", + length_mm=length*1000,max_point_gap_mm=max(gaps)*1000) @tool( @@ -773,21 +797,64 @@ def sketch_mirror(args: dict[str, Any]) -> dict[str, Any]: return result(True, "Mirrored the sketch entities.") -@tool( - "convert_entities", - "Project the selected model edges or a face's loops onto the open sketch (Convert Entities).", - { - "selection": SELECTION_SCHEMA, - "chain": {"type": "boolean", "default": True}, - "inner_loops": {"type": "boolean", "default": False}, - }, - ["selection"], -) +def _curve_samples(curve): + import pythoncom + import win32com.client + flag_methods(curve,"GetEndParams","Evaluate2") + refs=[win32com.client.VARIANT(pythoncom.VT_BYREF|t,v) for t,v in + ((pythoncom.VT_R8,0.),(pythoncom.VT_R8,0.),(pythoncom.VT_BOOL,False),(pythoncom.VT_BOOL,False))] + if not curve.GetEndParams(*refs): + raise RuntimeError("Cannot read native curve parameter bounds.") + start,end=float(refs[0].value),float(refs[1].value) + if not all(math.isfinite(x) for x in (start,end)) or end<=start: + raise RuntimeError("Native curve bounds are invalid.") + return [[float(q) for q in as_list(curve.Evaluate2(start+(end-start)*i/24,0))[:3]] for i in range(25)] + + +@tool("convert_entities", "Convert selected edges, face loops or source sketch segments into the open 2D/3D sketch. Verifies new native curves; supported sketch-segment inputs additionally compare 25 transformed source samples and total length with chain=false. Reports ellipse-to-spline native representation and unverified correspondence explicitly.", + {"selection": SELECTION_SCHEMA,"chain": {"type":"boolean","default":True},"inner_loops":{"type":"boolean","default":False}},["selection"]) def convert_entities(args: dict[str, Any]) -> dict[str, Any]: doc, manager = _require_open_sketch() + before = _segment_count(doc) + spec=args["selection"] + expected=[] + expected_length=0. + supported=False + if spec.get("sketch_segments") and set(spec)<= {"sketch_name","sketch_segments"}: + source_segments=sketch_segment_objects(doc,spec.get("sketch_name")) + source_sketch = value(resolve_sketch(doc,spec["sketch_name"])[1],"GetSpecificFeature2") if spec.get("sketch_name") else manager.ActiveSketch + source_to_model=value(value(value(source_sketch,"ModelToSketchTransform"),"Inverse"),"ArrayData") + model_to_target=value(value(manager.ActiveSketch,"ModelToSketchTransform"),"ArrayData") + supported=True + for index in dict.fromkeys(spec["sketch_segments"]): + if not 0<=int(index) 0 for s in segments) + confirmed=ok and bool(segments) and valid + correspondence=None + gap_mm=None + if confirmed and supported and expected: + curves=[flag_methods(value(s,"GetCurve"),"GetClosestPointOn") for s in segments] + gap_mm=1000*max(min(math.dist(p,[float(q) for q in as_list(c.GetClosestPointOn(*p))[:3]]) for c in curves) for p in expected) + actual_length=sum(float(value(s,"GetLength")) for s in segments) + length_matches=actual_length>=expected_length-1e-5 if args.get("chain",True) else abs(actual_length-expected_length)<=1e-5 + correspondence=gap_mm<=.01 and length_matches + confirmed=confirmed and correspondence + return result(confirmed, "Converted and verified native output curves." if confirmed else "Conversion or native output curves could not be confirmed.", + native_accepted=ok,segments_added=len(segments),native_types=[int(value(s,"GetType")) for s in segments], + geometry_correspondence_confirmed=correspondence,max_source_point_gap_mm=gap_mm) @tool( diff --git a/tests/live_spatial_convert_regression.py b/tests/live_spatial_convert_regression.py new file mode 100644 index 0000000..6bc5e49 --- /dev/null +++ b/tests/live_spatial_convert_regression.py @@ -0,0 +1,52 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +"""Validate native spatial circle/ellipse conversion on three planes and an offset.""" +import math +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS, require_part, sketch_segment_objects, value + + +def run(): + original=call("list_open_documents")["active_title"] + scratch=[] + try: + for plane,axis in (("front",2),("top",1),("right",0),("offset",2)): + scratch.append(call("create_new_document",kind="part")["document"]["title"]) + if plane=="offset": + call("create_plane",mode="offset",selection={"planes":["front"]},distance_mm=5,name="SourcePlane") + call("create_sketch",plane_name="SourcePlane",name="SourceCurves") + else: + call("create_sketch",plane=plane,name="SourceCurves") + call("draw_circle",x_mm=20,y_mm=5,radius_mm=2) + ellipse=call("draw_ellipse",center_x_mm=0,center_y_mm=0,major_x_mm=10/math.sqrt(2),major_y_mm=10/math.sqrt(2),minor_x_mm=-5/math.sqrt(2),minor_y_mm=5/math.sqrt(2)) + assert abs(ellipse["length_mm"]-48.4422411027)<.001 + call("close_sketch");call("create_3d_sketch",name="SpatialCurves") + converted=call("convert_entities",selection={"sketch_name":"SourceCurves","sketch_segments":[0,1]},chain=False) + assert converted["geometry_correspondence_confirmed"] and converted["max_source_point_gap_mm"]<.01 + assert converted["native_types"]==[1,3] + _,doc=require_part() + segments=sketch_segment_objects(doc) + circle=next(s for s in segments if int(value(s,"GetType"))==1) + params=list(value(value(circle,"GetCurve"),"CircleParams")) + assert abs(abs(params[axis+3])-1)<1e-6,params + if plane=="offset": assert abs(abs(params[2]*1000)-5)<.001,params + assert abs(sum(float(value(s,"GetLength"))*1000 for s in segments)-(4*math.pi+48.4422411027))<.01 + count=len(segments) + try: + HANDLERS["draw_ellipse"](dict(center_x_mm=0,center_y_mm=0,major_x_mm=10,major_y_mm=0,minor_x_mm=0,minor_y_mm=5)) + except RuntimeError as exc: assert "2D sketch" in str(exc) + else: raise AssertionError("Direct unsupported 3D ellipse creation was not rejected.") + assert len(sketch_segment_objects(doc))==count + call("close_sketch") + print("SPATIAL_CONVERT",plane,converted,params,flush=True) + print("Spatial circle/ellipse conversion and unsupported native creation guards passed.",flush=True) + finally: + opened={d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened:call("close_document",name=title,discard_changes=True) + if original:call("activate_document",name=original) + + +if __name__=="__main__":run() diff --git a/tests/test_spatial_convert.py b/tests/test_spatial_convert.py new file mode 100644 index 0000000..39f48d4 --- /dev/null +++ b/tests/test_spatial_convert.py @@ -0,0 +1,68 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +import unittest +from types import SimpleNamespace as NS +from unittest.mock import Mock, patch +import pythoncom +from solidworks_mcp import sw_sketch as sketch + + +class SpatialConvertTests(unittest.TestCase): + def test_ellipse_rejects_invalid_axes_before_native_access(self): + for minor in ((0,0),(2,2),(0,11)): + with patch.object(sketch,"_require_open_sketch") as native: + with self.assertRaisesRegex(RuntimeError,"Ellipse axes"): + sketch.draw_ellipse(dict(center_x_mm=0,center_y_mm=0,major_x_mm=10,major_y_mm=0,minor_x_mm=minor[0],minor_y_mm=minor[1])) + native.assert_not_called() + + def test_ellipse_rejects_direct_3d_creation_before_mutation(self): + doc=NS(SketchManager=NS(ActiveSketch=NS(Is3D=lambda:True))) + manager=NS(CreateEllipse=Mock()) + with patch.object(sketch,"_require_open_sketch",return_value=(doc,manager)): + with self.assertRaisesRegex(RuntimeError,"2D sketch"): + sketch.draw_ellipse(dict(center_x_mm=0,center_y_mm=0,major_x_mm=10,major_y_mm=0,minor_x_mm=0,minor_y_mm=5)) + manager.CreateEllipse.assert_not_called() + + def test_ellipse_placeholder_without_curve_is_failure(self): + doc=NS(SketchManager=NS(ActiveSketch=NS(Is3D=lambda:False))) + manager=NS(AddToDB=False,CreateEllipse=lambda *a:NS(GetCurve=lambda:None)) + with patch.object(sketch,"_require_open_sketch",return_value=(doc,manager)),patch.object(sketch,"_segment_count",side_effect=[0,1]): + self.assertFalse(sketch.draw_ellipse(dict(center_x_mm=0,center_y_mm=0,major_x_mm=10,major_y_mm=0,minor_x_mm=0,minor_y_mm=5))["ok"]) + self.assertFalse(manager.AddToDB) + + def test_curve_bounds_use_typed_mutable_outputs(self): + def bounds(*refs): + self.assertEqual([r.varianttype for r in refs],[pythoncom.VT_BYREF|pythoncom.VT_R8]*2+[pythoncom.VT_BYREF|pythoncom.VT_BOOL]*2) + refs[0].value=2.;refs[1].value=4. + return True + curve=NS(GetEndParams=bounds,Evaluate2=lambda t,n:[t,0,0,999]) + with patch.object(sketch,"flag_methods",side_effect=lambda o,*a:o): + samples=sketch._curve_samples(curve) + self.assertEqual(len(samples),25) + self.assertEqual(samples[0],[2,0,0]);self.assertEqual(samples[-1],[4,0,0]) + + def test_conversion_native_acceptance_without_output_is_failure(self): + with patch.object(sketch,"_require_open_sketch",return_value=(None,NS(SketchUseEdge3=lambda *a:True))),patch.object(sketch,"_segment_count",return_value=0),patch.object(sketch,"require_selection"),patch.object(sketch,"sketch_segment_objects",return_value=[]): + self.assertFalse(sketch.convert_entities({"selection":{"edges":[0]}})["ok"]) + + def test_conversion_checks_transformed_samples_and_rejects_wrong_output(self): + matrix=[1,0,0,0,1,0,0,0,1,0,0,.035,1,0,0,0] + identity=[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0] + source=NS(GetCurve=lambda:NS(),GetLength=lambda:.01,GetType=lambda:0) + source_sketch=NS(ModelToSketchTransform=NS(Inverse=NS(ArrayData=matrix))) + target_sketch=NS(ModelToSketchTransform=NS(ArrayData=identity)) + for error,length,ok in ((0,.01,True),(.001,.01,False),(0,.005,False)): + nearest=Mock(side_effect=lambda x,y,z:[x+error,y,z]) + output=NS(GetCurve=lambda:NS(GetClosestPointOn=nearest),GetLength=lambda:length,GetType=lambda:0) + manager=NS(ActiveSketch=target_sketch,SketchUseEdge3=lambda *a:True) + with patch.object(sketch,"_require_open_sketch",return_value=(None,manager)),patch.object(sketch,"_segment_count",return_value=0),patch.object(sketch,"sketch_segment_objects",side_effect=[[source],[output]]),patch.object(sketch,"resolve_sketch",return_value=("Source",NS(GetSpecificFeature2=lambda:source_sketch))),patch.object(sketch,"_curve_samples",return_value=[[0,0,0],[.01,0,0]]),patch.object(sketch,"require_selection"),patch.object(sketch,"flag_methods",side_effect=lambda o,*a:o): + payload=sketch.convert_entities({"selection":{"sketch_name":"Source","sketch_segments":[0]},"chain":False}) + self.assertEqual(payload["ok"],ok) + self.assertEqual(payload["data"]["geometry_correspondence_confirmed"],ok) + nearest.assert_any_call(0,0,.035) + + +if __name__=="__main__": + unittest.main() diff --git a/tools/verify_install.py b/tools/verify_install.py index 80ffffb..4b9d0f7 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -319,6 +319,17 @@ async def rejected_call(name, arguments): if abs(arc["radius_mm"]-5) > .01 or abs(arc["length_mm"]-5*angle) > .01 or arc["max_point_gap_mm"] > .01: raise RuntimeError("Installed MCP spatial arc geometry differs.") await call("close_sketch") + await call("create_sketch", {"plane": "right", "name": "InstalledSpatialSource"}) + await call("draw_circle", {"x_mm": 240, "y_mm": 20, "radius_mm": 2}) + ellipse = await call("draw_ellipse", {"center_x_mm": 210, "center_y_mm": 20, "major_x_mm": 220, "major_y_mm": 20, "minor_x_mm": 210, "minor_y_mm": 25}) + if abs(ellipse["length_mm"]-48.4422411027) > .01 or ellipse["max_point_gap_mm"] > .01: + raise RuntimeError("Installed MCP ellipse geometry differs.") + await call("close_sketch") + await call("create_3d_sketch", {"name": "InstalledSpatialConverted"}) + converted = await call("convert_entities", {"selection": {"sketch_name": "InstalledSpatialSource", "sketch_segments": [0,1]}, "chain": False}) + if converted["native_types"] != [1,3] or not converted["geometry_correspondence_confirmed"] or converted["max_source_point_gap_mm"] > .01: + raise RuntimeError("Installed MCP spatial circle/ellipse conversion differs.") + await call("close_sketch") finally: if sketch3d_title: await call("close_document", {"name": sketch3d_title, "discard_changes": True}) From c6d0a7010edbed7fcb10f915b2c36e4d162f3eb8 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:10:03 +0800 Subject: [PATCH 27/31] =?UTF-8?q?=E6=8C=89=E6=8C=81=E4=B9=85=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E8=AF=86=E5=88=AB=E8=BD=AC=E6=8D=A2=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9B=B2=E7=BA=BF=E5=B9=B6=E8=A6=86=E7=9B=96=E5=B7=B2=E6=9C=89?= =?UTF-8?q?=E5=87=A0=E4=BD=95=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 1 + solidworks_mcp/sw_sketch.py | 7 +++++-- tests/live_spatial_convert_regression.py | 3 ++- tests/test_spatial_convert.py | 11 ++++++++++- tools/verify_install.py | 1 + 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index 188799f..d183971 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -221,6 +221,7 @@ XYZ 构造线绕开本机原生 `CreateCenterLine` 的 Z 压平行为,复用 ` 轴方向、非零半轴和长短轴关系前置检查。直接 3D 椭圆接口会产生无有效曲线的对象, 在修改前拒绝,并提供平面草图再转换的路径。 `convert_entities` 不再仅相信原生布尔返回值,检查新增段、有效曲线和长度。 +新增段按冻结的持久引用识别,不假定原生输出顺序;已有空间线在转换前后单独保留。 支持的单独草图段选择另外读取带类型的 BYREF 参数范围,每段 25 个源点转换至目标 草图坐标后比较输出曲线;关闭链时核验总长度,开启链时输出总长度不得缩短。 部分圆弧、混合模型边/面等其他选择仍只核验有效输出,对应几何标为未核验。 diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 98b9c00..808fff3 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -815,7 +815,10 @@ def _curve_samples(curve): {"selection": SELECTION_SCHEMA,"chain": {"type":"boolean","default":True},"inner_loops":{"type":"boolean","default":False}},["selection"]) def convert_entities(args: dict[str, Any]) -> dict[str, Any]: doc, manager = _require_open_sketch() - before = _segment_count(doc) + existing=sketch_segment_objects(doc) + before={persistent_reference_id(doc,s) for s in existing} + if b"" in before: + raise RuntimeError("Cannot identify existing target curves before conversion.") spec=args["selection"] expected=[] expected_length=0. @@ -840,7 +843,7 @@ def convert_entities(args: dict[str, Any]) -> dict[str, Any]: expected_length+=length require_selection(doc, args["selection"]) ok = bool(manager.SketchUseEdge3(bool(args.get("chain", True)), bool(args.get("inner_loops", False)))) - segments=sketch_segment_objects(doc)[before:] + segments=[s for s in sketch_segment_objects(doc) if not before or persistent_reference_id(doc,s) not in before] valid=all(value(s,"GetCurve") is not None and float(value(s,"GetLength")) > 0 for s in segments) confirmed=ok and bool(segments) and valid correspondence=None diff --git a/tests/live_spatial_convert_regression.py b/tests/live_spatial_convert_regression.py index 6bc5e49..92f17a2 100644 --- a/tests/live_spatial_convert_regression.py +++ b/tests/live_spatial_convert_regression.py @@ -23,6 +23,7 @@ def run(): ellipse=call("draw_ellipse",center_x_mm=0,center_y_mm=0,major_x_mm=10/math.sqrt(2),major_y_mm=10/math.sqrt(2),minor_x_mm=-5/math.sqrt(2),minor_y_mm=5/math.sqrt(2)) assert abs(ellipse["length_mm"]-48.4422411027)<.001 call("close_sketch");call("create_3d_sketch",name="SpatialCurves") + call("draw_line",x1_mm=100,y1_mm=100,z1_mm=100,x2_mm=110,y2_mm=100,z2_mm=100) converted=call("convert_entities",selection={"sketch_name":"SourceCurves","sketch_segments":[0,1]},chain=False) assert converted["geometry_correspondence_confirmed"] and converted["max_source_point_gap_mm"]<.01 assert converted["native_types"]==[1,3] @@ -32,7 +33,7 @@ def run(): params=list(value(value(circle,"GetCurve"),"CircleParams")) assert abs(abs(params[axis+3])-1)<1e-6,params if plane=="offset": assert abs(abs(params[2]*1000)-5)<.001,params - assert abs(sum(float(value(s,"GetLength"))*1000 for s in segments)-(4*math.pi+48.4422411027))<.01 + assert abs(sum(float(value(s,"GetLength"))*1000 for s in segments)-(10+4*math.pi+48.4422411027))<.01 count=len(segments) try: HANDLERS["draw_ellipse"](dict(center_x_mm=0,center_y_mm=0,major_x_mm=10,major_y_mm=0,minor_x_mm=0,minor_y_mm=5)) diff --git a/tests/test_spatial_convert.py b/tests/test_spatial_convert.py index 39f48d4..f622d5f 100644 --- a/tests/test_spatial_convert.py +++ b/tests/test_spatial_convert.py @@ -47,6 +47,15 @@ def test_conversion_native_acceptance_without_output_is_failure(self): with patch.object(sketch,"_require_open_sketch",return_value=(None,NS(SketchUseEdge3=lambda *a:True))),patch.object(sketch,"_segment_count",return_value=0),patch.object(sketch,"require_selection"),patch.object(sketch,"sketch_segment_objects",return_value=[]): self.assertFalse(sketch.convert_entities({"selection":{"edges":[0]}})["ok"]) + def test_conversion_identifies_new_curves_when_native_order_changes(self): + old=NS(reference=b"old",GetCurve=lambda:None) + new=NS(reference=b"new",GetCurve=lambda:NS(),GetLength=lambda:.01,GetType=lambda:1) + with patch.object(sketch,"_require_open_sketch",return_value=(None,NS(SketchUseEdge3=lambda *a:True))),patch.object(sketch,"sketch_segment_objects",side_effect=[[old],[new,old]]),patch.object(sketch,"persistent_reference_id",side_effect=lambda doc,obj:obj.reference),patch.object(sketch,"require_selection"): + payload=sketch.convert_entities({"selection":{"edges":[0]}}) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["data"]["segments_added"],1) + self.assertIsNone(payload["data"]["geometry_correspondence_confirmed"]) + def test_conversion_checks_transformed_samples_and_rejects_wrong_output(self): matrix=[1,0,0,0,1,0,0,0,1,0,0,.035,1,0,0,0] identity=[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0] @@ -57,7 +66,7 @@ def test_conversion_checks_transformed_samples_and_rejects_wrong_output(self): nearest=Mock(side_effect=lambda x,y,z:[x+error,y,z]) output=NS(GetCurve=lambda:NS(GetClosestPointOn=nearest),GetLength=lambda:length,GetType=lambda:0) manager=NS(ActiveSketch=target_sketch,SketchUseEdge3=lambda *a:True) - with patch.object(sketch,"_require_open_sketch",return_value=(None,manager)),patch.object(sketch,"_segment_count",return_value=0),patch.object(sketch,"sketch_segment_objects",side_effect=[[source],[output]]),patch.object(sketch,"resolve_sketch",return_value=("Source",NS(GetSpecificFeature2=lambda:source_sketch))),patch.object(sketch,"_curve_samples",return_value=[[0,0,0],[.01,0,0]]),patch.object(sketch,"require_selection"),patch.object(sketch,"flag_methods",side_effect=lambda o,*a:o): + with patch.object(sketch,"_require_open_sketch",return_value=(None,manager)),patch.object(sketch,"_segment_count",return_value=0),patch.object(sketch,"sketch_segment_objects",side_effect=[[],[source],[output]]),patch.object(sketch,"resolve_sketch",return_value=("Source",NS(GetSpecificFeature2=lambda:source_sketch))),patch.object(sketch,"_curve_samples",return_value=[[0,0,0],[.01,0,0]]),patch.object(sketch,"require_selection"),patch.object(sketch,"flag_methods",side_effect=lambda o,*a:o): payload=sketch.convert_entities({"selection":{"sketch_name":"Source","sketch_segments":[0]},"chain":False}) self.assertEqual(payload["ok"],ok) self.assertEqual(payload["data"]["geometry_correspondence_confirmed"],ok) diff --git a/tools/verify_install.py b/tools/verify_install.py index 4b9d0f7..bb7ff5d 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -326,6 +326,7 @@ async def rejected_call(name, arguments): raise RuntimeError("Installed MCP ellipse geometry differs.") await call("close_sketch") await call("create_3d_sketch", {"name": "InstalledSpatialConverted"}) + await call("draw_line", {"x1_mm": 100, "y1_mm": 100, "z1_mm": 100, "x2_mm": 110, "y2_mm": 100, "z2_mm": 100}) converted = await call("convert_entities", {"selection": {"sketch_name": "InstalledSpatialSource", "sketch_segments": [0,1]}, "chain": False}) if converted["native_types"] != [1,3] or not converted["geometry_correspondence_confirmed"] or converted["max_source_point_gap_mm"] > .01: raise RuntimeError("Installed MCP spatial circle/ellipse conversion differs.") From bd750fcba2c83c80e8df2d5de67267e788c1f303 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:26:06 +0800 Subject: [PATCH 28/31] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=89=E7=BB=B4?= =?UTF-8?q?=E8=BD=B4=E5=90=91=E5=B0=BA=E5=AF=B8=E5=B9=B6=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?=E6=B1=82=E8=A7=A3=E5=87=A0=E4=BD=95=E4=B8=8E=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 13 ++++- README.md | 5 +- solidworks_mcp/sw_sketch.py | 64 ++++++++++++++++++++++ tests/live_axis_dimension_regression.py | 46 ++++++++++++++++ tests/test_axis_dimension.py | 73 +++++++++++++++++++++++++ tools/build_coverage_report.py | 2 +- tools/verify_install.py | 13 +++++ 7 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 tests/live_axis_dimension_regression.py create mode 100644 tests/test_axis_dimension.py diff --git a/COVERAGE.md b/COVERAGE.md index d183971..feb9786 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,7 +47,7 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 179 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +默认工具共 180 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 曲面编辑 3 个、Motion 算例管理 7 个。 新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 @@ -232,6 +232,17 @@ CreateSketchPlane 返回 True、选中类型 133,但未读到有效对象或 这些接口的接受状态不作为工作平面支持证据。任意工作平面编辑/持久化、直接 3D 椭圆、 部分圆弧/混合选择完整对应核验、复杂依赖和旧软件继续保留缺口。 +## 3D 方向尺寸 + +新增 `add_3d_dimension` 使用原生 AddAlongX/Y/ZDimension,限制为编辑中的 3D 草图。 +选取两个不同点索引并冻结持久引用,读取线性尺寸类型、原生数值及实际投影间距。 +SetSystemValue3 虽即时读回数值,几何却延迟求解;EditRebuild3 会求解并退出编辑, +工具重建后按持久引用恢复原草图,并核验同一对点的实际距离,不以参数值作为几何证明。 +零件 XYZ 从 10/20/30 mm 驱动到 15/25/35 mm,后续 X 改为 17 mm 仍驱动几何。 +装配体 XY 同样通过;Z 在先创建或最后创建时均返回空,原有 Z 间距仍 30 mm, +工具返回失败,未以其他轴或线长替代。装配体 Z、零/负向组合、更多点/约束组合、 +已有尺寸修改的通用几何读回、恢复失败和旧版软件仍待补齐或专项验证。 + ## 原生 API 清点 ```powershell diff --git a/README.md b/README.md index b7b7332..5c2498f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -179 tools: sketching with real relations and driving dimensions, the solid +180 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. @@ -161,6 +161,9 @@ parallel-through-point), `create_axis`. 产生无有效曲线的原生对象,因此在修改前拒绝。模型边/面与部分圆弧等输入的完整对应 几何核验仍未覆盖;返回的 `geometry_correspondence_confirmed` 明确区分已核验与未核验。 其他 3D 曲线、工作平面、完整约束/尺寸与更多建模组合仍有缺口。 +`add_3d_dimension` 用两个原生点索引创建 X/Y/Z 投影尺寸;重建求解后恢复原草图编辑, +核验线性尺寸、点的持久引用及实际轴向距离。零件 XYZ 和装配体 XY 实测通过; +本机装配体 Z 接口返回空,明确失败并保留缺口。 Geometry: `draw_line`, `draw_centerline`, `draw_circle`, `draw_rectangle`, `draw_arc`, `draw_3point_arc`, `draw_ellipse`, `draw_polygon`, `draw_slot`, diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 808fff3..47fd786 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -1090,6 +1090,70 @@ def add_dimension(args: dict[str, Any]) -> dict[str, Any]: return result(True, "Added a dimension.", sketch_status=status, **applied) +@tool("add_3d_dimension", "Create a native X/Y/Z projected linear dimension between two points of the open 3D sketch. Indices come from list_sketch_points. Values/placement are mm; an optional value is applied in all configurations, while geometry is verified in the active one. Rebuilds to solve deferred geometry, restores the same edit context, and verifies native dimension plus actual projected point distance. Native assembly Z may be refused and is reported as failure.", + {"axis":{"type":"string","enum":["x","y","z"]},"point_indices":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2,"uniqueItems":True}, + "value_mm":{"type":"number","minimum":0},"place_x_mm":{"type":"number","default":0},"place_y_mm":{"type":"number","default":0},"place_z_mm":{"type":"number","default":0}},["axis","point_indices"]) +def add_3d_dimension(args): + app,doc=active_document() + sketch=_active_sketch(doc) + if not bool(value(sketch,"Is3D")): + raise RuntimeError("Axis dimensions require an open 3D sketch.") + axis=str(args["axis"]) + if axis not in ("x","y","z"): + raise RuntimeError("Axis must be x, y or z.") + indices=[int(i) for i in args["point_indices"]] + points=sketch_point_objects(doc) + if len(indices)!=2 or indices[0]==indices[1] or any(i<0 or i>=len(points) for i in indices): + raise RuntimeError("Select two distinct valid native sketch point indices.") + selected=[points[i] for i in indices] + references=[persistent_reference_id(doc,p) for p in selected] + if not all(references) or references[0]==references[1]: + raise RuntimeError("Cannot confirm two distinct native point references.") + coordinate=axis.upper() + before_distance=abs(float(value(selected[1],coordinate))-float(value(selected[0],coordinate))) + expected=before_distance + if args.get("value_mm") is not None: + expected=float(args["value_mm"])/1000 + placement=[float(args.get(f"place_{k}_mm",0))/1000 for k in "xyz"] + if expected<0 or not all(math.isfinite(v) for v in [expected,*placement]): + raise RuntimeError("Dimension values and placement must be finite; the distance cannot be negative.") + name=sketch_name_for_object(doc,sketch) + original_reference=persistent_reference_id(doc,sketch) + require_selection(doc,{"sketch_points":indices}) + method=f"AddAlong{coordinate}Dimension" + try: + with dimension_dialog_suppressed(app): + display=getattr(flag_methods(sketch_manager(doc),method),method)(*placement) + if display is None: + return result(False,"SOLIDWORKS did not create the projected 3D dimension.",axis=axis,projected_before_mm=before_distance*1000) + dimension=flag_methods(display,"GetDimension2").GetDimension2(0) + if dimension is None: + return result(False,"Display dimension created, but its native dimension is unavailable.",axis=axis) + full_name=str(value(dimension,"FullName")) + display_type=int(value(display,"Type2")) + code=0 + if args.get("value_mm") is not None: + code=int(flag_methods(dimension,"SetSystemValue3").SetSystemValue3(expected,2,empty_variant())) + finally: + clear_selection(doc) + rebuilt=rebuild(doc) + context=doc.SketchManager.ActiveSketch + if context is None: + reopened=edit_sketch({"sketch_name":name}) + context=doc.SketchManager.ActiveSketch + restored=bool(reopened["ok"]) and context is not None and persistent_reference_id(doc,context)==original_reference + else: + restored=bool(value(context,"Is3D")) and persistent_reference_id(doc,context)==original_reference + actual=float(value(dimension,"SystemValue")) + after={persistent_reference_id(doc,p):p for p in sketch_point_objects(doc,name)} + same_points=all(r in after for r in references) + distance=abs(float(value(after[references[1]],coordinate))-float(value(after[references[0]],coordinate))) if same_points else None + confirmed=rebuilt and restored and display_type==2 and code==0 and abs(actual-expected)<=1e-9 and distance is not None and abs(distance-expected)<=1e-5 + return result(confirmed,"Created and verified a projected 3D dimension." if confirmed else "Dimension created, but its value, geometry or edit context could not be confirmed.", + axis=axis,full_name=full_name,value_mm=actual*1000,projected_distance_mm=distance*1000 if distance is not None else None, + display_type=display_type,set_value_status=code,rebuild_ok=rebuilt,edit_context_restored=restored,point_references_confirmed=same_points) + + @tool( "set_dimension", "Change an existing dimension by its full name, for example 'D1@草图1'. Use list_dimensions to " diff --git a/tests/live_axis_dimension_regression.py b/tests/live_axis_dimension_regression.py new file mode 100644 index 0000000..db265fe --- /dev/null +++ b/tests/live_axis_dimension_regression.py @@ -0,0 +1,46 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +"""Native axis dimensions must drive point geometry and restore 3D editing.""" +from live_expansion_regression import call +from solidworks_mcp.sw_core import HANDLERS + + +def run(): + original=call("list_open_documents")["active_title"] + scratch=[] + try: + for kind in ("part","assembly"): + scratch.append(call("create_new_document",kind=kind)["document"]["title"]) + call("create_3d_sketch",name="AxisDimensions") + call("draw_line",x1_mm=0,y1_mm=0,x2_mm=10,y2_mm=20,z2_mm=30) + names=[] + for axis,target in zip("xyz",(15,25,35)): + if kind=="assembly" and axis=="z": + rejected=HANDLERS["add_3d_dimension"](dict(axis=axis,point_indices=[0,1],value_mm=target,place_x_mm=50,place_y_mm=50,place_z_mm=50)) + assert not rejected["ok"] and abs(rejected["data"]["projected_before_mm"]-30)<.01 + print("ASSEMBLY_Z_NATIVE_REFUSAL",rejected,flush=True) + continue + added=call("add_3d_dimension",axis=axis,point_indices=[0,1],value_mm=target,place_x_mm=50,place_y_mm=50,place_z_mm=50) + assert added["edit_context_restored"] and added["point_references_confirmed"] + assert abs(added["projected_distance_mm"]-target)<.01 + names.append(added["full_name"]) + print("AXIS_DIM",kind,added,flush=True) + print("AXIS_POINTS",kind,axis,call("list_sketch_points"),flush=True) + points=call("list_sketch_points")["points"] + expected=(15,25,35) if kind=="part" else (15,25,30) + assert all(abs(abs(points[1]["model_point_mm"][i]-points[0]["model_point_mm"][i])-v)<.01 for i,v in enumerate(expected)) + call("close_sketch") + call("set_dimension",full_name=names[0],value_mm=17) + points=call("list_sketch_points",sketch_name="AxisDimensions")["points"] + assert abs(abs(points[1]["model_point_mm"][0]-points[0]["model_point_mm"][0])-17)<.01 + print("Part XYZ and assembly XY driven dimensions passed; assembly Z native refusal remains a gap.",flush=True) + finally: + opened={d["title"] for d in call("list_open_documents")["documents"]} + for title in scratch: + if title in opened:call("close_document",name=title,discard_changes=True) + if original:call("activate_document",name=original) + + +if __name__=="__main__":run() diff --git a/tests/test_axis_dimension.py b/tests/test_axis_dimension.py new file mode 100644 index 0000000..e294e77 --- /dev/null +++ b/tests/test_axis_dimension.py @@ -0,0 +1,73 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +import unittest +from contextlib import ExitStack, nullcontext +from types import SimpleNamespace as NS +from unittest.mock import Mock, patch +from solidworks_mcp import sw_sketch as sketch + + +class AxisDimensionTests(unittest.TestCase): + def exercise(self, *, solve=True, apply=True, code=0, restore=True, created=True): + current=NS(reference=b"sketch",Is3D=lambda:True) + p=NS(reference=b"a",X=0,Y=0,Z=0) + q=NS(reference=b"b",X=.01,Y=.02,Z=.03) + dimension=NS(SystemValue=.01,FullName="D1@Spatial") + def set_value(target,configuration,empty): + if apply:dimension.SystemValue=target + return code + dimension.SetSystemValue3=set_value + display=NS(GetDimension2=lambda i:dimension,Type2=2) + manager=NS(ActiveSketch=current,AddAlongXDimension=Mock(return_value=display if created else None)) + doc=NS(SketchManager=manager) + def rebuild(doc): + manager.ActiveSketch=None + if solve:q.X=.015 + return True + def reopen(args): + manager.ActiveSketch=current if restore else None + return {"ok":restore} + with ExitStack() as stack: + for name,replacement in {"active_document":lambda:(None,doc),"sketch_manager":lambda d:manager, + "sketch_point_objects":lambda *a:[p,q],"persistent_reference_id":lambda d,o:o.reference, + "sketch_name_for_object":lambda *a:"Spatial","flag_methods":lambda o,*a:o, + "require_selection":lambda *a:2,"clear_selection":lambda *a:None, + "dimension_dialog_suppressed":lambda *a:nullcontext(),"rebuild":rebuild,"edit_sketch":reopen}.items(): + stack.enter_context(patch.object(sketch,name,side_effect=replacement)) + payload=sketch.add_3d_dimension(dict(axis="x",point_indices=[0,1],value_mm=15,place_x_mm=50,place_y_mm=20,place_z_mm=30)) + manager.AddAlongXDimension.assert_called_once_with(.05,.02,.03) + return payload,manager + + def test_dimension_requires_solved_geometry_and_restored_context(self): + payload,manager=self.exercise() + self.assertTrue(payload["ok"]) + self.assertEqual(payload["data"]["projected_distance_mm"],15) + self.assertIsNotNone(manager.ActiveSketch) + + def test_dimension_value_without_driven_geometry_is_failure(self): + payload,_=self.exercise(solve=False) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["data"]["value_mm"],15) + self.assertEqual(payload["data"]["projected_distance_mm"],10) + + def test_ignored_value_and_native_status_are_failures(self): + for options in ({"apply":False},{"code":1}): + self.assertFalse(self.exercise(**options)[0]["ok"]) + + def test_failed_context_restore_is_failure(self): + self.assertFalse(self.exercise(restore=False)[0]["ok"]) + + def test_null_display_is_not_success(self): + self.assertFalse(self.exercise(created=False)[0]["ok"]) + + def test_2d_and_duplicate_points_are_rejected_before_mutation(self): + for is3d,indices in ((False,[0,1]),(True,[0,0])): + doc=NS(SketchManager=NS(ActiveSketch=NS(Is3D=lambda:is3d))) + with patch.object(sketch,"active_document",return_value=(None,doc)),patch.object(sketch,"sketch_point_objects",return_value=[NS(),NS()]),patch.object(sketch,"require_selection") as select: + with self.assertRaises(RuntimeError):sketch.add_3d_dimension(dict(axis="x",point_indices=indices)) + select.assert_not_called() + + +if __name__=="__main__":unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index d6b336c..df94db6 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -31,7 +31,7 @@ "swFmSectionPartView": ["insert_section_view"], "swFmSectionAssemView": ["insert_section_view"], "swFmUnfoldedView": ["insert_projected_view"], "swFmRefPlane": ["create_plane"], "swFmRefAxis": ["create_axis"], "swFmRefSurface": ["surface_extrude", "surface_revolve", "surface_loft", "planar_surface", "offset_surface", "knit_surfaces", "extend_surface", "untrim_surface", "fill_surface", "ruled_surface", "delete_surface_holes", "surface_sweep", "mid_surface", "get_mid_surface_data", "preview_surface_trim", "trim_surface", "get_surface_trim_data", "boundary_surface", "get_boundary_feature_data"], - "swFmProfileFeature": ["create_sketch", "create_3d_sketch", "list_sketch_points"], "swFmWeldMemberFeat": ["insert_structural_member"], + "swFmProfileFeature": ["create_sketch", "create_3d_sketch", "list_sketch_points", "add_3d_dimension"], "swFmWeldMemberFeat": ["insert_structural_member"], "swFmCoordinateSystem": ["create_coordinate_system", "list_coordinate_systems"], "swFmRefCurve": ["create_curve_through_points", "get_curve_points", "set_curve_points", "composite_curve"], } diff --git a/tools/verify_install.py b/tools/verify_install.py index bb7ff5d..179a3ad 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -331,6 +331,19 @@ async def rejected_call(name, arguments): if converted["native_types"] != [1,3] or not converted["geometry_correspondence_confirmed"] or converted["max_source_point_gap_mm"] > .01: raise RuntimeError("Installed MCP spatial circle/ellipse conversion differs.") await call("close_sketch") + await call("create_3d_sketch", {"name": "InstalledAxisDimensions"}) + await call("draw_line", {"x1_mm":0,"y1_mm":0,"x2_mm":10,"y2_mm":20,"z2_mm":30}) + names=[] + for axis,target in zip("xyz",(15,25,35)): + added=await call("add_3d_dimension", {"axis":axis,"point_indices":[0,1],"value_mm":target,"place_x_mm":50,"place_y_mm":50,"place_z_mm":50}) + if not added["edit_context_restored"] or not added["point_references_confirmed"] or abs(added["projected_distance_mm"]-target)>.01: + raise RuntimeError("Installed MCP axis dimension or driven geometry differs.") + names.append(added["full_name"]) + await call("close_sketch") + await call("set_dimension", {"full_name":names[0],"value_mm":18}) + positions=(await call("list_sketch_points", {"sketch_name":"InstalledAxisDimensions"}))["points"] + if abs(abs(positions[1]["model_point_mm"][0]-positions[0]["model_point_mm"][0])-18)>.01: + raise RuntimeError("Installed MCP later dimension edit did not drive spatial geometry.") finally: if sketch3d_title: await call("close_document", {"name": sketch3d_title, "discard_changes": True}) From 9392ae6901eba89bc4aed255a18eaec68ebae066 Mon Sep 17 00:00:00 2001 From: limuzi013 <128580527+limuzi013@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:42:22 +0800 Subject: [PATCH 29/31] =?UTF-8?q?=E6=A0=B8=E9=AA=8C=E8=BD=B4=E5=90=91?= =?UTF-8?q?=E5=B0=BA=E5=AF=B8=E9=80=89=E6=8B=A9=E5=BC=95=E7=94=A8=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A9=B1=E5=8A=A8=E7=8A=B6=E6=80=81=E8=AF=BB?= =?UTF-8?q?=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- COVERAGE.md | 6 ++++++ README.md | 3 +++ solidworks_mcp/sw_sketch.py | 12 +++++++++--- tests/test_axis_dimension.py | 20 +++++++++++++++++--- tools/verify_install.py | 13 +++++++++++++ 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/COVERAGE.md b/COVERAGE.md index feb9786..e3f9a90 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -242,6 +242,12 @@ SetSystemValue3 虽即时读回数值,几何却延迟求解;EditRebuild3 会 装配体 XY 同样通过;Z 在先创建或最后创建时均返回空,原有 Z 间距仍 30 mm, 工具返回失败,未以其他轴或线长替代。装配体 Z、零/负向组合、更多点/约束组合、 已有尺寸修改的通用几何读回、恢复失败和旧版软件仍待补齐或专项验证。 +完整安装回归发现含 3D 圆/椭圆转换的零件中,后续 X 尺寸创建返回空且没有新增尺寸。 +独立共点草图和扫描曲面不触发该问题;两种点选择路径的持久引用均正确,常见属性 +与重进编辑未解决。保留原始失败和分阶段探针;验收同时检查拒绝状态及独立零件的 +XYZ 驱动,不能据独立零件成功声称复杂上下文完成。新工具在调用前另核验实际选择引用。 +本机 swDimensionDrivenState_e 确认 0 未知、1 从动、2 驱动;修正原反置的 driven 标记, +同时返回原生 driven_state。参考尺寸、未知状态和驱动尺寸不再混为同一个状态。 ## 原生 API 清点 diff --git a/README.md b/README.md index 5c2498f..fc8e7cd 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,9 @@ parallel-through-point), `create_axis`. `add_3d_dimension` 用两个原生点索引创建 X/Y/Z 投影尺寸;重建求解后恢复原草图编辑, 核验线性尺寸、点的持久引用及实际轴向距离。零件 XYZ 和装配体 XY 实测通过; 本机装配体 Z 接口返回空,明确失败并保留缺口。 +含 3D 圆/椭圆转换的零件中,后续 X 尺寸也出现原生拒绝,仍为缺口;原生点选择前 +验证持久引用,避免把选择成功当成选中了正确点。`list_dimensions` 保留原生状态值: +0 未知、1 从动、2 驱动,并修正 `driven` 标记。 Geometry: `draw_line`, `draw_centerline`, `draw_circle`, `draw_rectangle`, `draw_arc`, `draw_3point_arc`, `draw_ellipse`, `draw_polygon`, `draw_slot`, diff --git a/solidworks_mcp/sw_sketch.py b/solidworks_mcp/sw_sketch.py index 47fd786..61bec15 100644 --- a/solidworks_mcp/sw_sketch.py +++ b/solidworks_mcp/sw_sketch.py @@ -1122,10 +1122,15 @@ def add_3d_dimension(args): require_selection(doc,{"sketch_points":indices}) method=f"AddAlong{coordinate}Dimension" try: + selection=flag_methods(doc.SelectionManager,"GetSelectedObjectCount2","GetSelectedObject6") + selected_references=[persistent_reference_id(doc,selection.GetSelectedObject6(i,-1)) for i in range(1,selection.GetSelectedObjectCount2(-1)+1)] + if selected_references!=references: + return result(False,"Native selection did not retain the requested point references.",axis=axis,selected_references_confirmed=False) with dimension_dialog_suppressed(app): display=getattr(flag_methods(sketch_manager(doc),method),method)(*placement) if display is None: - return result(False,"SOLIDWORKS did not create the projected 3D dimension.",axis=axis,projected_before_mm=before_distance*1000) + return result(False,"SOLIDWORKS did not create the projected 3D dimension.",axis=axis,projected_before_mm=before_distance*1000, + selected_references_confirmed=True,sketch_status=_sketch_status(doc)) dimension=flag_methods(display,"GetDimension2").GetDimension2(0) if dimension is None: return result(False,"Display dimension created, but its native dimension is unavailable.",axis=axis) @@ -1196,7 +1201,7 @@ def set_dimension(args: dict[str, Any]) -> dict[str, Any]: @tool( "list_dimensions", - "Read-only: list every driving dimension in the document, or only those of one feature/sketch, " + "Read-only: list native driving/driven display dimensions in the document, or only those of one feature/sketch. Reports driven_state 0=unknown, 1=driven, 2=driving, " "with the full names that set_dimension takes.", {"feature_name": {"type": "string", "description": "Restrict to one feature or sketch."}}, ) @@ -1225,7 +1230,8 @@ def list_dimensions(args: dict[str, Any]) -> dict[str, Any]: "owner": owner, "full_name": full_name, "name": str(safe(dimension, "Name", "")), - "driven": bool(safe(dimension, "DrivenState", 1) == 2), + "driven": bool(safe(dimension, "DrivenState", 0) == 1), + "driven_state": int(safe(dimension, "DrivenState", 0)), } # Dimension type 3 is angular in swDimensionType_e; everything # else we surface here is a length. diff --git a/tests/test_axis_dimension.py b/tests/test_axis_dimension.py index e294e77..e468e7e 100644 --- a/tests/test_axis_dimension.py +++ b/tests/test_axis_dimension.py @@ -10,7 +10,7 @@ class AxisDimensionTests(unittest.TestCase): - def exercise(self, *, solve=True, apply=True, code=0, restore=True, created=True): + def exercise(self, *, solve=True, apply=True, code=0, restore=True, created=True, selection_matches=True): current=NS(reference=b"sketch",Is3D=lambda:True) p=NS(reference=b"a",X=0,Y=0,Z=0) q=NS(reference=b"b",X=.01,Y=.02,Z=.03) @@ -21,7 +21,7 @@ def set_value(target,configuration,empty): dimension.SetSystemValue3=set_value display=NS(GetDimension2=lambda i:dimension,Type2=2) manager=NS(ActiveSketch=current,AddAlongXDimension=Mock(return_value=display if created else None)) - doc=NS(SketchManager=manager) + doc=NS(SketchManager=manager,SelectionManager=NS(GetSelectedObjectCount2=lambda m:2,GetSelectedObject6=lambda i,m:([p,q] if selection_matches else [q,p])[i-1])) def rebuild(doc): manager.ActiveSketch=None if solve:q.X=.015 @@ -37,7 +37,8 @@ def reopen(args): "dimension_dialog_suppressed":lambda *a:nullcontext(),"rebuild":rebuild,"edit_sketch":reopen}.items(): stack.enter_context(patch.object(sketch,name,side_effect=replacement)) payload=sketch.add_3d_dimension(dict(axis="x",point_indices=[0,1],value_mm=15,place_x_mm=50,place_y_mm=20,place_z_mm=30)) - manager.AddAlongXDimension.assert_called_once_with(.05,.02,.03) + if selection_matches:manager.AddAlongXDimension.assert_called_once_with(.05,.02,.03) + else:manager.AddAlongXDimension.assert_not_called() return payload,manager def test_dimension_requires_solved_geometry_and_restored_context(self): @@ -62,6 +63,19 @@ def test_failed_context_restore_is_failure(self): def test_null_display_is_not_success(self): self.assertFalse(self.exercise(created=False)[0]["ok"]) + def test_wrong_native_selection_is_rejected_before_dimension_creation(self): + self.assertFalse(self.exercise(selection_matches=False)[0]["ok"]) + + def test_dimension_listing_distinguishes_driving_and_driven_native_states(self): + for state,driven in ((0,False),(1,True),(2,False)): + dimension=NS(FullName="D1@Spatial",Name="D1",SystemValue=.015,DrivenState=state,GetType=lambda:2) + display=NS(GetDimension2=lambda i:dimension) + feature=NS(Name="Spatial",GetFirstDisplayDimension=lambda:display) + with patch.object(sketch,"active_document",return_value=(None,None)),patch.object(sketch,"find_feature",return_value=feature),patch.object(sketch,"flag_methods",side_effect=lambda o,*a:o),patch.object(sketch,"_next_display",return_value=None): + info=sketch.list_dimensions({"feature_name":"Spatial"})["data"]["dimensions"][0] + self.assertEqual(info["driven"],driven) + self.assertEqual(info["driven_state"],state) + def test_2d_and_duplicate_points_are_rejected_before_mutation(self): for is3d,indices in ((False,[0,1]),(True,[0,0])): doc=NS(SketchManager=NS(ActiveSketch=NS(Is3D=lambda:is3d))) diff --git a/tools/verify_install.py b/tools/verify_install.py index 179a3ad..5efd086 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -64,6 +64,7 @@ async def rejected_call(name, arguments): wrap_title = None dome_title = None sketch3d_title = None + dimension_title = None try: if scratch: title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] @@ -333,18 +334,30 @@ async def rejected_call(name, arguments): await call("close_sketch") await call("create_3d_sketch", {"name": "InstalledAxisDimensions"}) await call("draw_line", {"x1_mm":0,"y1_mm":0,"x2_mm":10,"y2_mm":20,"z2_mm":30}) + refused=await rejected_call("add_3d_dimension", {"axis":"x","point_indices":[0,1],"value_mm":15,"place_x_mm":50,"place_y_mm":50,"place_z_mm":50}) + if not refused["selected_references_confirmed"] or refused["projected_before_mm"]!=10 or (await call("list_dimensions", {"feature_name":"InstalledAxisDimensions"}))["dimensions"]: + raise RuntimeError("Installed MCP converted-model refusal differs or created an unreported dimension.") + await call("close_sketch") + dimension_title=(await call("create_new_document", {"kind":"part"}))["document"]["title"] + await call("create_3d_sketch", {"name":"InstalledAxisDimensions"}) + await call("draw_line", {"x1_mm":0,"y1_mm":0,"x2_mm":10,"y2_mm":20,"z2_mm":30}) names=[] for axis,target in zip("xyz",(15,25,35)): added=await call("add_3d_dimension", {"axis":axis,"point_indices":[0,1],"value_mm":target,"place_x_mm":50,"place_y_mm":50,"place_z_mm":50}) if not added["edit_context_restored"] or not added["point_references_confirmed"] or abs(added["projected_distance_mm"]-target)>.01: raise RuntimeError("Installed MCP axis dimension or driven geometry differs.") names.append(added["full_name"]) + dimension_info=(await call("list_dimensions", {"feature_name":"InstalledAxisDimensions"}))["dimensions"] + if len(dimension_info)!=3 or any(d["driven"] or d["driven_state"]!=2 for d in dimension_info): + raise RuntimeError("Installed MCP driving dimension state readback differs.") await call("close_sketch") await call("set_dimension", {"full_name":names[0],"value_mm":18}) positions=(await call("list_sketch_points", {"sketch_name":"InstalledAxisDimensions"}))["points"] if abs(abs(positions[1]["model_point_mm"][0]-positions[0]["model_point_mm"][0])-18)>.01: raise RuntimeError("Installed MCP later dimension edit did not drive spatial geometry.") finally: + if dimension_title: + await call("close_document", {"name":dimension_title,"discard_changes":True}) if sketch3d_title: await call("close_document", {"name": sketch3d_title, "discard_changes": True}) if dome_title: From d5effba9e0438998d3028217a2029b6518427a49 Mon Sep 17 00:00:00 2001 From: Slacker-LLC <128580527+Slacker-LLC@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:26:40 +0800 Subject: [PATCH 30/31] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Motion=20=E6=B1=82?= =?UTF-8?q?=E8=A7=A3=E4=B8=8E=E5=8A=A8=E7=94=BB=E5=B7=A5=E5=85=B7=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=9B=B6=E4=BB=B6=E8=BF=90=E5=8A=A8=E5=85=83?= =?UTF-8?q?=E7=B4=A0=E4=B8=8E=E6=8F=92=E5=85=A5=E7=BB=84=E4=BB=B6=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E6=B4=BB=E5=8A=A8=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 8 个 MotionManager 工具:运动特征清单、引力、线性/旋转马达、原生求解、 逐时间结果采样、动画序列、播放控制、帧序列/视频导出,合计 188 个工具 - 零件文档上添加引力或马达改为带 document_type 的结构化失败,不再触发 -2147417851 原生 COM 服务器错误 - insert_component 插入后恢复装配体为活动文档并读回 active_document,避免 静默打开零件后后续装配体工具全部失败 - 安装协议改为装配体加插入零件的真实 Motion 流程:215 项离线测试、188 个唯一 工具的 stdio 发现、引力/结果/求解/零件拒绝对照/帧导出与播放全部通过 --- COVERAGE.md | 58 ++- README.md | 2 +- solidworks_mcp/sw_assembly.py | 15 +- solidworks_mcp/sw_motion.py | 472 ++++++++++++++++++++++++- tests/live_motion_solver_regression.py | 174 +++++++++ tests/test_assembly.py | 86 +++++ tests/test_motion.py | 252 +++++++++++++ tools/build_coverage_report.py | 5 +- tools/verify_install.py | 100 +++++- 9 files changed, 1129 insertions(+), 35 deletions(-) create mode 100644 tests/live_motion_solver_regression.py create mode 100644 tests/test_assembly.py diff --git a/COVERAGE.md b/COVERAGE.md index e3f9a90..c56324a 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,8 +47,9 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 180 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 -曲面编辑 3 个、Motion 算例管理 7 个。 +默认工具共 188 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +曲面编辑 3 个、Motion 算例管理 7 个;本批新增 8 个 Motion 工具:运动特征查询、 +重力与线性/旋转马达创建、求解与结果读取、动画序列、播放控制、帧序列/视频导出。 新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 刻划及解析/样条方法:解析凸起增加 42 mm³、凹陷减少 38 mm³,样条结果误差 @@ -79,7 +80,8 @@ IWrapSketchFeatureData 不暴露方法、网格精度和全部目标面,查询 多面替换、目标面引用保存、后置草图/复杂依赖、方法/网格修改和前述原生缺口仍待补齐。 钣金验证板厚修改后的体积、展开/折叠状态及 DXF 实际几何;焊件验证管材 解析体积、长度变化及切割清单;曲面验证缝合面积、加厚体积和偏移距离。 -Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回。 +Motion 在新建零件和装配体上验证算例生命周期、类型及时间轴读回;运动特征、 +求解与结果读取、动画与导出的实测记录见后文 Motion 一节。 随后新增 11 个直接编辑、参考点、坐标系及 XYZ/组合曲线工具。 移动面的偏移、平移、旋转,删除面与修补孔,以及替换面均通过实际体积或面积检查; @@ -155,8 +157,9 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 实例选择仅作用于 MCP 服务进程,不启动或关闭 SolidWorks,也不切换用户窗口。 所选实例退出后不自动替换;只读可访问不等于建模健康。 -上述验证均使用新建测试文档。Motion 的动力学求解、载荷、接触及结果读取尚未实现; -Simulation、CAM、Routing 等官方模块继续保留为待覆盖项。 +上述验证均使用新建测试文档。Motion 的载荷(力/力矩)、弹簧、阻尼、3D 接触 +特征、曲线图与 CSV 读取仍未实现;Simulation、CAM、Routing 等官方模块继续 +保留为待覆盖项。 新增边界曲面创建及原生定义查询 2 个工具。12 个临时零件验证单/双方向、 三个轮廓、按两个方向分别修剪及同时修剪、圆草图/模型边轮廓、法向条件和形成实体。 @@ -249,6 +252,51 @@ XYZ 驱动,不能据独立零件成功声称复杂上下文完成。新工具 本机 swDimensionDrivenState_e 确认 0 未知、1 从动、2 驱动;修正原反置的 driven 标记, 同时返回原生 driven_state。参考尺寸、未知状态和驱动尺寸不再混为同一个状态。 +## Motion 求解、结果与动画 + +新增 8 个原生 Motion 工具:`list_motion_features`、`add_motion_gravity`、 +`add_motion_motor`、`solve_motion_study`、`get_motion_study_results`、 +`set_motion_study_animation`、`control_motion_study_playback`、 +`export_motion_study_frames`。算例生命周期在新建零件与装配体上实测;运动元素 +(引力与马达)只在装配体文档上有效,零件文档返回带 `document_type="part"` 的 +结构化失败,因为原生 `CreateFeature` 在零件上会抛 COM 服务器错误。测试文档 +结束前关闭。 + +本批同时修正既有工具:`insert_component` 为加载引用文件而静默打开零件后会把 +活动文档切成该零件,后续装配体工具随即失败;现在插入后恢复装配体为活动文档并 +读回 `active_document`。安装协议改为装配体加插入零件的真实 Motion 流程,并暴露 +两个协议层约束:SaveAs 会改变文档标题,Basic Motion 算例拥有时间轴但动画导出 +不产出文件。215 项离线测试通过。 + +重力特征按类型库 `swFeatureNameID_e` 值 74 创建,回读原生定义的实际强度 9.81、 +轴向索引 1(Y)和反向标志,并从 `GetMotionFeatures` 确认特征名(本机为“引力”) +与类型 `AEMGravity`。线性与旋转马达按 77/78 创建,方向引用取自实际选中的装配体 +边,回读速度 25/30、`MotorType` 0/1 与反向标志,特征类型分别为 `AEMLinearMotor` +与 `AEMRotationalMotor`;`ConstantSpeedMotor` 的官方参数单位是 RPM,工具按原生 +数值原样读回,未换算成 mm/s。 + +`solve_motion_study` 激活算例后调用原生 `Calculate`,再读取该算例类型对应的结果 +对象:本机 `Calculate` 返回 True、`IsOutOfDate` 为 False,但在重力加两个马达的 +装配体上,0/0.5/1.0 s 采样到的质心位置与速度全部为零,工具按要求返回失败并给出 +该观测,而不是以 True 当作求解成功。`get_motion_study_results` 读取同一结果对象的 +质心位置、线速度/加速度、角速度/加速度与欧拉角,三组时间分别返回三个分量。 + +动画侧 `CreateByRotateModel` 把时间轴从 0 改为 2.0 s 并回读成功;`CreateByExplode` +与 `CreateByCollapse` 在无爆炸视图的本机上返回原生 True 但不延长/覆盖时间轴, +工具因此返回失败并保留原始值。帧序列导出用屏幕渲染器写出 16 个 PNG +(3,369,810 字节),逐个文件核对非零大小;同一路径的 AVI 导出原生返回 False 且 +没有任何文件,工具返回失败。播放控制读取/设置播放模式并核对 `IsPlaying`, +loop + play 与 stop 均通过。 + +本机环境限制已核实并保留为缺口:注册表与本进程模块列表中没有 SOLIDWORKS Motion +动力学插件(ADAMS 求解器),`GetSupportedStudyTypes` 虽报告掩码 7,但把算例类型 +设为 Motion Analysis 会被静默忽略,Basic Motion 可以切换却得不到任何求解结果; +AVI 与 JPG 导出、PhotoWorks 渲染器同样被拒绝,只有屏幕渲染器的 BMP/PNG 序列成功。 +`GetProperties(4)` 返回的 COSMOSMotion 属性可读(积分器类型、误差容限、最大迭代 +次数等),但该对象不暴露 `GetFrameRate/SetFrameRate`。 +力与力矩、弹簧、阻尼、3D 接触特征、曲线图与 CSV 读取、求解器/许可可用时的真实 +动力学核验、图像尺寸与比例参数、运动事件回调仍待补齐。 + ## 原生 API 清点 ```powershell diff --git a/README.md b/README.md index fc8e7cd..665c85c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -180 tools: sketching with real relations and driving dimensions, the solid +188 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. diff --git a/solidworks_mcp/sw_assembly.py b/solidworks_mcp/sw_assembly.py index e550f3e..38d58c9 100644 --- a/solidworks_mcp/sw_assembly.py +++ b/solidworks_mcp/sw_assembly.py @@ -122,11 +122,22 @@ def insert_component(args: dict[str, Any]) -> dict[str, Any]: if component is None: return result(False, f"SOLIDWORKS did not insert {path.name} into the assembly.") rebuild(doc) + # The silent OpenDoc6 above activates the referenced part, so the assembly is + # no longer the active document and every follow-up assembly tool would fail + # its own document check. Put the assembly back and report the readback. + title = str(value(doc, "GetTitle")) + active = app.ActiveDoc + if active is None or str(value(active, "GetTitle")) != title: + app.ActivateDoc3(title, False, 0, byref_long(0)) + active = app.ActiveDoc + active_title = str(value(active, "GetTitle")) if active is not None else None return result( - True, - f"Inserted {path.name}.", + active_title == title, + f"Inserted {path.name}." if active_title == title else + f"Inserted {path.name}, but SOLIDWORKS left another document active.", component=str(safe(component, "Name2", "")), path=str(path), + active_document=active_title, ) diff --git a/solidworks_mcp/sw_motion.py b/solidworks_mcp/sw_motion.py index 5c2e2aa..7bd1379 100644 --- a/solidworks_mcp/sw_motion.py +++ b/solidworks_mcp/sw_motion.py @@ -1,24 +1,56 @@ # Copyright 2026 JIALE LIU # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. -# You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 # Unless required by applicable law or agreed to in writing, software distributed # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR # CONDITIONS OF ANY KIND, either express or implied. See the License for details. -"""Native MotionManager study management; availability comes from the live study.""" +"""Native MotionManager studies: lifecycle, timeline, features, solver results and animation. + +Every mutating call reads the native definition or result object back instead of +trusting the return code. Whether a study type, a solver or an output format is +actually available comes from the live study, because it depends on which +SOLIDWORKS Motion components are installed and licensed. +""" import math +import time +from pathlib import Path from .sw_core import ( - active_document, as_list, byref_long, document_type, extension, - flag_methods, result, tool, value, + active_document, as_list, byref_long, document_type, extension, flag_methods, + iter_components, require_selection, result, safe, to_deg, tool, value, ) +from .sw_file import validated_output_path TYPES = {"animation": 1, "basic_motion": 2, "motion_analysis": 4} +SIMULATION_TYPES = (2, 4) +MOTOR_TYPES = {"linear": 77, "rotational": 78} +GRAVITY_AXES = {"x": 0, "y": 1, "z": 2} +PART_DOCUMENT = 1 +ASSEMBLY_DOCUMENT = 2 +PLAY_MODES = {"normal": 1, "loop": 2, "reciprocate": 3} +RENDERERS = {"screen": 0, "photoworks": 1} +# swAnimationOutputType_e +FRAME_FORMATS = {"avi": 1, "bmp": 2, "tga": 3, "png": 4, "jpg": 5, "tif": 6, + "mp4": 7, "mkv": 8, "flv": 9, "lxo": 10} NAME = {"type": "string", "minLength": 1} TYPE = {"type": "string", "enum": list(TYPES), "default": "animation"} +# Members called with arguments. Properties are deliberately absent: flagging a +# property as a method makes its put unresolvable. +_STUDY_METHODS = ( + "CreateDefinition", "CreateFeature", "Calculate", "GetResults", "GetProperties", + "GetMotionFeatures", "GetMotionFeaturesCount", "GetNumOfExternalMotors", + "GetNumOfExternalForces", "Activate", "SetDuration", "SetTime", "SaveToAVI", + "CreateByRotateModel", "CreateByExplode", "CreateByCollapse", +) +_RESULT_METHODS = ( + "GetCMPosition", "GetLinearVelocity", "GetLinearAcceleration", + "GetAngularVelocity", "GetAngularAcceleration", "GetEulerAngles", +) + def _name(raw): name = str(raw).strip() @@ -27,14 +59,33 @@ def _name(raw): return name -def _manager(): +class MotionElementsUnsupported(RuntimeError): + """The active document type cannot host native motion elements. + + A part document owns a MotionManager, but only for model animation. Asking + it for a gravity or motor feature fails inside ``CreateFeature`` with a raw + COM server fault instead of a diagnosable return value, so the tool checks + the document type first and reports the limitation itself. + """ + + def __init__(self, label: str): + super().__init__(f"Native motion elements (gravity and motors) require an assembly document; " + f"the active document is a {label}.") + self.label = label + + +def _manager(require_elements: bool = False): _, doc = active_document() - if document_type(doc) not in (1, 2): + kind = document_type(doc) + if kind not in (PART_DOCUMENT, ASSEMBLY_DOCUMENT): raise RuntimeError("Motion studies require an active part or assembly.") + if require_elements and kind != ASSEMBLY_DOCUMENT: + raise MotionElementsUnsupported({PART_DOCUMENT: "part"}.get(kind, f"document type {kind}")) manager = value(extension(doc), "GetMotionStudyManager") if manager is None: raise RuntimeError("The native MotionManager is unavailable for this document.") - return flag_methods(manager, "GetMotionStudy", "ActivateMotionStudy", "DeleteMotionStudy") + return flag_methods(manager, "GetMotionStudy", "ActivateMotionStudy", "DeleteMotionStudy", + "CreateAVIParameter") def _names(manager): @@ -47,7 +98,7 @@ def _study(manager, name): study = manager.GetMotionStudy(name) if study is None: raise RuntimeError(f"MotionManager could not access '{name}'.") - return flag_methods(study, "GetSupportedStudyTypes", "SetDuration", "SetTime") + return flag_methods(study, *_STUDY_METHODS) def _supported(study): @@ -72,6 +123,122 @@ def _type(raw): return TYPES[raw] +def _activate(manager, study): + if bool(value(study, "IsActive")): + return True + return bool(manager.ActivateMotionStudy(str(value(study, "Name")))) and bool(value(study, "IsActive")) + + +def _features(study): + return [{"name": str(safe(feature, "Name", "")), "type": str(safe(feature, "GetTypeName2", ""))} + for feature in as_list(value(study, "GetMotionFeatures"))] + + +def _results(study, code): + try: + return study.GetResults(code) + except Exception: + return None + + +# Native result vectors and angles are SI: metres, metres/second and radians. +def _triple(raw, scale=1.0): + if raw is None: + return None + try: + raw = value(raw, "ArrayData") + except Exception: + pass + try: + return [round(float(item) * scale, 9) for item in list(raw)[:3]] + except Exception: + return None + + +def _component(doc, name=None): + components = iter_components(doc, True) + if name: + for component in components: + if str(safe(component, "Name2", "")) == name: + return component + raise RuntimeError(f"No top-level component named '{name}'.") + return components[0] if components else None + + +def _sample(results, component, times): + flag_methods(results, *_RESULT_METHODS) + samples = [] + for moment in times: + entry = {"time_sec": moment} + for key, member, scale in (("position_mm", "GetCMPosition", 1000.0), + ("linear_velocity_mm_per_s", "GetLinearVelocity", 1000.0), + ("linear_acceleration_mm_per_s2", "GetLinearAcceleration", 1000.0), + ("angular_velocity_rad_per_s", "GetAngularVelocity", 1.0), + ("angular_acceleration_rad_per_s2", "GetAngularAcceleration", 1.0), + ("euler_angles_deg", "GetEulerAngles", to_deg(1.0))): + try: + entry[key] = _triple(getattr(results, member)(moment, component), scale) + except Exception: + entry[key] = None + samples.append(entry) + return samples + + +def _differs(left, right, tolerance=1e-9): + if left is None or right is None: + return False + return any(abs(float(x) - float(y)) > tolerance for x, y in zip(left, right)) + + +def _moved(samples): + """True once any sampled quantity changes; a static study stays False.""" + if len(samples) < 2: + return False + first = samples[0] + for entry in samples[1:]: + for key in ("position_mm", "linear_velocity_mm_per_s", "angular_velocity_rad_per_s"): + if _differs(entry.get(key), first.get(key)): + return True + return False + + +def _sample_times(study, requested): + if requested: + times = sorted({float(moment) for moment in requested}) + else: + duration = float(value(study, "GetDuration")) + times = [0.0] if duration <= 0 else sorted({0.0, round(duration / 2.0, 6), round(duration, 6)}) + if any(not math.isfinite(moment) or moment < 0 for moment in times): + raise RuntimeError("Sample times must be finite and non-negative.") + duration = float(value(study, "GetDuration")) + if times and duration > 0 and times[-1] > duration + 1e-9: + raise RuntimeError(f"Sample time {times[-1]} is beyond the study duration {duration}.") + return times + + +def _await_files(target: Path, deadline_sec: float = 30.0): + """Wait for the animator to finish writing; frames land while the call returns.""" + series = target.suffix.lower() != ".avi" + deadline = time.monotonic() + deadline_sec + previous = None + stable = 0 + while True: + current = sorted(target.parent.glob(f"{target.stem}-*{target.suffix}")) if series else ( + [target] if target.is_file() else []) + signature = [(str(path), path.stat().st_size) for path in current] + ready = [path for path in current if path.stat().st_size > 0] + if ready and signature == previous: + stable += 1 + if stable >= 2: + return ready + else: + stable = 0 + previous = signature + if time.monotonic() >= deadline: + return ready + time.sleep(0.5) + + def _set_type(study, code): if not _supported(study) & code: return result(False, "Requested study type is unavailable in the current installed/loaded/licensed session.", study=_info(study)) @@ -100,7 +267,7 @@ def create_motion_study(args): study = value(manager, "CreateMotionStudy") if study is None: return result(False, "Native motion study creation failed.") - study = flag_methods(study, "GetSupportedStudyTypes", "SetDuration", "SetTime") + study = flag_methods(study, *_STUDY_METHODS) study.Name = name output = _set_type(study, code) if str(value(study, "Name")) != name or name not in _names(manager): @@ -138,7 +305,7 @@ def duplicate_motion_study(args): study = value(_study(manager, name), "Duplicate") if study is None: return result(False, "Native motion study duplication failed.") - study = flag_methods(study, "GetSupportedStudyTypes") + study = flag_methods(study, *_STUDY_METHODS) study.Name = new_name info = _info(study) return result(info["name"] == new_name and new_name in _names(manager), "Read back duplicated motion study.", study=info) @@ -164,13 +331,290 @@ def set_motion_study_timing(args): duration = updates.get("duration_sec", before["duration_sec"]) if updates.get("time_sec", 0) > duration: raise RuntimeError("Cursor time must not exceed the study duration.") - if not manager.ActivateMotionStudy(before["name"]): + if not _activate(manager, study): return result(False, "Could not activate the study before setting its timeline.", study=before) accepted = True if "duration_sec" in updates: accepted = bool(study.SetDuration(duration)) - if accepted and "time_sec" in updates: - accepted = bool(study.SetTime(updates["time_sec"])) + if "time_sec" in updates: + # The cursor move is applied even when the preceding SetDuration reports + # false, which has been observed right after a study-type switch, so the + # read-back decides and the native flag is reported separately. + study.SetTime(updates["time_sec"]) info = _info(study) matches = all(math.isclose(info[k], v, rel_tol=1e-7, abs_tol=1e-7) for k, v in updates.items()) - return result(accepted and matches, "Read back motion study timeline.", study=info) + return result(matches, "Read back motion study timeline.", study=info, native_accepted=accepted) + + +@tool("list_motion_features", "Read-only: list a motion study's native motion features with their localised names and types, plus the external motor and force counters.", + {"name": NAME}, ["name"]) +def list_motion_features(args): + manager = _manager() + study = _study(manager, _name(args["name"])) + count = int(value(study, "GetMotionFeaturesCount")) + features = _features(study) + return result(count == len(features), "Read motion features.", study=_info(study), count=count, + features=features, external_motors=int(value(study, "GetNumOfExternalMotors")), + external_forces=int(value(study, "GetNumOfExternalForces"))) + + +@tool("add_motion_gravity", "Create a native gravity feature in a motion study, then read the stored definition back.", + {"name": NAME, "strength": {"type": "number", "exclusiveMinimum": 0, "default": 9.80665}, + "axis": {"type": "string", "enum": list(GRAVITY_AXES), "default": "y"}, + "reverse_direction": {"type": "boolean", "default": False}}, ["name"]) +def add_motion_gravity(args): + strength = float(args.get("strength", 9.80665)) + if not math.isfinite(strength) or strength <= 0: + raise RuntimeError("Gravity strength must be a finite positive number in m/s^2.") + axis = GRAVITY_AXES[str(args.get("axis", "y")).lower()] + reverse = bool(args.get("reverse_direction", False)) + try: + manager = _manager(require_elements=True) + except MotionElementsUnsupported as exc: + return result(False, str(exc), document_type=exc.label) + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before adding gravity.", study=_info(study)) + definition = study.CreateDefinition(74) + if definition is None: + return result(False, "SOLIDWORKS returned no gravity feature data object.") + definition.Strength = strength + definition.ReverseDirection = reverse + definition.Axis = axis + feature = study.CreateFeature(definition) + if feature is None: + return result(False, "SOLIDWORKS refused the gravity feature.") + created = {"name": str(safe(feature, "Name", "")), "type": str(safe(feature, "GetTypeName2", ""))} + stored = {"strength": float(value(definition, "Strength")), "reverse_direction": bool(value(definition, "ReverseDirection")), + "axis": int(value(definition, "Axis"))} + features = _features(study) + confirmed = (any(entry["name"] == created["name"] for entry in features) + and math.isclose(stored["strength"], strength, rel_tol=1e-9) + and stored["reverse_direction"] == reverse and stored["axis"] == axis) + return result(confirmed, "Read the created gravity feature back.", feature=created, definition=stored, + features=features, count=int(value(study, "GetMotionFeaturesCount"))) + + +@tool("add_motion_motor", "Create a native linear or rotational motor along selected geometry and read the stored constant speed back.", + {"name": NAME, "motor_type": {"type": "string", "enum": list(MOTOR_TYPES), "default": "rotational"}, + "direction": {"type": "object", "description": "Selection of the edge, axis or face that defines the motor direction."}, + "speed": {"type": "number", "description": "Constant speed for the native constant-speed motor (RPM per IMotionMotorFeatureData::ConstantSpeedMotor)."}, + "reverse_direction": {"type": "boolean", "default": False}}, ["name", "direction", "speed"]) +def add_motion_motor(args): + motor_type = str(args.get("motor_type", "rotational")) + if motor_type not in MOTOR_TYPES: + raise RuntimeError(f"Unknown motor type '{motor_type}'.") + speed = float(args["speed"]) + if not math.isfinite(speed): + raise RuntimeError("Motor speed must be finite.") + reverse = bool(args.get("reverse_direction", False)) + _, doc = active_document() + try: + manager = _manager(require_elements=True) + except MotionElementsUnsupported as exc: + return result(False, str(exc), document_type=exc.label) + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before adding the motor.", study=_info(study)) + require_selection(doc, args["direction"], mark=1) + selection_manager = flag_methods(doc.SelectionManager, "GetSelectedObject6") + reference = selection_manager.GetSelectedObject6(1, -1) + if reference is None: + return result(False, "The direction selection resolved to nothing.") + definition = study.CreateDefinition(MOTOR_TYPES[motor_type]) + if definition is None: + return result(False, "SOLIDWORKS returned no motor feature data object.") + definition.DirectionReference = reference + definition.ReverseDirection = reverse + definition.ConstantSpeedMotor(speed) + stored = {"speed": float(value(definition, "Velocity")), "reverse_direction": bool(value(definition, "ReverseDirection")), + "motor_type": int(value(definition, "MotorType"))} + feature = study.CreateFeature(definition) + if feature is None: + return result(False, "SOLIDWORKS refused the motor feature.", definition=stored) + created = {"name": str(safe(feature, "Name", "")), "type": str(safe(feature, "GetTypeName2", ""))} + features = _features(study) + stored = {"speed": float(value(definition, "Velocity")), "reverse_direction": bool(value(definition, "ReverseDirection")), + "motor_type": int(value(definition, "MotorType"))} + confirmed = (any(entry["name"] == created["name"] for entry in features) + and math.isclose(stored["speed"], speed, rel_tol=1e-9) + and stored["reverse_direction"] == reverse + and stored["motor_type"] == (0 if motor_type == "linear" else 1)) + return result(confirmed, "Read the created motor feature back.", feature=created, definition=stored, + features=features, count=int(value(study, "GetMotionFeaturesCount"))) + + +@tool("solve_motion_study", "Activate a motion study, run the native Calculate, then read the results object back: out-of-date flag plus sampled kinematics of a component.", + {"name": NAME, "component": {"type": "string", "minLength": 1}, + "sample_times": {"type": "array", "items": {"type": "number", "minimum": 0}, "minItems": 2}}, ["name"]) +def solve_motion_study(args): + manager = _manager() + study = _study(manager, _name(args["name"])) + info = _info(study) + if not _activate(manager, study): + return result(False, "Could not activate the study before calculating.", study=info) + started = time.monotonic() + accepted = bool(value(study, "Calculate")) + elapsed = round(time.monotonic() - started, 3) + features = int(value(study, "GetMotionFeaturesCount")) + data = {"study": _info(study), "native_accepted": accepted, "elapsed_sec": elapsed, "motion_features": features} + results = _results(study, info["type_code"]) + if results is None: + return result(False, "The native calculation produced no results object for this study type.", **data) + out_of_date = bool(value(results, "IsOutOfDate")) + data["out_of_date"] = out_of_date + _, doc = active_document() + component = _component(doc, args.get("component")) + if component is not None: + times = _sample_times(study, args.get("sample_times")) + samples = _sample(results, component, times) + data["component"] = str(safe(component, "Name2", "")) + data["samples"] = samples + data["motion_detected"] = _moved(samples) + if not accepted: + return result(False, "The native calculation was refused.", **data) + if out_of_date: + return result(False, "The native results object still reports the study as out of date.", **data) + if info["type_code"] in SIMULATION_TYPES and features and not data.get("motion_detected", False): + return result(False, "The native calculation returned true, but no sampled displacement or velocity " + "changed for the probed component.", **data) + return result(True, "Calculated the motion study and read the results state back.", **data) + + +@tool("get_motion_study_results", "Read-only: per-time native motion results for one component (centre-of-mass position, linear and angular velocity/acceleration, Euler angles).", + {"name": NAME, "component": {"type": "string", "minLength": 1}, + "times": {"type": "array", "items": {"type": "number", "minimum": 0}, "minItems": 1}}, ["name", "times"]) +def get_motion_study_results(args): + manager = _manager() + study = _study(manager, _name(args["name"])) + info = _info(study) + _, doc = active_document() + component = _component(doc, args.get("component")) + if component is None: + return result(False, "The active document has no top-level component to probe.", study=info) + results = _results(study, info["type_code"]) + if results is None: + return result(False, "The native MotionManager returned no results object for this study type.", study=info) + times = _sample_times(study, args["times"]) + samples = _sample(results, component, times) + return result(True, "Read native motion results.", study=info, component=str(safe(component, "Name2", "")), + out_of_date=bool(value(results, "IsOutOfDate")), samples=samples) + + +@tool("set_motion_study_animation", "Create or replace a native animation sequence on a motion study: rotate the model, explode or collapse, then read the resulting timeline back.", + {"name": NAME, "action": {"type": "string", "enum": ["rotate_model", "explode", "collapse"]}, + "duration_sec": {"type": "number", "exclusiveMinimum": 0}, + "start_time_sec": {"type": "number", "minimum": 0, "default": 0}, + "delete_existing": {"type": "boolean", "default": True}, + "axis": {"type": "string", "enum": ["x", "y", "z"], "default": "y"}, + "revolutions": {"type": "integer", "minimum": 1, "default": 1}, + "direction": {"type": "string", "enum": ["clockwise", "counterclockwise"], "default": "clockwise"}}, + ["name", "action", "duration_sec"]) +def set_motion_study_animation(args): + action = str(args["action"]) + duration = float(args["duration_sec"]) + start = float(args.get("start_time_sec", 0)) + if not math.isfinite(duration) or duration <= 0 or not math.isfinite(start) or start < 0: + raise RuntimeError("Animation duration must be positive and the start time non-negative.") + delete_existing = bool(args.get("delete_existing", True)) + manager = _manager() + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before creating the animation.", study=_info(study)) + if action == "rotate_model": + axis = {"x": 0, "y": 1, "z": 2}[str(args.get("axis", "y")).lower()] + direction = {"clockwise": 0, "counterclockwise": 1}[str(args.get("direction", "clockwise")).lower()] + accepted = bool(study.CreateByRotateModel(delete_existing, axis, int(args.get("revolutions", 1)), + direction, duration, start)) + elif action == "explode": + accepted = bool(study.CreateByExplode(delete_existing, duration, start)) + else: + accepted = bool(study.CreateByCollapse(delete_existing, duration, start)) + info = _info(study) + end = round(start + duration, 6) + covered = info["duration_sec"] + 1e-6 >= end + return result(accepted and covered, "Read back the created animation timeline.", study=info, + action=action, expected_end_sec=end, native_accepted=accepted) + + +@tool("control_motion_study_playback", "Read or set the native animation play mode and start or stop playback, reporting the actual IsPlaying state.", + {"name": NAME, "play_mode": {"type": "string", "enum": list(PLAY_MODES)}, + "action": {"type": "string", "enum": ["none", "play", "stop"], "default": "none"}, + "from_start": {"type": "boolean", "default": False}, + "time_sec": {"type": "number", "minimum": 0}}, ["name"]) +def control_motion_study_playback(args): + manager = _manager() + study = _study(manager, _name(args["name"])) + before = {"play_mode": int(value(study, "PlayMode")), "playing": bool(value(study, "IsPlaying"))} + accepted = True + mode = before["play_mode"] + if "play_mode" in args: + requested = str(args["play_mode"]) + study.PlayMode = PLAY_MODES[requested] + mode = int(value(study, "PlayMode")) + accepted = accepted and mode == PLAY_MODES[requested] + action = str(args.get("action", "none")) + if action == "play" and not before["playing"]: + if "time_sec" in args: + study.SetTime(float(args["time_sec"])) + elif bool(args.get("from_start", False)): + study.SetTime(0.0) + accepted = bool(value(study, "Play")) and accepted + time.sleep(0.3) + elif action == "stop" and before["playing"]: + accepted = bool(value(study, "Stop")) and accepted + time.sleep(0.3) + playing = bool(value(study, "IsPlaying")) + expected = {"play": True, "stop": False}.get(action) + return result(accepted and (expected is None or playing == expected), + "Read back animation playback state.", play_mode=mode, playing=playing, + previous=before, action=action) + + +@tool("export_motion_study_frames", "Render a motion study through the native animator to an image series or a movie file and verify the files that appear next to the output path.", + {"name": NAME, "path": {"type": "string", "description": "Output file path under the MCP output root; image series append -NNNN."}, + "format": {"type": "string", "enum": sorted(FRAME_FORMATS), "default": "png"}, + "frame_per_second": {"type": "number", "exclusiveMinimum": 0, "default": 7.5}, + "start_time_sec": {"type": "number", "minimum": 0}, + "end_time_sec": {"type": "number", "minimum": 0}, + "save_entire_animation": {"type": "boolean", "default": True}, + "renderer": {"type": "string", "enum": list(RENDERERS), "default": "screen"}, + "allow_overwrite": {"type": "boolean", "default": False}}, ["name", "path"]) +def export_motion_study_frames(args): + fmt = str(args.get("format", "png")).lower() + if fmt not in FRAME_FORMATS: + raise RuntimeError(f"Unknown animation format '{fmt}'.") + allow_overwrite = bool(args.get("allow_overwrite", False)) + target = validated_output_path(str(args["path"]), {"." + name for name in FRAME_FORMATS}, + allow_overwrite=allow_overwrite) + if target.suffix.lower() != f".{fmt}": + raise RuntimeError(f"The output path must end with '.{fmt}'.") + existing = sorted(target.parent.glob(f"{target.stem}-*{target.suffix}")) + if existing and not allow_overwrite: + raise RuntimeError(f"Refusing to overwrite {len(existing)} existing frames next to {target}.") + frame_per_second = float(args.get("frame_per_second", 7.5)) + if not math.isfinite(frame_per_second) or frame_per_second <= 0: + raise RuntimeError("Frame rate must be a finite positive number.") + manager = _manager() + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before rendering.", study=_info(study)) + parameters = value(manager, "CreateAVIParameter") + if parameters is None: + return result(False, "SOLIDWORKS returned no animation parameter object.") + parameters.FramePerSecond = frame_per_second + parameters.SaveEntireAnimation = bool(args.get("save_entire_animation", True)) + parameters.OutputType = FRAME_FORMATS[fmt] + parameters.RendererType = RENDERERS[str(args.get("renderer", "screen"))] + if "start_time_sec" in args: + parameters.StartTime = float(args["start_time_sec"]) + if "end_time_sec" in args: + parameters.EndTime = float(args["end_time_sec"]) + accepted = bool(study.SaveToAVI(str(target), parameters)) + files = _await_files(target) + sizes = {path.name: path.stat().st_size for path in files} + data = {"study": _info(study), "path": str(target), "format": fmt, "native_accepted": accepted, + "frame_per_second": float(value(parameters, "FramePerSecond")), "file_count": len(files), + "files": sorted(sizes), "bytes": sum(sizes.values())} + return result(bool(files), "Verified the rendered animation files on disk." if files else + "The native animator wrote no file for this format and renderer.", **data) diff --git a/tests/live_motion_solver_regression.py b/tests/live_motion_solver_regression.py new file mode 100644 index 0000000..f415671 --- /dev/null +++ b/tests/live_motion_solver_regression.py @@ -0,0 +1,174 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Live MotionManager solver/animation checks; scratch documents only. + +Fixtures stay under SW_MCP_OUTPUT_ROOT/.motion-tests/. The script records +what the installed Motion components actually do: on builds without the +SOLIDWORKS Motion solver the study type switch and the motion samples are +reported as unavailable instead of being asserted as working. +""" + +import json +from pathlib import Path +import sys +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from live_expansion_regression import call, near +from solidworks_mcp.sw_core import ( + HANDLERS, OUTPUT_ROOT, as_list, byref_long, running_app, value, +) + + +def raw(tool_name, **args): + payload = HANDLERS[tool_name](args) + print(f"{'PASS' if payload['ok'] else 'FAIL'} {tool_name}: {payload.get('message')}", flush=True) + return payload + + +def fixture(folder: Path): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="Base") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + call("close_sketch") + call("boss_extrude", sketch_name="Base", depth_mm=10) + near(call("get_mass_properties")["volume_mm3"], 1000, "fixture volume") + part = folder / "block.SLDPRT" + call("save_document", path=str(part)) + return part + + +def main(): + app = running_app() + initial = {str(value(document, "GetTitle")) for document in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + folder = OUTPUT_ROOT / ".motion-tests" / uuid.uuid4().hex + folder.mkdir(parents=True, exist_ok=True) + try: + part = fixture(folder) + part_refusal = raw("add_motion_gravity", name="MCP Part Probe", strength=9.81, axis="y") + assert not part_refusal["ok"] and part_refusal["data"]["document_type"] == "part", part_refusal + print("Part documents refuse motion elements with a documented reason instead of a COM fault.", flush=True) + assembly = call("create_new_document", kind="assembly")["document"]["title"] + inserted = call("insert_component", path=str(part)) + component = inserted["component"] + call("set_component_fixed", component_names=[component], fixed=False) + call("activate_document", name=assembly) + + created = call("create_motion_study", name="MCP Solve", type="animation")["study"] + supported = created["supported_types"] + print(f"Supported study types reported: {supported}", flush=True) + switched = call("set_motion_study_type", name="MCP Solve", type="basic_motion")["study"] + assert switched["type"] == "basic_motion", switched + refused = raw("set_motion_study_type", name="MCP Solve", type="motion_analysis") + assert not refused["ok"], refused + assert refused["data"]["study"]["type"] == "basic_motion", refused + print("Motion Analysis study type is refused by this session; recorded as a gap.", flush=True) + + timing = call("set_motion_study_timing", name="MCP Solve", duration_sec=1.0, time_sec=0)["study"] + near(timing["duration_sec"], 1.0, "study duration") + + gravity = call("add_motion_gravity", name="MCP Solve", strength=9.81, axis="y", reverse_direction=False) + assert gravity["feature"]["type"] == "AEMGravity", gravity + near(gravity["definition"]["strength"], 9.81, "gravity strength") + assert gravity["definition"]["axis"] == 1 and gravity["definition"]["reverse_direction"] is False, gravity + assert gravity["count"] == 1, gravity + + listed = call("list_motion_features", name="MCP Solve") + assert listed["count"] == 1 and listed["features"][0]["name"] == gravity["feature"]["name"], listed + assert isinstance(listed["external_motors"], int) and isinstance(listed["external_forces"], int), listed + + edges = call("list_edges")["edges"] + assert len(edges) >= 2, edges + linear = call("add_motion_motor", name="MCP Solve", motor_type="linear", + direction={"edges": [0]}, speed=25.0) + assert linear["feature"]["type"] == "AEMLinearMotor", linear + assert linear["definition"] == {"speed": 25.0, "reverse_direction": False, "motor_type": 0}, linear + rotary = call("add_motion_motor", name="MCP Solve", motor_type="rotational", + direction={"edges": [1]}, speed=30.0, reverse_direction=True) + assert rotary["feature"]["type"] == "AEMRotationalMotor", rotary + assert rotary["definition"] == {"speed": 30.0, "reverse_direction": True, "motor_type": 1}, rotary + everything = call("list_motion_features", name="MCP Solve") + assert everything["count"] == 3, everything + assert {entry["type"] for entry in everything["features"]} == {"AEMGravity", "AEMLinearMotor", "AEMRotationalMotor"}, everything + + solved = raw("solve_motion_study", name="MCP Solve", sample_times=[0.0, 0.5, 1.0]) + data = solved["data"] + assert data["native_accepted"] is True, data + assert data["out_of_date"] is False, data + assert isinstance(data["motion_detected"], bool), data + assert solved["ok"] == data["motion_detected"], data + print(f"Sampled motion detected={data['motion_detected']} on component {data.get('component')}; " + f"solve verdict ok={solved['ok']}.", flush=True) + + results = call("get_motion_study_results", name="MCP Solve", component=component, times=[0.0, 0.5, 1.0]) + assert [sample["time_sec"] for sample in results["samples"]] == [0.0, 0.5, 1.0], results + first = results["samples"][0] + assert all(len(first[key]) == 3 for key in ("position_mm", "linear_velocity_mm_per_s", + "linear_acceleration_mm_per_s2")), first + assert results["out_of_date"] in (True, False), results + + call("create_motion_study", name="MCP Anim", type="animation") + animation = call("set_motion_study_animation", name="MCP Anim", action="rotate_model", + duration_sec=2.0, start_time_sec=0) + near(animation["study"]["duration_sec"], 2.0, "animation duration") + for action in ("explode", "collapse"): + payload = raw("set_motion_study_animation", name="MCP Anim", action=action, duration_sec=1.0, + start_time_sec=0, delete_existing=False) + covered = payload["data"]["study"]["duration_sec"] + 1e-6 >= payload["data"]["expected_end_sec"] + assert payload["ok"] == (payload["data"]["native_accepted"] and covered), payload + + frames = raw("export_motion_study_frames", name="MCP Anim", path=str(folder / "frames.png"), + format="png", frame_per_second=7.5) + if frames["ok"]: + assert frames["data"]["file_count"] >= 1 and frames["data"]["bytes"] > 0, frames + for name in frames["data"]["files"]: + assert (folder / name).stat().st_size > 0, name + print(f"Rendered {frames['data']['file_count']} PNG frames " + f"({frames['data']['bytes']} bytes).", flush=True) + else: + print("PNG series rendering is unavailable in this session; recorded as a gap.", flush=True) + movie = raw("export_motion_study_frames", name="MCP Anim", path=str(folder / "movie.avi"), + format="avi", frame_per_second=7.5) + if not movie["ok"]: + assert movie["data"]["file_count"] == 0, movie + print("AVI encoding is refused by this session; recorded as a gap.", flush=True) + + playback = raw("control_motion_study_playback", name="MCP Anim", play_mode="loop", + action="play", from_start=True) + if playback["ok"]: + assert playback["data"]["play_mode"] == 2 and playback["data"]["playing"] is True, playback + stopped = call("control_motion_study_playback", name="MCP Anim", action="stop") + assert stopped["playing"] is False, stopped + else: + print("Animation playback could not be started in this session; recorded as a gap.", flush=True) + + call("delete_motion_study", name="MCP Solve") + call("delete_motion_study", name="MCP Anim") + print(f"Motion tools verified on {assembly} with {len(edges)} selectable edges.", flush=True) + finally: + documents = list(call("list_open_documents")["documents"]) + documents.sort(key=lambda entry: entry["document_type"] != "assembly") + for document in documents: + if document["title"] not in initial: + HANDLERS["close_document"]({"name": document["title"], "discard_changes": True}) + for document in list(call("list_open_documents")["documents"]): + if document["title"] not in initial: + HANDLERS["close_document"]({"name": document["title"], "discard_changes": True}) + remaining = [entry["title"] for entry in call("list_open_documents")["documents"]] + if original and original in remaining: + call("activate_document", name=original) + print("REMAINING " + json.dumps(remaining, ensure_ascii=False), flush=True) + assert set(remaining) <= initial, remaining + + +if __name__ == "__main__": + main() diff --git a/tests/test_assembly.py b/tests/test_assembly.py new file mode 100644 index 0000000..9197a1e --- /dev/null +++ b/tests/test_assembly.py @@ -0,0 +1,86 @@ +# Copyright 2026 JIALE LIU +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Component insertion has to leave the assembly as the active document. + +Inserting a part that SOLIDWORKS has to open first activates that part, so the +insertion tool would otherwise hand the next tool a part document. +""" + +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest.mock import patch + +from solidworks_mcp import sw_assembly + + +class FakeApplication: + def __init__(self, opens_the_part=True, activates=True): + self.assembly = SimpleNamespace(GetTitle="Assembly1") + self.part = SimpleNamespace(GetTitle="InstalledMotionPart.SLDPRT") + self.ActiveDoc = self.assembly + self.opens_the_part = opens_the_part + self.activates = activates + self.activation_calls = [] + + def OpenDoc6(self, *arguments): + if self.opens_the_part: + self.ActiveDoc = self.part + return self.part + + def ActivateDoc3(self, title, *arguments): + self.activation_calls.append(title) + if self.activates and title == self.assembly.GetTitle: + self.ActiveDoc = self.assembly + return True + return False + + +class InsertComponentTests(unittest.TestCase): + def setUp(self): + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + self.part_path = Path(self.folder.name) / "part.SLDPRT" + self.part_path.write_bytes(b"") + self.component = SimpleNamespace(Name2="part-1") + + def insert(self, app): + app.assembly.AddComponent5 = lambda *arguments: self.component + with patch.object(sw_assembly, "require_assembly", return_value=(app, app.assembly)), \ + patch.object(sw_assembly, "rebuild"): + return sw_assembly.insert_component({"path": str(self.part_path)}) + + def test_the_assembly_is_reactivated_after_inserting_a_closed_part(self): + app = FakeApplication() + payload = self.insert(app) + self.assertTrue(payload["ok"], payload) + self.assertEqual(payload["data"]["active_document"], "Assembly1") + self.assertEqual(app.activation_calls, ["Assembly1"]) + + def test_an_already_active_assembly_is_not_reactivated(self): + app = FakeApplication(opens_the_part=False) + payload = self.insert(app) + self.assertTrue(payload["ok"], payload) + self.assertEqual(payload["data"]["active_document"], "Assembly1") + self.assertEqual(app.activation_calls, []) + + def test_a_failed_reactivation_is_reported_with_the_inserted_component(self): + app = FakeApplication(activates=False) + payload = self.insert(app) + self.assertFalse(payload["ok"], payload) + self.assertEqual(payload["data"]["component"], "part-1") + self.assertEqual(payload["data"]["active_document"], "InstalledMotionPart.SLDPRT") + self.assertIn("left another document active", payload["message"]) diff --git a/tests/test_motion.py b/tests/test_motion.py index 87adcb2..71466bc 100644 --- a/tests/test_motion.py +++ b/tests/test_motion.py @@ -6,13 +6,41 @@ # under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR # CONDITIONS OF ANY KIND, either express or implied. See the License for details. +from pathlib import Path from types import SimpleNamespace +import tempfile import unittest from unittest.mock import patch from solidworks_mcp import sw_motion +class FakeDefinition: + """Stands in for ISimulation*FeatureData, including its property readback.""" + + def __init__(self, apply_settings=True): + self.Strength = 0.0 + self.ReverseDirection = False + self.Axis = 0 + self.DirectionReference = None + self.Velocity = 0.0 + self.MotorType = 0 + self.speed = None + self.apply_settings = apply_settings + + def _FlagAsMethod(self, name): + return None + + def ConstantSpeedMotor(self, speed): + self.speed = speed + if self.apply_settings: + self.Velocity = speed + + +def fake_feature(name="Gravity1", kind="AEMGravity"): + return SimpleNamespace(Name=name, GetTypeName2=lambda: kind) + + class MotionBoundaryTests(unittest.TestCase): def test_invalid_timeline_is_rejected_before_com(self): for args in ({"duration_sec": 0}, {"time_sec": -1}, {"time_sec": float("nan")}, {}): @@ -42,5 +70,229 @@ def supported(mask): self.assertEqual(sw_motion._supported(SimpleNamespace(GetSupportedStudyTypes=supported)), 3) +class MotionFeatureDataTests(unittest.TestCase): + def test_gravity_strength_and_axis_come_from_the_definition(self): + definition = FakeDefinition() + study = SimpleNamespace( + CreateDefinition=lambda kind: definition, + CreateFeature=lambda data: fake_feature(), + GetMotionFeatures=lambda: [fake_feature()], + GetMotionFeaturesCount=lambda: 1, + ) + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True): + payload = sw_motion.add_motion_gravity( + {"name": "Solve", "strength": 12.0, "axis": "z", "reverse_direction": True}) + self.assertTrue(payload["ok"], payload) + self.assertEqual(payload["data"]["definition"], {"strength": 12.0, "reverse_direction": True, "axis": 2}) + + def test_gravity_rejects_invalid_strength_before_com(self): + for strength in (0, -1, float("inf"), float("nan")): + with self.subTest(strength=strength), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.add_motion_gravity({"name": "Solve", "strength": strength}) + manager.assert_not_called() + + def test_gravity_is_refused_on_a_part_before_touching_the_motion_manager(self): + part = SimpleNamespace(GetType=lambda: 1) + with patch.object(sw_motion, "active_document", return_value=(None, part)), \ + patch.object(sw_motion, "extension") as extension: + payload = sw_motion.add_motion_gravity({"name": "Solve", "strength": 9.81}) + self.assertFalse(payload["ok"], payload) + self.assertEqual(payload["data"]["document_type"], "part") + self.assertIn("assembly", payload["message"].lower()) + extension.assert_not_called() + + def test_motor_is_refused_on_a_part_before_touching_the_motion_manager(self): + part = SimpleNamespace(GetType=lambda: 1) + with patch.object(sw_motion, "active_document", return_value=(None, part)), \ + patch.object(sw_motion, "extension") as extension: + payload = sw_motion.add_motion_motor({"name": "Solve", "motor_type": "linear", + "direction": {"edges": [0]}, "speed": 25.0}) + self.assertFalse(payload["ok"], payload) + self.assertEqual(payload["data"]["document_type"], "part") + extension.assert_not_called() + + def test_gravity_is_not_confirmed_when_the_definition_ignores_it(self): + definition = FakeDefinition() + study = SimpleNamespace( + CreateDefinition=lambda kind: definition, + CreateFeature=lambda data: fake_feature(), + GetMotionFeatures=lambda: [fake_feature()], + GetMotionFeaturesCount=lambda: 1, + ) + + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(FakeDefinition, "Strength", property(lambda self: 0.0, lambda self, value: None), create=True): + payload = sw_motion.add_motion_gravity({"name": "Solve", "strength": 12.0}) + self.assertFalse(payload["ok"], payload) + + def test_motor_speed_and_type_are_read_back(self): + reference = object() + definition = FakeDefinition() + definition.MotorType = 1 + study = SimpleNamespace( + CreateDefinition=lambda kind: definition, + CreateFeature=lambda data: fake_feature("RotaryMotor1", "AEMRotationalMotor"), + GetMotionFeatures=lambda: [fake_feature("RotaryMotor1", "AEMRotationalMotor")], + GetMotionFeaturesCount=lambda: 1, + ) + selection_manager = SimpleNamespace(GetSelectedObject6=lambda mark, index: reference, + _FlagAsMethod=lambda name: None) + doc = SimpleNamespace(SelectionManager=selection_manager) + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "require_selection", return_value=1), \ + patch.object(sw_motion, "active_document", return_value=(None, doc)): + payload = sw_motion.add_motion_motor( + {"name": "Solve", "motor_type": "rotational", "direction": {"edges": [0]}, "speed": 30.0}) + self.assertTrue(payload["ok"], payload) + self.assertEqual(payload["data"]["definition"]["speed"], 30.0) + self.assertEqual(payload["data"]["definition"]["motor_type"], 1) + self.assertIs(definition.DirectionReference, reference) + + def test_motor_is_not_confirmed_when_the_speed_is_dropped(self): + definition = FakeDefinition(apply_settings=False) + definition.MotorType = 1 + study = SimpleNamespace( + CreateDefinition=lambda kind: definition, + CreateFeature=lambda data: fake_feature("RotaryMotor1", "AEMRotationalMotor"), + GetMotionFeatures=lambda: [fake_feature("RotaryMotor1", "AEMRotationalMotor")], + GetMotionFeaturesCount=lambda: 1, + ) + selection_manager = SimpleNamespace(GetSelectedObject6=lambda mark, index: object(), + _FlagAsMethod=lambda name: None) + doc = SimpleNamespace(SelectionManager=selection_manager) + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "require_selection", return_value=1), \ + patch.object(sw_motion, "active_document", return_value=(None, doc)): + payload = sw_motion.add_motion_motor( + {"name": "Solve", "motor_type": "rotational", "direction": {"edges": [0]}, "speed": 30.0}) + self.assertFalse(payload["ok"], payload) + + +class MotionResultTests(unittest.TestCase): + def test_triple_scales_si_values_and_survives_nothing(self): + self.assertEqual(sw_motion._triple(SimpleNamespace(ArrayData=[1.0, -2.0, 0.5]), 1000.0), [1000.0, -2000.0, 500.0]) + self.assertIsNone(sw_motion._triple(None)) + self.assertIsNone(sw_motion._triple(SimpleNamespace(ArrayData=None))) + + def test_only_a_real_change_counts_as_motion(self): + still = [{"time_sec": 0.0, "position_mm": [0.0, 0.0, 0.0]}, + {"time_sec": 1.0, "position_mm": [0.0, 0.0, 0.0], "linear_velocity_mm_per_s": [0.0, 0.0, 0.0]}] + moving = [{"time_sec": 0.0, "position_mm": [0.0, 0.0, 0.0], "linear_velocity_mm_per_s": [0.0, 0.0, 0.0]}, + {"time_sec": 1.0, "position_mm": [0.0, 0.0, 0.0], "linear_velocity_mm_per_s": [0.0, -9810.0, 0.0]}] + self.assertFalse(sw_motion._moved(still)) + self.assertFalse(sw_motion._moved([])) + self.assertTrue(sw_motion._moved(moving)) + + def test_sample_times_default_to_the_timeline(self): + study = SimpleNamespace(GetDuration=lambda: 4.0) + self.assertEqual(sw_motion._sample_times(study, None), [0.0, 2.0, 4.0]) + self.assertEqual(sw_motion._sample_times(SimpleNamespace(GetDuration=lambda: 0.0), None), [0.0]) + with self.assertRaises(RuntimeError): + sw_motion._sample_times(study, [0.0, 5.0]) + + def test_solve_reports_a_simulation_study_that_produced_no_motion(self): + study = SimpleNamespace(Calculate=lambda: True, GetMotionFeaturesCount=lambda: 1) + samples = [{"time_sec": 0.0, "position_mm": [0.0, 0.0, 0.0]}, + {"time_sec": 1.0, "position_mm": [0.0, 0.0, 0.0]}] + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "_info", return_value={"type_code": 2, "type": "basic_motion"}), \ + patch.object(sw_motion, "_results", return_value=SimpleNamespace(IsOutOfDate=lambda: False)), \ + patch.object(sw_motion, "active_document", return_value=(None, None)), \ + patch.object(sw_motion, "_component", return_value=SimpleNamespace(Name2="block-1")), \ + patch.object(sw_motion, "_sample", return_value=samples), \ + patch.object(sw_motion, "_sample_times", return_value=[0.0, 1.0]): + payload = sw_motion.solve_motion_study({"name": "Solve"}) + self.assertFalse(payload["ok"], payload) + self.assertTrue(payload["data"]["native_accepted"]) + self.assertFalse(payload["data"]["out_of_date"]) + self.assertFalse(payload["data"]["motion_detected"]) + + def test_solve_accepts_an_animation_that_does_not_move_components(self): + study = SimpleNamespace(Calculate=lambda: True, GetMotionFeaturesCount=lambda: 0) + samples = [{"time_sec": 0.0, "position_mm": [0.0, 0.0, 0.0]}, + {"time_sec": 1.0, "position_mm": [0.0, 0.0, 0.0]}] + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "_info", return_value={"type_code": 1, "type": "animation"}), \ + patch.object(sw_motion, "_results", return_value=SimpleNamespace(IsOutOfDate=lambda: False)), \ + patch.object(sw_motion, "active_document", return_value=(None, None)), \ + patch.object(sw_motion, "_component", return_value=SimpleNamespace(Name2="block-1")), \ + patch.object(sw_motion, "_sample", return_value=samples), \ + patch.object(sw_motion, "_sample_times", return_value=[0.0, 1.0]): + payload = sw_motion.solve_motion_study({"name": "Anim"}) + self.assertTrue(payload["ok"], payload) + + def test_solve_refuses_when_the_results_stay_out_of_date(self): + study = SimpleNamespace(Calculate=lambda: True, GetMotionFeaturesCount=lambda: 0) + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "_info", return_value={"type_code": 1, "type": "animation"}), \ + patch.object(sw_motion, "_results", return_value=SimpleNamespace(IsOutOfDate=lambda: True)), \ + patch.object(sw_motion, "active_document", return_value=(None, None)), \ + patch.object(sw_motion, "_component", return_value=None): + payload = sw_motion.solve_motion_study({"name": "Anim"}) + self.assertFalse(payload["ok"], payload) + self.assertTrue(payload["data"]["out_of_date"]) + + def test_solve_reports_a_missing_results_object(self): + study = SimpleNamespace(Calculate=lambda: True, GetMotionFeaturesCount=lambda: 0) + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "_info", return_value={"type_code": 4, "type": "motion_analysis"}), \ + patch.object(sw_motion, "_results", return_value=None): + payload = sw_motion.solve_motion_study({"name": "Analysis"}) + self.assertFalse(payload["ok"], payload) + + +class MotionAnimationTests(unittest.TestCase): + def test_animation_duration_is_required_before_com(self): + for args in ({"duration_sec": 0}, {"duration_sec": -2}, {"duration_sec": float("nan")}, {"duration_sec": 1, "start_time_sec": -1}): + with self.subTest(args=args), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.set_motion_study_animation({"name": "Anim", "action": "rotate_model", **args}) + manager.assert_not_called() + + def test_animation_timeline_must_cover_the_request(self): + study = SimpleNamespace(CreateByRotateModel=lambda *args: True) + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "_info", return_value={"duration_sec": 2.0}): + payload = sw_motion.set_motion_study_animation( + {"name": "Anim", "action": "rotate_model", "duration_sec": 2.0, "start_time_sec": 3.0}) + self.assertFalse(payload["ok"], payload) + self.assertEqual(payload["data"]["expected_end_sec"], 5.0) + + +class MotionExportTests(unittest.TestCase): + def test_export_requires_a_matching_extension(self): + with patch.object(sw_motion, "validated_output_path", + side_effect=lambda path, allowed, allow_overwrite=False: Path(path)), \ + patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.export_motion_study_frames({"name": "Anim", "path": "anim.avi", "format": "png"}) + manager.assert_not_called() + + def test_export_rejects_an_unknown_format(self): + with patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.export_motion_study_frames({"name": "Anim", "path": "anim.xyz", "format": "xyz"}) + manager.assert_not_called() + + def test_frame_wait_returns_finished_files_only(self): + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + target = root / "anim.png" + (root / "anim-0000.png").write_bytes(b"frame") + (root / "anim-0001.png").write_bytes(b"frame") + files = sw_motion._await_files(target, deadline_sec=0.0) + self.assertEqual([path.name for path in files], ["anim-0000.png", "anim-0001.png"]) + + if __name__ == "__main__": unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index df94db6..15fba38 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -38,7 +38,10 @@ MODULE_TOOLS = {"Motion": ["list_motion_studies", "create_motion_study", "activate_motion_study", "duplicate_motion_study", "delete_motion_study", "set_motion_study_type", - "set_motion_study_timing"]} + "set_motion_study_timing", "list_motion_features", "add_motion_gravity", + "add_motion_motor", "solve_motion_study", "get_motion_study_results", + "set_motion_study_animation", "control_motion_study_playback", + "export_motion_study_frames"]} def report(api): diff --git a/tools/verify_install.py b/tools/verify_install.py index 5efd086..f5dbc6a 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -20,6 +20,7 @@ from pathlib import Path import sys import unittest +import uuid async def protocol(install, expected, catalog, scratch): @@ -51,6 +52,13 @@ async def rejected_call(name, arguments): if payload["ok"]: raise RuntimeError(f"Installed MCP falsely accepted an ignored native edit: {name}.") return payload.get("data", {}) + async def native_call(name, arguments=None): + # A native failure is data here: motion solving and rendering report + # what the session actually did, and the caller asserts the fields. + output = await session.call_tool(name, arguments or {}) + if output.isError: + raise RuntimeError(f"Installed MCP protocol error: {name}: {output.content[0].text}") + return json.loads(output.content[0].text) connections = await call("list_solidworks_sessions") attached = connections["attached_process_id"] if attached is None: @@ -60,11 +68,13 @@ async def rejected_call(name, arguments): raise RuntimeError("Installed MCP instance selection differs.") initial = await call("list_open_documents") original = initial["active_title"] + initial_titles = {document["title"] for document in initial["documents"]} title = None wrap_title = None dome_title = None sketch3d_title = None dimension_title = None + motion_title = None try: if scratch: title = (await call("create_new_document", {"kind": "part"}))["document"]["title"] @@ -355,19 +365,85 @@ async def rejected_call(name, arguments): positions=(await call("list_sketch_points", {"sketch_name":"InstalledAxisDimensions"}))["points"] if abs(abs(positions[1]["model_point_mm"][0]-positions[0]["model_point_mm"][0])-18)>.01: raise RuntimeError("Installed MCP later dimension edit did not drive spatial geometry.") + motion_folder=Path((await call("solidworks_status"))["output_root"])/f"installed-verify-{uuid.uuid4().hex}" + # The scratch part from the dimension block is still the active document, + # so the part-side refusal is checked before anything else takes focus. + refused=await rejected_call("add_motion_gravity", {"name":"InstalledMotion","strength":9.81,"axis":"y"}) + if refused.get("document_type")!="part": + raise RuntimeError("Installed MCP does not refuse motion elements on a part document with a documented reason.") + motion_part_title=(await call("create_new_document", {"kind":"part"}))["document"]["title"] + await call("create_sketch", {"plane":"front","name":"InstalledMotionProfile"}) + await call("draw_rectangle", {"x1_mm":0,"y1_mm":0,"x2_mm":10,"y2_mm":10}) + await call("close_sketch") + await call("boss_extrude", {"sketch_name":"InstalledMotionProfile","depth_mm":10}) + motion_part_path=motion_folder/"InstalledMotionPart.SLDPRT" + await call("save_document", {"path":str(motion_part_path)}) + motion_part_title=(await call("list_open_documents"))["active_title"] + await call("close_document", {"name":motion_part_title,"discard_changes":True}) + motion_part_title=None + motion_title=(await call("create_new_document", {"kind":"assembly"}))["document"]["title"] + component=(await call("insert_component", {"path":str(motion_part_path)}))["component"] + await call("set_component_fixed", {"component_names":[component],"fixed":False}) + created=await call("create_motion_study", {"name":"InstalledMotion","type":"animation"}) + if created["study"]["type"]!="animation": + raise RuntimeError("Installed MCP motion study type readback differs.") + switched=await call("set_motion_study_type", {"name":"InstalledMotion","type":"basic_motion"}) + if switched["study"]["type"]!="basic_motion": + raise RuntimeError("Installed MCP motion study type switch differs.") + await call("set_motion_study_timing", {"name":"InstalledMotion","duration_sec":1.0,"time_sec":0}) + gravity=await call("add_motion_gravity", {"name":"InstalledMotion","strength":9.81,"axis":"y","reverse_direction":False}) + if gravity["feature"]["type"]!="AEMGravity" or abs(gravity["definition"]["strength"]-9.81)>1e-9 or gravity["definition"]["axis"]!=1 or gravity["definition"]["reverse_direction"]: + raise RuntimeError("Installed MCP gravity feature readback differs.") + features=await call("list_motion_features", {"name":"InstalledMotion"}) + if features["count"]!=1 or features["features"][0]["name"]!=gravity["feature"]["name"]: + raise RuntimeError("Installed MCP motion feature listing differs.") + sampled=await call("get_motion_study_results", {"name":"InstalledMotion","component":component,"times":[0.0,1.0]}) + if [sample["time_sec"] for sample in sampled["samples"]]!=[0.0,1.0] or any(len(sample["position_mm"])!=3 for sample in sampled["samples"]): + raise RuntimeError("Installed MCP motion result sampling differs.") + solved=await native_call("solve_motion_study", {"name":"InstalledMotion","sample_times":[0.0,0.5,1.0]}) + state=solved.get("data",{}) + if state.get("native_accepted") is not True or state.get("out_of_date") is not False: + raise RuntimeError(f"Installed MCP motion calculation state differs: {solved}.") + await call("delete_motion_study", {"name":"InstalledMotion"}) + # Animation frames only come out of an animation study; a basic-motion + # study owns the same timeline but its animator writes nothing. + await call("create_motion_study", {"name":"InstalledFrames","type":"animation"}) + animation=await call("set_motion_study_animation", {"name":"InstalledFrames","action":"rotate_model","duration_sec":2.0,"start_time_sec":0}) + if animation["study"]["duration_sec"]<2.0: + raise RuntimeError("Installed MCP animation timeline readback differs.") + frames=await call("export_motion_study_frames", {"name":"InstalledFrames","path":str(motion_folder/"InstalledFrames.png"),"format":"png","frame_per_second":7.5}) + if frames["file_count"]<1 or frames["bytes"]<=0 or not all((motion_folder/name).is_file() for name in frames["files"]): + raise RuntimeError("Installed MCP frame export wrote no verifiable file on disk.") + played=await native_call("control_motion_study_playback", {"name":"InstalledFrames","play_mode":"loop","action":"play","from_start":True}) + if (played.get("data") or {}).get("play_mode")!=2: + raise RuntimeError(f"Installed MCP animation play-mode readback differs: {played}.") + stopped=await call("control_motion_study_playback", {"name":"InstalledFrames","action":"stop"}) + if stopped["playing"] is not False: + raise RuntimeError("Installed MCP animation playback did not stop.") + await call("delete_motion_study", {"name":"InstalledFrames"}) + remaining=await call("list_motion_studies") + if {study["name"] for study in remaining["studies"]} & {"InstalledMotion","InstalledFrames"}: + raise RuntimeError(f"Installed MCP motion study deletion differs: {remaining}.") finally: - if dimension_title: - await call("close_document", {"name":dimension_title,"discard_changes":True}) - if sketch3d_title: - await call("close_document", {"name": sketch3d_title, "discard_changes": True}) - if dome_title: - await call("close_document", {"name": dome_title, "discard_changes": True}) - if wrap_title: - await call("close_document", {"name": wrap_title, "discard_changes": True}) - if title: - await call("close_document", {"name": title, "discard_changes": True}) - if scratch and original: - await call("activate_document", {"name": original}) + if scratch: + # Every scratch document is closed by title, including ones renamed by + # SaveAs. Closing an assembly unloads its components, so one pass can + # leave a part behind; a failing close never hides the original error. + try: + for _ in range(2): + documents = [document for document in (await call("list_open_documents"))["documents"] + if document["title"] not in initial_titles] + for document in documents: + try: + await call("close_document", {"name":document["title"],"discard_changes":True}) + except Exception: + pass + if not documents: + break + if original: + await call("activate_document", {"name": original}) + except Exception: + pass if catalog: catalog.parent.mkdir(parents=True, exist_ok=True) catalog.write_text(json.dumps([tool.model_dump(mode="json") for tool in response.tools], From ff502769984bf9ab8e728a565a0993cf7645ed28 Mon Sep 17 00:00:00 2001 From: Slacker-LLC <128580527+Slacker-LLC@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:49:30 +0800 Subject: [PATCH 31/31] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Motion=20=E8=BD=BD?= =?UTF-8?q?=E8=8D=B7=E5=B7=A5=E5=85=B7=E5=B9=B6=E4=BF=AE=E6=AD=A3=E8=BF=90?= =?UTF-8?q?=E5=8A=A8=E7=89=B9=E5=BE=81=E8=AF=86=E5=88=AB=E4=B8=8E=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 4 个 Motion 载荷工具:线性力/力矩、线性/扭转/线性运动/扭转运动弹簧、 线性/扭转阻尼、3D 接触,合计 192 个工具 - CreateFeature 返回的 dispatch 在本机报告的是上一个特征,创建改为前后对比 GetMotionFeatures 识别新条目;引力与马达同步改用同一路径 - 两个端点必须放在同一选择集的两个标记里(mark 1 与追加的 mark 2), 否则第二选择会替换第一选择 - 3D 接触的 ContactComponents 固定使用 SAFEARRAY(VT_ARRAY|VT_DISPATCH), 元组赋值被原生静默忽略 - 新增 7 项离线测试与实机载荷回归脚本;覆盖率映射、COVERAGE、README 与安装 协议同步到 192 工具,安装验收为 222 项离线测试 + 192 个唯一工具 stdio 发现 --- COVERAGE.md | 50 +++- README.md | 2 +- solidworks_mcp/sw_motion.py | 294 +++++++++++++++++++++++- tests/live_motion_payload_regression.py | 146 ++++++++++++ tests/test_motion.py | 139 +++++++++++ tools/build_coverage_report.py | 7 +- tools/verify_install.py | 30 +++ 7 files changed, 650 insertions(+), 18 deletions(-) create mode 100644 tests/live_motion_payload_regression.py diff --git a/COVERAGE.md b/COVERAGE.md index c56324a..85737d0 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -47,9 +47,10 @@ Simulation、Motion、CAM、Routing 等官方模块需核对本机安装、许 ## 当前新增实现与验证 -默认工具共 188 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 -曲面编辑 3 个、Motion 算例管理 7 个;本批新增 8 个 Motion 工具:运动特征查询、 -重力与线性/旋转马达创建、求解与结果读取、动画序列、播放控制、帧序列/视频导出。 +默认工具共 192 个,仍未全覆盖。前一批新增 22 个:钣金 6 个、焊件 6 个、 +曲面编辑 3 个、Motion 算例管理 7 个;随后新增 8 个 Motion 工具:运动特征查询、 +重力与线性/旋转马达创建、求解与结果读取、动画序列、播放控制、帧序列/视频导出; +本批新增 4 个 Motion 载荷工具:力/力矩、弹簧、阻尼、3D 接触。 新增包覆创建与只读检查 2 个工具。在六个新建圆柱零件上实测凸起、凹陷、 刻划及解析/样条方法:解析凸起增加 42 mm³、凹陷减少 38 mm³,样条结果误差 @@ -157,9 +158,9 @@ Motion 在新建零件和装配体上验证算例生命周期、类型及时间 实例选择仅作用于 MCP 服务进程,不启动或关闭 SolidWorks,也不切换用户窗口。 所选实例退出后不自动替换;只读可访问不等于建模健康。 -上述验证均使用新建测试文档。Motion 的载荷(力/力矩)、弹簧、阻尼、3D 接触 -特征、曲线图与 CSV 读取仍未实现;Simulation、CAM、Routing 等官方模块继续 -保留为待覆盖项。 +上述验证均使用新建测试文档。Motion 的力/力矩、弹簧、阻尼与 3D 接触在后续批次 +实现并读回原生定义;曲线图与 CSV 读取仍未实现;Simulation、CAM、Routing 等 +官方模块继续保留为待覆盖项。 新增边界曲面创建及原生定义查询 2 个工具。12 个临时零件验证单/双方向、 三个轮廓、按两个方向分别修剪及同时修剪、圆草图/模型边轮廓、法向条件和形成实体。 @@ -294,8 +295,41 @@ loop + play 与 stop 均通过。 AVI 与 JPG 导出、PhotoWorks 渲染器同样被拒绝,只有屏幕渲染器的 BMP/PNG 序列成功。 `GetProperties(4)` 返回的 COSMOSMotion 属性可读(积分器类型、误差容限、最大迭代 次数等),但该对象不暴露 `GetFrameRate/SetFrameRate`。 -力与力矩、弹簧、阻尼、3D 接触特征、曲线图与 CSV 读取、求解器/许可可用时的真实 -动力学核验、图像尺寸与比例参数、运动事件回调仍待补齐。 +曲线图与 CSV 读取、求解器/许可可用时的真实动力学核验、图像尺寸与比例参数、 +运动事件回调仍待补齐;载荷本身的残余缺口见下一节。 + +## Motion 载荷:力、弹簧、阻尼与 3D 接触 + +新增 4 个原生载荷工具:`add_motion_force`(线性力/力矩)、`add_motion_spring` +(线性/扭转/线性运动/扭转运动弹簧)、`add_motion_damper`(线性/扭转阻尼)、 +`add_motion_contact`(3D 接触)。特征按类型库 `swFeatureNameID_e` 值 75/76、 +79–82、83/84、85 经 `CreateDefinition` 与 `CreateFeature` 创建,两个端点用同一 +选择集的两个标记(mark 1 与追加的 mark 2)取得,第二选择必须 append,否则会 +替换第一选择。 + +本批查出一个影响所有运动特征创建的既有缺陷:`CreateFeature` 返回的 dispatch +对象在本机报告的是**上一个**特征的名称与类型,因此原来的“返回对象是否出现在 +特征清单里”判据可能把旧特征当作新特征。现在创建前后对比 `GetMotionFeatures()` +找出新增条目,以该条目的原生类型与读回参数判定成功;`add_motion_gravity`、 +`add_motion_motor` 一并改用同一路径。 + +实机验证在新建双零件装配体的运动算例上进行(日志 `motion-payload-live.log`), +一次创建 9 个载荷特征并逐个读回:线性力 10 N(`ForceType` 0、`ForceFunctionType` +常量 0、幅值读回)、力矩 5 N·m 反向(`ForceType` 1)、线性弹簧(常数 1、自由长度 +0.05)、扭转弹簧(常数 2)、线性运动弹簧(3 圈读回 3)、线性阻尼 0.5、扭转阻尼 +0.75、3D 接触(刚度 100000、指数 2、最大阻尼 1000、穿透 0.0001、摩擦 full 读回 1、 +恢复系数 0.3)。特征类型分别为 `AEMLinearForce`、`AEMTorque`、`AEMLinearSpring` +(79 与 81 共用)、`AEMTorsionalSpring`(80 与 82 共用)、`AEMLinearDamper`、 +`AEMTorsionalDamper`、`AEM3DContact`。 + +原生约束按事实记录:3D 接触的 `ContactComponents` 只接受 +SAFEARRAY(VT_ARRAY|VT_DISPATCH),元组赋值被静默忽略,工具固定使用前者;扭转运动 +弹簧(82)忽略线圈直径、圈数与线径(读回 -1),工具对该场景返回失败并保留观测; +扭转弹簧的 `FreeAngle` 属性在本机类型库存在但读写均抛 `AttributeError`。 +力函数仅验证常量类型,step/harmonic/function/spline 的函数值尚未专项验证。 +零件文档对本批载荷同样返回带 `document_type="part"` 的结构化失败。222 项离线 +测试通过(Motion 载荷 7 项:幅值前置拒绝、新特征识别、弹簧参数作用域、阻尼前置 +拒绝、接触双组件校验、恢复系数范围、接触读写回)。 ## 原生 API 清点 diff --git a/README.md b/README.md index 665c85c..d0a2dce 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It does not launch SOLIDWORKS, register an add-in, execute arbitrary code, or touch the network. It attaches to a session you already have open and calls the documented API — so if a tool can't do something, neither could a macro. -188 tools: sketching with real relations and driving dimensions, the solid +192 tools: sketching with real relations and driving dimensions, the solid features you actually reach for, reference geometry, assemblies and mates, and — importantly — a feedback channel, including screenshots returned as images so the model can see what it just built. diff --git a/solidworks_mcp/sw_motion.py b/solidworks_mcp/sw_motion.py index 7bd1379..b1dc64e 100644 --- a/solidworks_mcp/sw_motion.py +++ b/solidworks_mcp/sw_motion.py @@ -18,6 +18,9 @@ import time from pathlib import Path +import pythoncom +import win32com.client + from .sw_core import ( active_document, as_list, byref_long, document_type, extension, flag_methods, iter_components, require_selection, result, safe, to_deg, tool, value, @@ -27,6 +30,22 @@ TYPES = {"animation": 1, "basic_motion": 2, "motion_analysis": 4} SIMULATION_TYPES = (2, 4) MOTOR_TYPES = {"linear": 77, "rotational": 78} +# swFeatureNameID_e: 75 linear force, 76 torque, 79-82 springs, 83-84 dampers, 85 3D contact. +FORCE_TYPES = {"linear": 75, "torque": 76} +SPRING_TYPES = {"linear": 79, "torsional": 80, "linear_motion": 81, "torsional_motion": 82} +DAMPER_TYPES = {"linear": 83, "torsional": 84} +CONTACT_TYPE = 85 +# swSimulationForceFunctionType_e +FORCE_FUNCTIONS = {"constant": 0, "step": 1, "harmonic": 2, "function": 3, "spline": 4} +# swMotionContactFrictionType_e +FRICTION_OPTIONS = {"off": 0, "full": 1, "dynamic": 2} +# Creation reads these back from the study's own feature list: the motion springs are +# stored as the plain spring type of the same direction, which is why 81/82 map to +# the linear/torsional feature type instead of a separate coil-spring feature. +FORCE_FEATURE_TYPES = {"linear": "AEMLinearForce", "torque": "AEMTorque"} +SPRING_FEATURE_TYPES = {"linear": "AEMLinearSpring", "torsional": "AEMTorsionalSpring", + "linear_motion": "AEMLinearSpring", "torsional_motion": "AEMTorsionalSpring"} +DAMPER_FEATURE_TYPES = {"linear": "AEMLinearDamper", "torsional": "AEMTorsionalDamper"} GRAVITY_AXES = {"x": 0, "y": 1, "z": 2} PART_DOCUMENT = 1 ASSEMBLY_DOCUMENT = 2 @@ -37,6 +56,8 @@ "mp4": 7, "mkv": 8, "flv": 9, "lxo": 10} NAME = {"type": "string", "minLength": 1} TYPE = {"type": "string", "enum": list(TYPES), "default": "animation"} +TWO_REFERENCES = {"type": "array", "minItems": 2, "maxItems": 2, + "items": {"type": "object", "description": "Selection specification for one end point."}} # Members called with arguments. Properties are deliberately absent: flagging a # property as a method makes its put unresolvable. @@ -165,6 +186,46 @@ def _component(doc, name=None): return components[0] if components else None +def _two_references(doc, references): + """Select both end points of a motion element in a single selection set.""" + if not isinstance(references, (list, tuple)) or len(references) != 2: + raise RuntimeError("Provide exactly two reference selections for the two end points.") + objects = [] + for index, spec in enumerate(references, start=1): + count = require_selection(doc, spec, mark=index, append=index > 1) + if count != 1: + raise RuntimeError(f"End point {index} must select exactly one object, not {count}.") + selection = flag_methods(doc.SelectionManager, "GetSelectedObject6") + reference = selection.GetSelectedObject6(index, -1) + if reference is None: + raise RuntimeError(f"End point {index} resolved to nothing after selection.") + objects.append(reference) + return objects + + +def _create_feature(study, definition): + """Create a motion feature and identify it by the study's own feature list. + + The dispatch object CreateFeature returns can still report the previous + feature's name and type on this build, so the new entry is found by diffing + GetMotionFeatures instead of trusting the returned object. + """ + before = [entry["name"] for entry in _features(study)] + feature = study.CreateFeature(definition) + if feature is None: + return None, None + for entry in reversed(_features(study)): + if entry["name"] not in before: + return feature, entry + return feature, {"name": str(safe(feature, "Name", "")), "type": str(safe(feature, "GetTypeName2", ""))} + + +def _dispatch_array(objects): + """A SAFEARRAY of dispatch pointers; the contact data object requires one.""" + return win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, + tuple(entry._oleobj_ for entry in objects)) + + def _sample(results, component, times): flag_methods(results, *_RESULT_METHODS) samples = [] @@ -381,14 +442,13 @@ def add_motion_gravity(args): definition.Strength = strength definition.ReverseDirection = reverse definition.Axis = axis - feature = study.CreateFeature(definition) + feature, created = _create_feature(study, definition) if feature is None: return result(False, "SOLIDWORKS refused the gravity feature.") - created = {"name": str(safe(feature, "Name", "")), "type": str(safe(feature, "GetTypeName2", ""))} stored = {"strength": float(value(definition, "Strength")), "reverse_direction": bool(value(definition, "ReverseDirection")), "axis": int(value(definition, "Axis"))} features = _features(study) - confirmed = (any(entry["name"] == created["name"] for entry in features) + confirmed = (created["type"] == "AEMGravity" and math.isclose(stored["strength"], strength, rel_tol=1e-9) and stored["reverse_direction"] == reverse and stored["axis"] == axis) return result(confirmed, "Read the created gravity feature back.", feature=created, definition=stored, @@ -429,14 +489,13 @@ def add_motion_motor(args): definition.ConstantSpeedMotor(speed) stored = {"speed": float(value(definition, "Velocity")), "reverse_direction": bool(value(definition, "ReverseDirection")), "motor_type": int(value(definition, "MotorType"))} - feature = study.CreateFeature(definition) + feature, created = _create_feature(study, definition) if feature is None: return result(False, "SOLIDWORKS refused the motor feature.", definition=stored) - created = {"name": str(safe(feature, "Name", "")), "type": str(safe(feature, "GetTypeName2", ""))} features = _features(study) stored = {"speed": float(value(definition, "Velocity")), "reverse_direction": bool(value(definition, "ReverseDirection")), "motor_type": int(value(definition, "MotorType"))} - confirmed = (any(entry["name"] == created["name"] for entry in features) + confirmed = (created["type"] == ("AEMLinearMotor" if motor_type == "linear" else "AEMRotationalMotor") and math.isclose(stored["speed"], speed, rel_tol=1e-9) and stored["reverse_direction"] == reverse and stored["motor_type"] == (0 if motor_type == "linear" else 1)) @@ -444,6 +503,229 @@ def add_motion_motor(args): features=features, count=int(value(study, "GetMotionFeaturesCount"))) +@tool("add_motion_force", "Create a constant native linear force or torque between two selected references and read the stored function back.", + {"name": NAME, "force_type": {"type": "string", "enum": list(FORCE_TYPES), "default": "linear"}, + "magnitude": {"type": "number", "description": "Constant value in the document force unit (N or N m for torque)."}, + "reverse_direction": {"type": "boolean", "default": False}, + "references": TWO_REFERENCES}, ["name", "magnitude", "references"]) +def add_motion_force(args): + magnitude = float(args["magnitude"]) + if not math.isfinite(magnitude) or magnitude == 0: + raise RuntimeError("Force magnitude must be a finite non-zero number.") + force_type = str(args.get("force_type", "linear")) + if force_type not in FORCE_TYPES: + raise RuntimeError(f"Unknown force type '{force_type}'.") + reverse = bool(args.get("reverse_direction", False)) + try: + manager = _manager(require_elements=True) + except MotionElementsUnsupported as exc: + return result(False, str(exc), document_type=exc.label) + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before adding the force.", study=_info(study)) + _, doc = active_document() + references = _two_references(doc, args["references"]) + definition = flag_methods(study.CreateDefinition(FORCE_TYPES[force_type]), "SetEndPoints", "GetEndPoints") + if definition is None: + return result(False, "SOLIDWORKS returned no force feature data object.") + definition.SetEndPoints(*references) + definition.ForceFunctionType = FORCE_FUNCTIONS["constant"] + definition.FunctionConstantValue = magnitude + definition.ReverseDirection = reverse + stored = {"magnitude": float(value(definition, "FunctionConstantValue")), + "function_type": int(value(definition, "ForceFunctionType")), + "force_type": int(value(definition, "ForceType")), + "action_type": int(value(definition, "ActionType")), + "reverse_direction": bool(value(definition, "ReverseDirection"))} + feature, created = _create_feature(study, definition) + if feature is None: + return result(False, "SOLIDWORKS refused the force feature.", definition=stored) + confirmed = (created["type"] == FORCE_FEATURE_TYPES[force_type] + and math.isclose(stored["magnitude"], magnitude, rel_tol=1e-9) + and stored["force_type"] == (0 if force_type == "linear" else 1) + and stored["reverse_direction"] == reverse) + return result(confirmed, "Read the created force feature back.", feature=created, definition=stored, + features=_features(study), count=int(value(study, "GetMotionFeaturesCount"))) + + +@tool("add_motion_spring", "Create a native linear, torsional or coil motion spring between two selected references and read the stored characteristics back.", + {"name": NAME, "spring_type": {"type": "string", "enum": list(SPRING_TYPES), "default": "linear"}, + "spring_constant": {"type": "number", "exclusiveMinimum": 0}, + "free_length": {"type": "number", "exclusiveMinimum": 0, "description": "Linear springs only."}, + "coil_diameter": {"type": "number", "exclusiveMinimum": 0, "description": "Coil motion springs only."}, + "number_of_coils": {"type": "integer", "minimum": 1, "description": "Coil motion springs only."}, + "wire_diameter": {"type": "number", "exclusiveMinimum": 0, "description": "Coil motion springs only."}, + "references": TWO_REFERENCES}, ["name", "spring_constant", "references"]) +def add_motion_spring(args): + constant = float(args["spring_constant"]) + if not math.isfinite(constant) or constant <= 0: + raise RuntimeError("Spring constant must be a finite positive number.") + spring_type = str(args.get("spring_type", "linear")) + if spring_type not in SPRING_TYPES: + raise RuntimeError(f"Unknown spring type '{spring_type}'.") + for key in ("free_length", "coil_diameter", "wire_diameter"): + if key in args and (not math.isfinite(float(args[key])) or float(args[key]) <= 0): + raise RuntimeError(f"{key} must be a finite positive number.") + if "number_of_coils" in args and int(args["number_of_coils"]) < 1: + raise RuntimeError("number_of_coils must be at least one.") + coil_spring = spring_type in ("linear_motion", "torsional_motion") + if "free_length" in args and coil_spring: + raise RuntimeError("free_length applies to the linear and torsional spring types, not to the coil motion springs.") + if not coil_spring and any(key in args for key in ("coil_diameter", "number_of_coils", "wire_diameter")): + raise RuntimeError("Coil diameter, coil count and wire diameter apply only to the coil motion springs.") + try: + manager = _manager(require_elements=True) + except MotionElementsUnsupported as exc: + return result(False, str(exc), document_type=exc.label) + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before adding the spring.", study=_info(study)) + _, doc = active_document() + references = _two_references(doc, args["references"]) + definition = flag_methods(study.CreateDefinition(SPRING_TYPES[spring_type]), "SetEndPoints", "GetEndPoints") + if definition is None: + return result(False, "SOLIDWORKS returned no spring feature data object.") + definition.SetEndPoints(*references) + definition.SpringConstant = constant + requested = {"spring_constant": constant} + stored = {"spring_constant": float(value(definition, "SpringConstant"))} + if "free_length" in args: + definition.FreeLength = float(args["free_length"]) + requested["free_length"] = float(args["free_length"]) + stored["free_length"] = float(value(definition, "FreeLength")) + for key, attributename in (("coil_diameter", "CoilDiameter"), ("number_of_coils", "NumberOfCoils"), + ("wire_diameter", "WireDiameter")): + if key not in args: + continue + setattr(definition, attributename, args[key]) + requested[key] = args[key] + stored[key] = value(definition, attributename) + feature, created = _create_feature(study, definition) + if feature is None: + return result(False, "SOLIDWORKS refused the spring feature.", definition=stored) + confirmed = created["type"] == SPRING_FEATURE_TYPES[spring_type] + for key, wanted in requested.items(): + actual = stored.get(key) + if key == "number_of_coils": + confirmed = confirmed and int(actual) == int(wanted) + else: + confirmed = confirmed and math.isclose(float(actual), float(wanted), rel_tol=1e-9) + return result(confirmed, "Read the created spring feature back.", feature=created, definition=stored, + requested=requested, features=_features(study), count=int(value(study, "GetMotionFeaturesCount"))) + + +@tool("add_motion_damper", "Create a native linear or torsional damper between two selected references and read the stored damping constant back.", + {"name": NAME, "damper_type": {"type": "string", "enum": list(DAMPER_TYPES), "default": "linear"}, + "damping_constant": {"type": "number", "exclusiveMinimum": 0}, + "references": TWO_REFERENCES}, ["name", "damping_constant", "references"]) +def add_motion_damper(args): + constant = float(args["damping_constant"]) + if not math.isfinite(constant) or constant <= 0: + raise RuntimeError("Damping constant must be a finite positive number.") + damper_type = str(args.get("damper_type", "linear")) + if damper_type not in DAMPER_TYPES: + raise RuntimeError(f"Unknown damper type '{damper_type}'.") + try: + manager = _manager(require_elements=True) + except MotionElementsUnsupported as exc: + return result(False, str(exc), document_type=exc.label) + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before adding the damper.", study=_info(study)) + _, doc = active_document() + references = _two_references(doc, args["references"]) + definition = flag_methods(study.CreateDefinition(DAMPER_TYPES[damper_type]), "SetEndPoints", "GetEndPoints") + if definition is None: + return result(False, "SOLIDWORKS returned no damper feature data object.") + definition.SetEndPoints(*references) + definition.DampingConstant = constant + stored = {"damping_constant": float(value(definition, "DampingConstant")), + "type": int(value(definition, "Type"))} + feature, created = _create_feature(study, definition) + if feature is None: + return result(False, "SOLIDWORKS refused the damper feature.", definition=stored) + confirmed = (created["type"] == DAMPER_FEATURE_TYPES[damper_type] + and math.isclose(stored["damping_constant"], constant, rel_tol=1e-9)) + return result(confirmed, "Read the created damper feature back.", feature=created, definition=stored, + features=_features(study), count=int(value(study, "GetMotionFeaturesCount"))) + + +@tool("add_motion_contact", "Create a native 3D contact feature between two components and read the contact parameters back.", + {"name": NAME, + "components": {"type": "array", "minItems": 2, "maxItems": 2, + "items": {"type": "string", "minLength": 1}, + "description": "Two distinct top-level component names."}, + "stiffness": {"type": "number", "exclusiveMinimum": 0}, + "exponent": {"type": "number", "exclusiveMinimum": 0}, + "max_damping": {"type": "number", "minimum": 0}, + "penetration": {"type": "number", "exclusiveMinimum": 0}, + "friction_option": {"type": "string", "enum": list(FRICTION_OPTIONS), "default": "full"}, + "use_restitution": {"type": "boolean", "default": False}, + "restitution": {"type": "number", "minimum": 0, "maximum": 1, "default": 0.5}}, + ["name", "components"]) +def add_motion_contact(args): + names = [str(entry) for entry in args["components"]] + if len(names) != 2 or len(set(names)) != 2: + raise RuntimeError("Provide two distinct component names for the contact pair.") + friction = str(args.get("friction_option", "full")) + if friction not in FRICTION_OPTIONS: + raise RuntimeError(f"Unknown friction option '{friction}'.") + for key in ("stiffness", "exponent", "penetration"): + if key in args and (not math.isfinite(float(args[key])) or float(args[key]) <= 0): + raise RuntimeError(f"{key} must be a finite positive number.") + if "max_damping" in args and (not math.isfinite(float(args["max_damping"])) or float(args["max_damping"]) < 0): + raise RuntimeError("max_damping must be a finite non-negative number.") + restitution = float(args.get("restitution", 0.5)) + if not math.isfinite(restitution) or not 0 <= restitution <= 1: + raise RuntimeError("restitution must be between 0 and 1.") + use_restitution = bool(args.get("use_restitution", False)) + try: + manager = _manager(require_elements=True) + except MotionElementsUnsupported as exc: + return result(False, str(exc), document_type=exc.label) + study = _study(manager, _name(args["name"])) + if not _activate(manager, study): + return result(False, "Could not activate the study before adding the contact.", study=_info(study)) + _, doc = active_document() + try: + components = [_component(doc, name) for name in names] + except RuntimeError as exc: + return result(False, str(exc), components=names) + definition = study.CreateDefinition(CONTACT_TYPE) + if definition is None: + return result(False, "SOLIDWORKS returned no 3D contact feature data object.") + definition.ContactComponents = _dispatch_array(components) + definition.FrictionOption = FRICTION_OPTIONS[friction] + definition.UseRestitutionCoefficient = use_restitution + definition.RestitutionCoefficient = restitution + requested = {"friction_option": FRICTION_OPTIONS[friction], "use_restitution": use_restitution, + "restitution": restitution} + for key, attributename in (("stiffness", "Stiffness"), ("exponent", "Exponent"), + ("max_damping", "MaxDamping"), ("penetration", "Penetration")): + if key in args: + setattr(definition, attributename, float(args[key])) + requested[key] = float(args[key]) + stored = {"stiffness": float(value(definition, "Stiffness")), "exponent": float(value(definition, "Exponent")), + "max_damping": float(value(definition, "MaxDamping")), + "penetration": float(value(definition, "Penetration")), + "friction_option": int(value(definition, "FrictionOption")), + "use_restitution": bool(value(definition, "UseRestitutionCoefficient")), + "restitution": float(value(definition, "RestitutionCoefficient"))} + feature, created = _create_feature(study, definition) + if feature is None: + return result(False, "SOLIDWORKS refused the 3D contact feature.", definition=stored, components=names) + confirmed = created["type"] == "AEM3DContact" + for key, wanted in requested.items(): + actual = stored[key] + if key in ("friction_option", "use_restitution"): + confirmed = confirmed and actual == wanted + else: + confirmed = confirmed and math.isclose(float(actual), float(wanted), rel_tol=1e-9) + return result(confirmed, "Read the created 3D contact feature back.", feature=created, definition=stored, + requested=requested, components=names, features=_features(study), + count=int(value(study, "GetMotionFeaturesCount"))) + + @tool("solve_motion_study", "Activate a motion study, run the native Calculate, then read the results object back: out-of-date flag plus sampled kinematics of a component.", {"name": NAME, "component": {"type": "string", "minLength": 1}, "sample_times": {"type": "array", "items": {"type": "number", "minimum": 0}, "minItems": 2}}, ["name"]) diff --git a/tests/live_motion_payload_regression.py b/tests/live_motion_payload_regression.py new file mode 100644 index 0000000..dc557ac --- /dev/null +++ b/tests/live_motion_payload_regression.py @@ -0,0 +1,146 @@ +# Copyright 2026 JIALE LIU +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for details. + +"""Live MotionManager payload checks: force, torque, springs, dampers and 3D contact. + +Fixtures stay under SW_MCP_OUTPUT_ROOT/.motion-payload/. Every element is +created between two real component faces/edges of a scratch assembly, and the +study's own feature list decides whether the native feature really appeared. +""" + +import json +from pathlib import Path +import sys +import uuid + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from live_expansion_regression import call, near +from solidworks_mcp.sw_core import HANDLERS, OUTPUT_ROOT, as_list, running_app, value + + +def raw(tool_name, **args): + payload = HANDLERS[tool_name](args) + print(f"{'PASS' if payload['ok'] else 'FAIL'} {tool_name}: {payload.get('message')}", flush=True) + return payload + + +def main(): + app = running_app() + initial = {str(value(document, "GetTitle")) for document in as_list(value(app, "GetDocuments"))} + original = str(value(app.ActiveDoc, "GetTitle")) if app.ActiveDoc is not None else "" + folder = OUTPUT_ROOT / ".motion-payload" / uuid.uuid4().hex + folder.mkdir(parents=True, exist_ok=True) + try: + part_paths = [] + for label in ("left", "right"): + call("create_new_document", kind="part") + call("create_sketch", plane="front", name="Base") + call("draw_rectangle", x1_mm=0, y1_mm=0, x2_mm=10, y2_mm=10) + call("close_sketch") + call("boss_extrude", sketch_name="Base", depth_mm=10) + near(call("get_mass_properties")["volume_mm3"], 1000, f"{label} fixture volume") + path = folder / f"{label}.SLDPRT" + call("save_document", path=str(path)) + call("close_document", name=call("list_open_documents")["active_title"], discard_changes=True) + part_paths.append(path) + + assembly = call("create_new_document", kind="assembly")["document"]["title"] + components = [] + for path in part_paths: + component = call("insert_component", path=str(path))["component"] + call("set_component_fixed", component_names=[component], fixed=False) + components.append(component) + + call("create_motion_study", name="Payload", type="basic_motion") + faces = call("list_faces")["faces"] + candidates = sorted(faces, key=lambda entry: entry["point_mm"][0]) + face_pair = [{"faces": [candidates[0]["index"]]}, {"faces": [candidates[-1]["index"]]}] + edges = call("list_edges")["edges"] + edge_pair = [{"edges": [edges[0]["index"]]}, {"edges": [edges[-1]["index"]]}] + + force = call("add_motion_force", name="Payload", force_type="linear", magnitude=10.0, + reverse_direction=False, references=face_pair) + assert force["feature"]["type"] == "AEMLinearForce", force + assert force["definition"]["force_type"] == 0, force + near(force["definition"]["magnitude"], 10.0, "linear force magnitude") + + torque = call("add_motion_force", name="Payload", force_type="torque", magnitude=5.0, + reverse_direction=True, references=edge_pair) + assert torque["feature"]["type"] == "AEMTorque", torque + assert torque["definition"]["force_type"] == 1 and torque["definition"]["reverse_direction"] is True, torque + near(torque["definition"]["magnitude"], 5.0, "torque magnitude") + + spring = call("add_motion_spring", name="Payload", spring_type="linear", spring_constant=1.0, + free_length=0.05, references=face_pair) + assert spring["feature"]["type"] == "AEMLinearSpring", spring + near(spring["definition"]["spring_constant"], 1.0, "linear spring constant") + near(spring["definition"]["free_length"], 0.05, "linear spring free length") + + torsional = call("add_motion_spring", name="Payload", spring_type="torsional", spring_constant=2.0, + references=edge_pair) + assert torsional["feature"]["type"] == "AEMTorsionalSpring", torsional + near(torsional["definition"]["spring_constant"], 2.0, "torsional spring constant") + + coil = call("add_motion_spring", name="Payload", spring_type="linear_motion", spring_constant=1.0, + coil_diameter=0.01, number_of_coils=3, wire_diameter=0.001, references=edge_pair) + assert coil["feature"]["type"] == "AEMLinearSpring", coil + assert coil["definition"]["number_of_coils"] == 3, coil + + coil_torsion = raw("add_motion_spring", name="Payload", spring_type="torsional_motion", + spring_constant=1.0, coil_diameter=0.01, number_of_coils=3, + wire_diameter=0.001, references=edge_pair) + if coil_torsion["ok"]: + assert coil_torsion["definition"]["number_of_coils"] == 3, coil_torsion + else: + assert coil_torsion["data"]["definition"]["number_of_coils"] in (-1, 0), coil_torsion + print("Torsional coil springs ignore the coil geometry on this build; recorded as a gap.", flush=True) + + damper = call("add_motion_damper", name="Payload", damper_type="linear", damping_constant=0.5, + references=face_pair) + assert damper["feature"]["type"] == "AEMLinearDamper", damper + near(damper["definition"]["damping_constant"], 0.5, "linear damping constant") + + torsional_damper = call("add_motion_damper", name="Payload", damper_type="torsional", + damping_constant=0.75, references=edge_pair) + assert torsional_damper["feature"]["type"] == "AEMTorsionalDamper", torsional_damper + near(torsional_damper["definition"]["damping_constant"], 0.75, "torsional damping constant") + + contact = call("add_motion_contact", name="Payload", components=components, stiffness=100000.0, + exponent=2.0, max_damping=1000.0, penetration=0.0001, + friction_option="full", use_restitution=True, restitution=0.3) + assert contact["feature"]["type"] == "AEM3DContact", contact + near(contact["definition"]["stiffness"], 100000.0, "contact stiffness") + assert contact["definition"]["friction_option"] == 1, contact + near(contact["definition"]["restitution"], 0.3, "contact restitution") + + listed = call("list_motion_features", name="Payload") + types = [entry["type"] for entry in listed["features"]] + print(f"Payload features ({listed['count']}): {json.dumps(types, ensure_ascii=False)}", flush=True) + for expected in ("AEMLinearForce", "AEMTorque", "AEMLinearSpring", "AEMTorsionalSpring", + "AEMLinearDamper", "AEMTorsionalDamper", "AEM3DContact"): + assert expected in types, types + call("delete_motion_study", name="Payload") + print(f"Motion payload tools verified on {assembly} with components {components}.", flush=True) + finally: + for _ in range(2): + documents = list(call("list_open_documents")["documents"]) + documents.sort(key=lambda entry: entry["document_type"] != "assembly") + for document in documents: + if document["title"] not in initial: + HANDLERS["close_document"]({"name": document["title"], "discard_changes": True}) + remaining = [entry["title"] for entry in call("list_open_documents")["documents"]] + if original and original in remaining: + call("activate_document", name=original) + print("REMAINING " + json.dumps(remaining, ensure_ascii=False), flush=True) + assert set(remaining) <= initial, remaining + + +if __name__ == "__main__": + main() diff --git a/tests/test_motion.py b/tests/test_motion.py index 71466bc..167f13d 100644 --- a/tests/test_motion.py +++ b/tests/test_motion.py @@ -294,5 +294,144 @@ def test_frame_wait_returns_finished_files_only(self): self.assertEqual([path.name for path in files], ["anim-0000.png", "anim-0001.png"]) +class FakeForceDefinition: + def __init__(self): + self.FunctionConstantValue = 0.0 + self.ForceFunctionType = 0 + self.ForceType = 0 + self.ActionType = 0 + self.ReverseDirection = False + self.endpoints = None + + def SetEndPoints(self, one, two): + self.endpoints = (one, two) + + def _FlagAsMethod(self, name): + return None + + +class FakeContactDefinition: + def __init__(self): + self.ContactComponents = None + self.FrictionOption = 1 + self.UseRestitutionCoefficient = False + self.RestitutionCoefficient = 0.5 + self.Stiffness = 0.0 + self.Exponent = 1.0 + self.MaxDamping = 0.0 + self.Penetration = 0.0 + + +class FakePayloadStudy: + """CreateFeature returns a stale dispatch object, as the live build does.""" + + def __init__(self, kind, definition): + self.kind = kind + self.definition = definition + self.features = [] + + def CreateDefinition(self, code): + return self.definition + + def CreateFeature(self, definition): + self.features.append(SimpleNamespace(Name=f"Feature{len(self.features) + 1}", GetTypeName2=lambda: self.kind)) + return SimpleNamespace(Name="Feature1", GetTypeName2=lambda: "AEMStaleType") + + def GetMotionFeatures(self): + return list(self.features) + + def GetMotionFeaturesCount(self): + return len(self.features) + + +class MotionPayloadTests(unittest.TestCase): + @staticmethod + def fake_document(count=2): + references = [object() for _ in range(count)] + selection = SimpleNamespace(GetSelectedObject6=lambda mark, index: references[mark - 1], + _FlagAsMethod=lambda name: None) + return SimpleNamespace(SelectionManager=selection), references + + def test_force_rejects_invalid_magnitude_before_com(self): + for magnitude in (0, float("nan"), float("inf")): + with self.subTest(magnitude=magnitude), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.add_motion_force({"name": "Solve", "magnitude": magnitude, + "references": [{}, {}]}) + manager.assert_not_called() + + def test_force_identifies_the_new_feature_not_the_returned_dispatch_object(self): + definition = FakeForceDefinition() + study = FakePayloadStudy("AEMLinearForce", definition) + study.features.append(SimpleNamespace(Name="OldFeature", GetTypeName2=lambda: "AEMOldType")) + doc, references = self.fake_document() + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "active_document", return_value=(None, doc)), \ + patch.object(sw_motion, "require_selection", return_value=1): + payload = sw_motion.add_motion_force({"name": "Solve", "magnitude": 12.0, + "references": [{"faces": [0]}, {"faces": [1]}]}) + self.assertTrue(payload["ok"], payload) + self.assertEqual(payload["data"]["feature"], {"name": "Feature2", "type": "AEMLinearForce"}) + self.assertEqual(definition.endpoints, tuple(references)) + self.assertEqual(definition.FunctionConstantValue, 12.0) + self.assertEqual(definition.ForceFunctionType, 0) + + def test_spring_parameter_scope_is_checked_before_com(self): + cases = ( + ({"spring_constant": 1.0, "spring_type": "linear", "coil_diameter": 0.01}, "coil geometry"), + ({"spring_constant": 1.0, "spring_type": "linear_motion", "free_length": 0.05}, "free length"), + ({"spring_constant": 0.0}, "spring constant"), + ({"spring_constant": 1.0, "number_of_coils": 0}, "coil count"), + ) + for extra, label in cases: + arguments = {"name": "Solve", "references": [{}, {}]} + arguments.update(extra) + with self.subTest(label=label), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.add_motion_spring(arguments) + manager.assert_not_called() + + def test_damper_rejects_invalid_constant_before_com(self): + for constant in (0, -1, float("nan"), float("inf")): + with self.subTest(constant=constant), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.add_motion_damper({"name": "Solve", "damping_constant": constant, + "references": [{}, {}]}) + manager.assert_not_called() + + def test_contact_requires_two_distinct_components(self): + for names in (["A"], ["A", "A"], ["A", "B", "C"]): + with self.subTest(names=names), patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.add_motion_contact({"name": "Solve", "components": names}) + manager.assert_not_called() + + def test_contact_rejects_an_out_of_range_restitution_before_com(self): + with patch.object(sw_motion, "_manager") as manager: + with self.assertRaises(RuntimeError): + sw_motion.add_motion_contact({"name": "Solve", "components": ["A", "B"], "restitution": 1.5}) + manager.assert_not_called() + + def test_contact_writes_both_components_and_reads_the_parameters_back(self): + definition = FakeContactDefinition() + study = FakePayloadStudy("AEM3DContact", definition) + components = [SimpleNamespace(Name2="left-1"), SimpleNamespace(Name2="right-1")] + with patch.object(sw_motion, "_manager"), patch.object(sw_motion, "_study", return_value=study), \ + patch.object(sw_motion, "_activate", return_value=True), \ + patch.object(sw_motion, "active_document", return_value=(None, SimpleNamespace())), \ + patch.object(sw_motion, "_component", side_effect=components), \ + patch.object(sw_motion, "_dispatch_array", return_value="PAIR") as array: + payload = sw_motion.add_motion_contact({"name": "Solve", "components": ["left-1", "right-1"], + "stiffness": 100000.0, "friction_option": "dynamic", + "use_restitution": True, "restitution": 0.25}) + self.assertTrue(payload["ok"], payload) + self.assertEqual(definition.ContactComponents, "PAIR") + self.assertEqual(definition.FrictionOption, 2) + self.assertTrue(definition.UseRestitutionCoefficient) + self.assertEqual(payload["data"]["feature"]["type"], "AEM3DContact") + array.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/tools/build_coverage_report.py b/tools/build_coverage_report.py index 15fba38..ca9d842 100644 --- a/tools/build_coverage_report.py +++ b/tools/build_coverage_report.py @@ -39,9 +39,10 @@ MODULE_TOOLS = {"Motion": ["list_motion_studies", "create_motion_study", "activate_motion_study", "duplicate_motion_study", "delete_motion_study", "set_motion_study_type", "set_motion_study_timing", "list_motion_features", "add_motion_gravity", - "add_motion_motor", "solve_motion_study", "get_motion_study_results", - "set_motion_study_animation", "control_motion_study_playback", - "export_motion_study_frames"]} + "add_motion_motor", "add_motion_force", "add_motion_spring", + "add_motion_damper", "add_motion_contact", "solve_motion_study", + "get_motion_study_results", "set_motion_study_animation", + "control_motion_study_playback", "export_motion_study_frames"]} def report(api): diff --git a/tools/verify_install.py b/tools/verify_install.py index f5dbc6a..a628e47 100644 --- a/tools/verify_install.py +++ b/tools/verify_install.py @@ -404,6 +404,36 @@ async def native_call(name, arguments=None): state=solved.get("data",{}) if state.get("native_accepted") is not True or state.get("out_of_date") is not False: raise RuntimeError(f"Installed MCP motion calculation state differs: {solved}.") + # Motion payloads need a second component: the force/spring/damper + # pair selects one face on each body, and 3D contact takes the two + # component names. Every check reads the study's own feature list + # because the handle CreateFeature returns describes the previous + # feature on this build. + second=(await call("insert_component", {"path":str(motion_part_path)}))["component"] + await call("set_component_fixed", {"component_names":[second],"fixed":False}) + faces=(await call("list_faces"))["faces"] + ordered=sorted(faces, key=lambda entry: entry["point_mm"][0]) + face_pair=[{"faces":[ordered[0]["index"]]},{"faces":[ordered[-1]["index"]]}] + edges=(await call("list_edges"))["edges"] + edge_pair=[{"edges":[edges[0]["index"]]},{"edges":[edges[-1]["index"]]}] + force=await call("add_motion_force", {"name":"InstalledMotion","force_type":"linear","magnitude":10.0,"references":face_pair}) + if force["feature"]["type"]!="AEMLinearForce" or force["definition"]["force_type"]!=0 or abs(force["definition"]["magnitude"]-10.0)>1e-9: + raise RuntimeError("Installed MCP linear force readback differs.") + torque=await call("add_motion_force", {"name":"InstalledMotion","force_type":"torque","magnitude":5.0,"reverse_direction":True,"references":edge_pair}) + if torque["feature"]["type"]!="AEMTorque" or torque["definition"]["force_type"]!=1 or torque["definition"]["reverse_direction"] is not True: + raise RuntimeError("Installed MCP torque readback differs.") + spring=await call("add_motion_spring", {"name":"InstalledMotion","spring_type":"linear","spring_constant":1.0,"free_length":0.05,"references":face_pair}) + if spring["feature"]["type"]!="AEMLinearSpring" or abs(spring["definition"]["free_length"]-0.05)>1e-9: + raise RuntimeError("Installed MCP spring readback differs.") + damper=await call("add_motion_damper", {"name":"InstalledMotion","damper_type":"linear","damping_constant":0.5,"references":face_pair}) + if damper["feature"]["type"]!="AEMLinearDamper" or abs(damper["definition"]["damping_constant"]-0.5)>1e-9: + raise RuntimeError("Installed MCP damper readback differs.") + contact=await call("add_motion_contact", {"name":"InstalledMotion","components":[component,second],"stiffness":100000.0,"friction_option":"full","use_restitution":True,"restitution":0.3}) + if contact["feature"]["type"]!="AEM3DContact" or contact["definition"]["friction_option"]!=1 or abs(contact["definition"]["restitution"]-0.3)>1e-9: + raise RuntimeError("Installed MCP 3D contact readback differs.") + payloads=await call("list_motion_features", {"name":"InstalledMotion"}) + if not {"AEMLinearForce","AEMTorque","AEMLinearSpring","AEMLinearDamper","AEM3DContact"} <= {entry["type"] for entry in payloads["features"]}: + raise RuntimeError(f"Installed MCP motion payload features are missing: {payloads}.") await call("delete_motion_study", {"name":"InstalledMotion"}) # Animation frames only come out of an animation study; a basic-motion # study owns the same timeline but its animator writes nothing.