solidworks-mcp-python 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. solidworks_mcp/__init__.py +57 -0
  2. solidworks_mcp/adapters/__init__.py +40 -0
  3. solidworks_mcp/adapters/base.py +1882 -0
  4. solidworks_mcp/adapters/circuit_breaker.py +2052 -0
  5. solidworks_mcp/adapters/com_executor.py +252 -0
  6. solidworks_mcp/adapters/complexity_analyzer.py +272 -0
  7. solidworks_mcp/adapters/connection_pool.py +1884 -0
  8. solidworks_mcp/adapters/factory.py +366 -0
  9. solidworks_mcp/adapters/intelligent_router.py +218 -0
  10. solidworks_mcp/adapters/mock_adapter.py +4016 -0
  11. solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
  12. solidworks_mcp/adapters/solidworks/__init__.py +17 -0
  13. solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
  14. solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
  15. solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
  16. solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
  17. solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
  18. solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
  19. solidworks_mcp/adapters/solidworks/features.py +3362 -0
  20. solidworks_mcp/adapters/solidworks/io.py +3649 -0
  21. solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
  22. solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
  23. solidworks_mcp/adapters/solidworks/selection.py +308 -0
  24. solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
  25. solidworks_mcp/adapters/sw_feature_errors.py +66 -0
  26. solidworks_mcp/adapters/sw_type_info.py +317 -0
  27. solidworks_mcp/adapters/vba_adapter.py +318 -0
  28. solidworks_mcp/adapters/vba_macro_executor.py +215 -0
  29. solidworks_mcp/agents/__init__.py +68 -0
  30. solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
  31. solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
  32. solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
  33. solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
  34. solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
  35. solidworks_mcp/agents/harness.py +216 -0
  36. solidworks_mcp/agents/history_db.py +1508 -0
  37. solidworks_mcp/agents/retrieval_index.py +164 -0
  38. solidworks_mcp/agents/schemas.py +165 -0
  39. solidworks_mcp/agents/smoke_test.py +285 -0
  40. solidworks_mcp/agents/soc_exporter.py +650 -0
  41. solidworks_mcp/agents/soc_pickup.py +332 -0
  42. solidworks_mcp/agents/soc_rewind.py +221 -0
  43. solidworks_mcp/agents/vector_rag.py +665 -0
  44. solidworks_mcp/cache/__init__.py +5 -0
  45. solidworks_mcp/cache/response_cache.py +164 -0
  46. solidworks_mcp/config.py +729 -0
  47. solidworks_mcp/exceptions.py +37 -0
  48. solidworks_mcp/security/__init__.py +68 -0
  49. solidworks_mcp/security/auth.py +128 -0
  50. solidworks_mcp/security/cors.py +27 -0
  51. solidworks_mcp/security/rate_limiting.py +90 -0
  52. solidworks_mcp/security/runtime.py +155 -0
  53. solidworks_mcp/server.py +777 -0
  54. solidworks_mcp/server_cli_fixed.py +120 -0
  55. solidworks_mcp/setup/__init__.py +1 -0
  56. solidworks_mcp/setup/cli.py +399 -0
  57. solidworks_mcp/setup/clients.py +157 -0
  58. solidworks_mcp/setup/config_file.py +230 -0
  59. solidworks_mcp/setup/handshake.py +202 -0
  60. solidworks_mcp/setup/launcher.py +102 -0
  61. solidworks_mcp/setup/legacy.py +100 -0
  62. solidworks_mcp/setup/registration.py +193 -0
  63. solidworks_mcp/setup/solidworks.py +49 -0
  64. solidworks_mcp/tools/__init__.py +64 -0
  65. solidworks_mcp/tools/analysis.py +723 -0
  66. solidworks_mcp/tools/automation.py +578 -0
  67. solidworks_mcp/tools/docs_discovery.py +1476 -0
  68. solidworks_mcp/tools/drawing.py +1015 -0
  69. solidworks_mcp/tools/drawing_analysis.py +456 -0
  70. solidworks_mcp/tools/drawing_automation.py +627 -0
  71. solidworks_mcp/tools/engineering_data.py +466 -0
  72. solidworks_mcp/tools/error_classifier.py +232 -0
  73. solidworks_mcp/tools/export.py +833 -0
  74. solidworks_mcp/tools/file_facts.py +40 -0
  75. solidworks_mcp/tools/file_management.py +1473 -0
  76. solidworks_mcp/tools/input_compat.py +17 -0
  77. solidworks_mcp/tools/macro_recording.py +464 -0
  78. solidworks_mcp/tools/modeling.py +2360 -0
  79. solidworks_mcp/tools/sketching.py +1422 -0
  80. solidworks_mcp/tools/template_management.py +367 -0
  81. solidworks_mcp/tools/tool_boundary.py +280 -0
  82. solidworks_mcp/tools/vba_generation.py +1145 -0
  83. solidworks_mcp/ui/__init__.py +9 -0
  84. solidworks_mcp/ui/local_llm.py +725 -0
  85. solidworks_mcp/ui/routers/__init__.py +10 -0
  86. solidworks_mcp/ui/routers/checkpoint.py +24 -0
  87. solidworks_mcp/ui/routers/docs.py +47 -0
  88. solidworks_mcp/ui/routers/llm.py +83 -0
  89. solidworks_mcp/ui/routers/local_model.py +69 -0
  90. solidworks_mcp/ui/routers/model.py +75 -0
  91. solidworks_mcp/ui/routers/preview.py +38 -0
  92. solidworks_mcp/ui/routers/session.py +161 -0
  93. solidworks_mcp/ui/routers/viewer.py +154 -0
  94. solidworks_mcp/ui/schemas.py +502 -0
  95. solidworks_mcp/ui/server.py +298 -0
  96. solidworks_mcp/ui/service.py +481 -0
  97. solidworks_mcp/ui/services/__init__.py +136 -0
  98. solidworks_mcp/ui/services/_utils.py +958 -0
  99. solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
  100. solidworks_mcp/ui/services/docs_service.py +282 -0
  101. solidworks_mcp/ui/services/llm_service.py +956 -0
  102. solidworks_mcp/ui/services/model_service.py +459 -0
  103. solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
  104. solidworks_mcp/ui/services/preview_service.py +489 -0
  105. solidworks_mcp/ui/services/session_service.py +1389 -0
  106. solidworks_mcp/utils/__init__.py +9 -0
  107. solidworks_mcp/utils/feature_tree_classifier.py +342 -0
  108. solidworks_mcp/utils/logging.py +76 -0
  109. solidworks_mcp/utils/validation.py +77 -0
  110. solidworks_mcp/version.py +8 -0
  111. solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
  112. solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
  113. solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
  114. solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
  115. solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2842 @@
1
+ """PyWin32 SolidWorks adapter for Windows COM integration.
2
+
3
+ This adapter uses pywin32 to communicate with SolidWorks via COM, providing real
4
+ SolidWorks automation capabilities on Windows platforms.
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ import platform
10
+ import re
11
+ import threading
12
+ import time
13
+ from collections.abc import Callable
14
+ from datetime import datetime
15
+ from types import SimpleNamespace
16
+ from typing import Any, TypeVar
17
+
18
+ from ..exceptions import SolidWorksMCPError
19
+ from . import sw_type_info
20
+ from .base import (
21
+ AdapterHealth,
22
+ AdapterResult,
23
+ AdapterResultStatus,
24
+ SolidWorksAdapter,
25
+ )
26
+ from .solidworks import (
27
+ SolidWorksDocumentationMixin,
28
+ SolidWorksDrawingStandardMixin,
29
+ SolidWorksFeaturesMixin,
30
+ SolidWorksIOMixin,
31
+ SolidWorksSelectionMixin,
32
+ SolidWorksSketchMixin,
33
+ bitmap,
34
+ )
35
+ from .solidworks.features import _null_callout, _select_feature_by_name
36
+ from .solidworks.io import _byref_int
37
+
38
+ try:
39
+ import pythoncom
40
+ import pywintypes
41
+ import win32com.client
42
+ from win32com.client import dynamic as _dynamic_module
43
+
44
+ PYWIN32_AVAILABLE = True
45
+ except ImportError:
46
+ # Keep names defined for tests that patch module attributes on non-Windows CI.
47
+ pythoncom = SimpleNamespace()
48
+ pywintypes = SimpleNamespace(com_error=Exception)
49
+ win32com = SimpleNamespace(client=SimpleNamespace())
50
+ _dynamic_module = SimpleNamespace(Dispatch=lambda *_a, **_kw: None)
51
+ PYWIN32_AVAILABLE = False
52
+
53
+
54
+ def _dynamic_dispatch(arg: Any) -> Any:
55
+ """Forward to ``win32com.client.dynamic.Dispatch`` via the imported
56
+ module reference. Forces late binding so VARIANT pass-by-ref params
57
+ used by ``OpenDoc6`` keep working even when the makepy ``gen_py``
58
+ wrapper is loaded.
59
+
60
+ Tests that need to stub dispatch should monkeypatch
61
+ ``win32com.client.dynamic.Dispatch`` directly — the monkeypatch
62
+ propagates through this module reference because Python modules
63
+ are singletons.
64
+ """
65
+ return _dynamic_module.Dispatch(arg)
66
+
67
+
68
+ from loguru import logger # noqa: E402
69
+
70
+ T = TypeVar("T")
71
+
72
+
73
+ def _byref_long(initial: int = 0) -> Any:
74
+ """Return a ``VT_BYREF | VT_I4`` VARIANT for a SolidWorks ByRef Long
75
+ out-parameter.
76
+
77
+ pywin32 late binding will not marshal a plain Python ``int`` into a
78
+ ``ByRef Long`` argument - the call fails with ``DISP_E_TYPEMISMATCH``
79
+ (``0x80020005``). ``RunMacro2``'s trailing ``nRetval`` needs the same
80
+ treatment as ``OpenDoc6``'s ``errors``/``warnings`` (issue #91).
81
+
82
+ Falls back to ``initial`` (a plain int) when pywin32 is unavailable so
83
+ the mock/CI test path keeps working.
84
+ """
85
+ variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
86
+ if not callable(variant_ctor):
87
+ return initial
88
+ return variant_ctor(
89
+ int(getattr(pythoncom, "VT_BYREF", 0)) | int(getattr(pythoncom, "VT_I4", 0)),
90
+ initial,
91
+ )
92
+
93
+
94
+ # OLE2 / Compound File Binary Format magic. A real (SW-authored) ``.swp`` is a
95
+ # compound file; a hand-written text macro is not.
96
+ _OLE2_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
97
+
98
+ # swRunMacroError_e - the code RunMacro2 writes into its ByRef error out-param.
99
+ # Not exposed in the SW type library, transcribed from swconst. Lets a failure
100
+ # say "22 (Invalidindex)" instead of a bare int.
101
+ _SW_RUN_MACRO_ERROR = {
102
+ 0: "NoError",
103
+ 1: "InvalidArg",
104
+ 2: "MacrosAreDisabled",
105
+ 3: "NotInDesignMode",
106
+ 4: "OnlyCodeModules",
107
+ 5: "OutOfMemory",
108
+ 6: "InvalidProcname",
109
+ 7: "InvalidPropertyType",
110
+ 8: "SuborfuncExpected",
111
+ 9: "BadParmCount",
112
+ 10: "BadVarType",
113
+ 11: "UserInterrupt",
114
+ 12: "Exception",
115
+ 13: "Overflow",
116
+ 14: "TypeMismatch",
117
+ 15: "ParmNotOptional",
118
+ 16: "UnknownLcid",
119
+ 17: "Busy",
120
+ 18: "ConnectionTerminated",
121
+ 19: "CallRejected",
122
+ 20: "CallFailed",
123
+ 21: "Zombied",
124
+ 22: "Invalidindex",
125
+ 23: "NoPermission",
126
+ 24: "Reverted",
127
+ 25: "TooManyOpenFiles",
128
+ 26: "DiskError",
129
+ 27: "CantSave",
130
+ 28: "OpenFileFailed",
131
+ }
132
+
133
+
134
+ def _headless_requested() -> bool:
135
+ """True when ``SOLIDWORKS_MCP_HEADLESS`` asks the SW window to stay hidden.
136
+
137
+ Off by default - a normal MCP session keeps the SolidWorks window visible.
138
+ The live test suite sets it so ``connect()`` does not re-show the window on
139
+ every per-test reconnect (each show/hide is a full window relayout).
140
+ """
141
+ return os.getenv("SOLIDWORKS_MCP_HEADLESS", "").strip().lower() in {
142
+ "1",
143
+ "true",
144
+ "yes",
145
+ "on",
146
+ }
147
+
148
+
149
+ # Operations that open, create, close or switch documents. SolidWorks rebuilds
150
+ # its UI (document window, CommandManager tabs, task panes) for each of these,
151
+ # so they get the longer settle pause.
152
+ _DOCUMENT_OPERATIONS = frozenset(
153
+ {
154
+ "activate_document",
155
+ "close_model",
156
+ "create_assembly",
157
+ "create_drawing",
158
+ "create_part",
159
+ "create_technical_drawing",
160
+ "open_model",
161
+ "pack_and_go_assembly",
162
+ "save_body_as_part",
163
+ "standard_drawing_views",
164
+ }
165
+ )
166
+ # Defaults in seconds; override with the environment variables below.
167
+ _DEFAULT_COM_SETTLE_SECONDS = 0.25
168
+ _DEFAULT_DOC_SETTLE_SECONDS = 1.0
169
+
170
+
171
+ def _settle_seconds(variable: str, default: float) -> float:
172
+ """Read a non-negative settle pause from the environment."""
173
+ try:
174
+ return max(0.0, float(os.getenv(variable, default)))
175
+ except ValueError:
176
+ return default
177
+
178
+
179
+ def _settle_after(operation_name: str) -> None:
180
+ """Pause after a COM operation so SolidWorks can finish its UI work.
181
+
182
+ SolidWorks does part of each operation's work later, in its idle loop
183
+ (CommandManager/task-pane refresh). Firing the next call while that is in
184
+ progress re-enters it, and SolidWorks 2026 crashed there repeatedly under
185
+ back-to-back API calls (COM pitfall #26). A human never drives it that
186
+ fast. Tunable via ``SOLIDWORKS_MCP_COM_SETTLE_SECONDS`` (every operation)
187
+ and ``SOLIDWORKS_MCP_DOC_SETTLE_SECONDS`` (document open/create/close).
188
+
189
+ Args:
190
+ operation_name: The ``_handle_com_operation`` name just completed.
191
+ """
192
+ if operation_name in _DOCUMENT_OPERATIONS:
193
+ pause = _settle_seconds(
194
+ "SOLIDWORKS_MCP_DOC_SETTLE_SECONDS", _DEFAULT_DOC_SETTLE_SECONDS
195
+ )
196
+ else:
197
+ pause = _settle_seconds(
198
+ "SOLIDWORKS_MCP_COM_SETTLE_SECONDS", _DEFAULT_COM_SETTLE_SECONDS
199
+ )
200
+ if pause > 0:
201
+ time.sleep(pause)
202
+
203
+
204
+ def _run_macro_error_name(code: Any) -> str:
205
+ """Render a swRunMacroError_e code as ``"<n> (<Name>)"`` when known."""
206
+ try:
207
+ code_int = int(code)
208
+ except (TypeError, ValueError):
209
+ return str(code)
210
+ name = _SW_RUN_MACRO_ERROR.get(code_int)
211
+ return f"{code_int} ({name})" if name else str(code_int)
212
+
213
+
214
+ def _parse_vb_module_name(macro_path: str) -> str:
215
+ """Resolve the VB module name ``RunMacro2`` needs for a macro file.
216
+
217
+ ``RunMacro2(path, ModuleName, ProcedureName, ...)`` fails with
218
+ ``swRunMacroError_Invalidindex`` (22) when ``ModuleName`` does not match a
219
+ module in the project, so the guess matters:
220
+
221
+ - A real ``.swp`` authored by SOLIDWORKS is an OLE compound file. Its
222
+ plaintext ``PROJECT`` stream carries ``Module=<name>``, and SW names that
223
+ module ``<stem>1`` (``sample_macro`` -> ``sample_macro1``) - the file stem
224
+ is the wrong guess. Read the real name out of the raw bytes.
225
+ - A hand-written text macro carries ``Attribute VB_Name = "..."``.
226
+ - Otherwise fall back to the file stem, then ``"SolidWorksMacro"`` (the name
227
+ the macro recorder uses).
228
+
229
+ Args:
230
+ macro_path (str): The macro path value.
231
+
232
+ Returns:
233
+ str: The resulting text value.
234
+ """
235
+ try:
236
+ with open(macro_path, "rb") as fh:
237
+ raw = fh.read()
238
+ except OSError:
239
+ raw = b""
240
+
241
+ if raw[:8] == _OLE2_MAGIC:
242
+ # PROJECT stream line: Module=<name> (CRLF-delimited ASCII)
243
+ match = re.search(rb"[\r\n]Module=([^\r\n\x00]{1,255})", raw)
244
+ if match:
245
+ name = match.group(1).decode("latin-1", "replace").strip().strip('"')
246
+ if name:
247
+ return name
248
+ else:
249
+ for line in raw.decode("utf-8", "replace").splitlines():
250
+ line = line.strip()
251
+ if line.lower().startswith("attribute vb_name"):
252
+ # Attribute VB_Name = "SolidWorksMacro"
253
+ _, _, rhs = line.partition("=")
254
+ return rhs.strip().strip('"').strip("'")
255
+
256
+ stem = os.path.splitext(os.path.basename(macro_path))[0]
257
+ if stem and not stem.startswith(".") and stem.strip("."):
258
+ return stem
259
+ return "SolidWorksMacro"
260
+
261
+
262
+ class _ComSessionCoordinator:
263
+ """Coordinate COM apartment initialisation and SolidWorks application lifecycle.
264
+
265
+ This collaborator is responsible for the low-level mechanics of connecting
266
+ to the SolidWorks COM server: initialising the COM apartment, acquiring the
267
+ ``SldWorks.Application`` object (with retries), waiting for the server to
268
+ become ready, toggling automation preferences, and tearing everything down
269
+ cleanly on disconnect.
270
+
271
+ It is instantiated once inside ``PyWin32Adapter.__init__`` and accessed
272
+ via ``self._com_coordinator``.
273
+
274
+ Attributes:
275
+ _adapter: Back-reference to the owning ``PyWin32Adapter`` instance.
276
+ """
277
+
278
+ def __init__(self, adapter: "PyWin32Adapter") -> None:
279
+ """Store a back-reference to the owning adapter.
280
+
281
+ Args:
282
+ adapter: The ``PyWin32Adapter`` instance that created this
283
+ coordinator. Must remain alive for the full lifetime of the
284
+ coordinator.
285
+ """
286
+ self._adapter = adapter
287
+
288
+ def initialize_com_apartment(self) -> None:
289
+ """Initialise the COM apartment via ``pythoncom.CoInitialize``.
290
+
291
+ Safe to call multiple times; subsequent calls are no-ops if the
292
+ apartment is already initialised (tracked via
293
+ ``adapter._com_initialized``).
294
+
295
+ Side Effects:
296
+ Sets ``adapter._com_initialized`` to ``True`` on first call.
297
+ """
298
+ if self._adapter._com_initialized:
299
+ return
300
+ pythoncom.CoInitialize()
301
+ self._adapter._com_initialized = True
302
+ # SolidWorks COM objects are STA-bound: only this thread may use them.
303
+ self._adapter._com_thread_id = threading.get_ident()
304
+
305
+ def uninitialize_com_apartment(self) -> None:
306
+ """Release the COM apartment via ``pythoncom.CoUninitialize``.
307
+
308
+ Only called when this coordinator was the one that initialised the
309
+ apartment (``adapter._com_initialized`` is ``True``). Resets the
310
+ flag afterwards so reconnect attempts work correctly.
311
+
312
+ Side Effects:
313
+ Sets ``adapter._com_initialized`` to ``False``.
314
+ """
315
+ if not self._adapter._com_initialized:
316
+ return
317
+ pythoncom.CoUninitialize()
318
+ self._adapter._com_initialized = False
319
+ self._adapter._com_thread_id = None
320
+
321
+ async def acquire_solidworks_application(self) -> Any:
322
+ """Acquire a live SolidWorks COM application object with retries.
323
+
324
+ Attempts up to 8 connect-cycles. Each cycle first tries
325
+ ``win32com.client.GetActiveObject("SldWorks.Application")`` to bind to
326
+ a running instance, then falls back to
327
+ ``win32com.client.Dispatch("SldWorks.Application")`` to start one.
328
+ Between cycles the coroutine sleeps 1 second so that a slow
329
+ SolidWorks launch has time to register its COM class.
330
+
331
+ Returns:
332
+ Any: The live ``SldWorks.Application`` COM object.
333
+
334
+ Raises:
335
+ SolidWorksMCPError: After all retries are exhausted, wraps the
336
+ last ``pywintypes.com_error`` or raises a generic message
337
+ when the returned object is ``None``.
338
+
339
+ Side Effects:
340
+ Sets ``adapter.swApp`` to the acquired COM object.
341
+ """
342
+ self._adapter.swApp = None
343
+ last_error: Exception | None = None
344
+ # Force late binding (dynamic.Dispatch) — the gen_py wrapper provides
345
+ # method-name lookup for flag_methods but early-bound dispatches
346
+ # reject VARIANT pass-by-ref params used by OpenDoc6 and friends.
347
+ for _ in range(8):
348
+ try:
349
+ raw = win32com.client.GetActiveObject("SldWorks.Application")
350
+ app = _dynamic_dispatch(raw) if raw is not None else None
351
+ if app is not None:
352
+ self._adapter.swApp = app
353
+ return app
354
+ except pywintypes.com_error as active_error:
355
+ last_error = active_error
356
+
357
+ try:
358
+ app = _dynamic_dispatch("SldWorks.Application")
359
+ if app is not None:
360
+ self._adapter.swApp = app
361
+ return app
362
+ except pywintypes.com_error as dispatch_error:
363
+ last_error = dispatch_error
364
+
365
+ await asyncio.sleep(1.0)
366
+
367
+ if last_error is not None:
368
+ raise SolidWorksMCPError(str(last_error))
369
+ raise SolidWorksMCPError("SolidWorks COM application instance is None")
370
+
371
+ async def wait_for_server_ready(self, app: Any) -> None:
372
+ """Poll until the SolidWorks COM server is responsive.
373
+
374
+ Reads ``app.RevisionNumber`` up to 10 times with 0.5-second pauses
375
+ between attempts. This guards against race conditions when SolidWorks
376
+ is still loading after the COM object is first obtained.
377
+
378
+ Args:
379
+ app: The ``SldWorks.Application`` COM object returned by
380
+ :meth:`acquire_solidworks_application`.
381
+
382
+ Raises:
383
+ SolidWorksMCPError: If the server does not respond within
384
+ 10 × 0.5 s = 5 seconds.
385
+ """
386
+ if not hasattr(app, "RevisionNumber"):
387
+ return
388
+
389
+ for _ in range(10):
390
+ revision = self._adapter._attempt(
391
+ lambda: self._adapter._get_attr_or_call(app, "RevisionNumber"),
392
+ default=None,
393
+ )
394
+ if revision is not None:
395
+ return
396
+ await asyncio.sleep(0.5)
397
+
398
+ raise SolidWorksMCPError(
399
+ "SolidWorks COM server did not become ready in time. "
400
+ "Confirm SolidWorks is fully launched and dismiss any startup dialogs."
401
+ )
402
+
403
+ @staticmethod
404
+ def set_automation_preferences(app: Any, *, interactive: bool) -> None:
405
+ """Toggle the SolidWorks warning and question dialog preferences.
406
+
407
+ Sets user preference toggles 150 and 149 to suppress (or restore)
408
+ popup dialogs during automated workflows. Also manages the
409
+ sketch-dimension input toggles so sketch dimensions do not require the
410
+ interactive Modify confirmation while automation is active.
411
+
412
+ Verified against the SolidWorks 2026 constants type library, the
413
+ relevant sketch-dimension toggles are:
414
+
415
+ * ``swInputDimValOnCreate`` = 10
416
+ * ``swSketchAcceptNumericInput`` = 372
417
+ * ``swSketchCreateDimensionOnlyWhenEntered`` = 520
418
+ * ``swScaleSketchOnFirstDimension`` = 642
419
+
420
+ These stay disabled for the full automation session instead of being
421
+ toggled around individual dimension calls, because once SolidWorks
422
+ enters the modal approval flow, extra COM cleanup chatter tends to make
423
+ recovery less reliable.
424
+
425
+ Should be called with ``interactive=False`` immediately after
426
+ connecting and ``interactive=True`` before disconnecting.
427
+
428
+ Args:
429
+ app: The live ``SldWorks.Application`` COM object.
430
+ interactive: When ``False``, dialogs are suppressed for
431
+ unattended operation. When ``True``, normal interactive
432
+ behaviour is restored.
433
+ """
434
+ app.SetUserPreferenceToggle(150, interactive)
435
+ app.SetUserPreferenceToggle(149, interactive)
436
+ if not interactive:
437
+ app.SetUserPreferenceToggle(10, False)
438
+ app.SetUserPreferenceToggle(372, False)
439
+ app.SetUserPreferenceToggle(520, False)
440
+ app.SetUserPreferenceToggle(642, False)
441
+
442
+ async def connect(self) -> None:
443
+ """Orchestrate the full SolidWorks connection sequence.
444
+
445
+ Performs in order:
446
+
447
+ 1. Initialise the COM apartment.
448
+ 2. Acquire the ``SldWorks.Application`` COM object (with retries).
449
+ 3. Wait for the server to become ready.
450
+ 4. Show the application window (hidden instead when
451
+ ``SOLIDWORKS_MCP_HEADLESS`` is set).
452
+ 5. Suppress interactive dialogs for automation.
453
+
454
+ On any failure the adapter state is cleaned up (``swApp`` set to
455
+ ``None``, COM apartment uninitialised) before re-raising.
456
+
457
+ Raises:
458
+ SolidWorksMCPError: Wraps any underlying COM or timeout error.
459
+ """
460
+ try:
461
+ self.initialize_com_apartment()
462
+ app = await self.acquire_solidworks_application()
463
+ self._adapter._attempt(
464
+ lambda: sw_type_info.flag_methods(app, "ISldWorks"), default=0
465
+ )
466
+ await self.wait_for_server_ready(app)
467
+ app.Visible = not _headless_requested()
468
+ self.set_automation_preferences(app, interactive=False)
469
+ except Exception as exc:
470
+ self._adapter.swApp = None
471
+ self.uninitialize_com_apartment()
472
+ raise SolidWorksMCPError(f"Failed to connect to SolidWorks: {exc}") from exc
473
+
474
+ async def disconnect(self) -> None:
475
+ """Clear session state and release the COM apartment.
476
+
477
+ Resets all adapter model/sketch references to ``None`` and always
478
+ uninitialises the COM apartment via ``finally`` so the apartment is
479
+ freed even if the SolidWorks process is already unstable.
480
+
481
+ Disconnect deliberately avoids additional UI preference COM calls.
482
+ Real SolidWorks sessions can hit RPC teardown failures when toggles are
483
+ restored during shutdown, so automation-safe preferences are applied on
484
+ connect and left untouched during disconnect.
485
+
486
+ Side Effects:
487
+ Sets ``adapter.currentModel``, ``adapter.currentSketch``,
488
+ ``adapter.currentSketchManager``, and ``adapter.swApp`` to
489
+ ``None``. Clears the sketch entity registry. Calls
490
+ ``CoUninitialize``.
491
+ """
492
+ try:
493
+ self._adapter.currentModel = None
494
+ self._adapter.currentSketch = None
495
+ self._adapter.currentSketchManager = None
496
+ self._adapter._reset_sketch_entity_registry()
497
+ self._adapter.swApp = None
498
+ finally:
499
+ self.uninitialize_com_apartment()
500
+
501
+
502
+ class _SketchGeometryService:
503
+ """Manage transient sketch-entity storage and provide geometry helpers.
504
+
505
+ Maintains a per-sketch registry that maps stable string IDs (e.g.
506
+ ``"Line_1"``, ``"Circle_3"``) to live SolidWorks COM entity objects. The
507
+ registry is reset each time a new sketch is opened or closed.
508
+
509
+ Also provides geometry primitives used by the sketch-dimension pipeline:
510
+ coordinate extraction, segment endpoint reading, shared-vertex detection,
511
+ and smart-dimension placement-point calculation.
512
+
513
+ Attributes:
514
+ _adapter: Back-reference to the owning ``PyWin32Adapter`` instance.
515
+ """
516
+
517
+ def __init__(self, adapter: "PyWin32Adapter") -> None:
518
+ """Store a back-reference to the owning adapter.
519
+
520
+ Args:
521
+ adapter: The ``PyWin32Adapter`` instance that created this
522
+ service. Provides access to ``_sketch_entities`` and
523
+ ``_sketch_entity_counter``.
524
+ """
525
+ self._adapter = adapter
526
+
527
+ def reset_registry(self) -> None:
528
+ """Clear the sketch entity registry and reset the ID counter.
529
+
530
+ Should be called at the start of every new sketch (via
531
+ ``adapter._reset_sketch_entity_registry``) so that entity IDs from a
532
+ previous sketch do not bleed into the current session.
533
+
534
+ Side Effects:
535
+ Clears ``adapter._sketch_entities`` and sets
536
+ ``adapter._sketch_entity_counter`` to ``0``.
537
+ """
538
+ self._adapter._sketch_entities.clear()
539
+ self._adapter._sketch_entity_centers.clear()
540
+ self._adapter._sketch_entity_counter = 0
541
+
542
+ def register_entity(self, prefix: str, entity: Any) -> str:
543
+ """Store a COM entity handle and return a stable string identifier.
544
+
545
+ Increments the counter, derives an ID of the form
546
+ ``"<prefix>_<counter>"`` (e.g. ``"Line_3"``), stores the entity in
547
+ ``adapter._sketch_entities``, and returns the ID so callers can
548
+ reference the entity in later dimension or constraint calls.
549
+
550
+ Args:
551
+ prefix: Descriptor for the entity type, e.g. ``"Line"``,
552
+ ``"Circle"``, ``"Arc"``, ``"Rectangle"``.
553
+ entity: Live SolidWorks COM entity object returned by the
554
+ ``SketchManager`` create call.
555
+
556
+ Returns:
557
+ str: Stable entity ID, e.g. ``"Circle_2"``.
558
+
559
+ Example::
560
+
561
+ line_id = service.register_entity("Line", sw_line_obj)
562
+ # line_id == "Line_1"
563
+ """
564
+ self._adapter._sketch_entity_counter += 1
565
+ entity_id = f"{prefix}_{self._adapter._sketch_entity_counter}"
566
+ self._adapter._sketch_entities[entity_id] = entity
567
+ return entity_id
568
+
569
+ def select_entity(self, entity: Any, append: bool) -> bool:
570
+ """Select a COM entity in the SolidWorks selection manager.
571
+
572
+ Tries three COM selection methods in decreasing API version order:
573
+ ``Select4`` (modern), ``Select2`` (legacy), ``Select`` (oldest). The
574
+ first call that returns a truthy value terminates the search.
575
+
576
+ Args:
577
+ entity: Live SolidWorks COM sketch entity object (line, arc,
578
+ circle, etc.).
579
+ append: When ``True``, the entity is added to the existing
580
+ selection set. When ``False``, existing selections are
581
+ cleared first.
582
+
583
+ Returns:
584
+ bool: ``True`` if any of the three select methods succeeded;
585
+ ``False`` if all failed or raised.
586
+ """
587
+ selected = self._adapter._attempt(
588
+ lambda: bool(entity.Select4(append, None)),
589
+ default=False,
590
+ )
591
+ if selected:
592
+ return True
593
+
594
+ selected = self._adapter._attempt(
595
+ lambda: bool(entity.Select2(append, 0)),
596
+ default=False,
597
+ )
598
+ if selected:
599
+ return True
600
+
601
+ return bool(
602
+ self._adapter._attempt(
603
+ lambda: bool(entity.Select(append)),
604
+ default=False,
605
+ )
606
+ )
607
+
608
+ def set_display_dimension_value(self, display_dim: Any, value_mm: float) -> None:
609
+ """Set the numeric value of a display-dimension object.
610
+
611
+ Converts the value from millimetres to metres and tries three COM
612
+ paths in order:
613
+
614
+ 1. ``GetDimension2(0).SetSystemValue3(value_m, 1, None)`` — modern.
615
+ 2. ``GetDimension().SetSystemValue2(value_m, 1)`` — legacy.
616
+ 3. Direct ``SystemValue`` property assignment — oldest.
617
+
618
+ Args:
619
+ display_dim: SolidWorks display-dimension COM object returned by
620
+ ``Extension.AddDimension``.
621
+ value_mm: Desired dimension value in **millimetres**. Converted
622
+ to metres internally.
623
+ """
624
+ value_m = value_mm / 1000.0
625
+ dimension_obj = self._adapter._attempt(
626
+ lambda: display_dim.GetDimension2(0), default=None
627
+ )
628
+ if dimension_obj is None:
629
+ dimension_obj = self._adapter._attempt(
630
+ lambda: display_dim.GetDimension(), default=None
631
+ )
632
+ if dimension_obj is None:
633
+ dimension_obj = display_dim
634
+
635
+ if (
636
+ self._adapter._attempt(
637
+ lambda: dimension_obj.SetSystemValue3(value_m, 1, None), default=None
638
+ )
639
+ is not None
640
+ ):
641
+ return
642
+ if (
643
+ self._adapter._attempt(
644
+ lambda: dimension_obj.SetSystemValue2(value_m, 1), default=None
645
+ )
646
+ is not None
647
+ ):
648
+ return
649
+
650
+ if hasattr(dimension_obj, "SystemValue"):
651
+ dimension_obj.SystemValue = value_m
652
+
653
+ def point_xyz(self, point_obj: Any) -> tuple[float, float, float] | None:
654
+ """Extract the XYZ coordinates from a SolidWorks point-like COM object.
655
+
656
+ Tries two access patterns:
657
+
658
+ 1. Direct attribute access — ``point_obj.X``, ``point_obj.Y``,
659
+ ``point_obj.Z`` (``IMathPoint``, ``IVertex``).
660
+ 2. Method call — ``point_obj.GetCoords()`` returning a sequence of
661
+ at least three numbers.
662
+
663
+ All values are in **metres** (SolidWorks internal units).
664
+
665
+ Args:
666
+ point_obj: Any COM object that might expose XYZ coordinates.
667
+ ``None`` is accepted and returns ``None`` immediately.
668
+
669
+ Returns:
670
+ tuple[float, float, float] | None: ``(x, y, z)`` in metres, or
671
+ ``None`` when no coordinate pattern matches.
672
+ """
673
+ if point_obj is None:
674
+ return None
675
+
676
+ if (
677
+ hasattr(point_obj, "X")
678
+ and hasattr(point_obj, "Y")
679
+ and hasattr(point_obj, "Z")
680
+ ):
681
+ x = self._adapter._attempt(lambda: float(point_obj.X), default=None)
682
+ y = self._adapter._attempt(lambda: float(point_obj.Y), default=None)
683
+ z = self._adapter._attempt(lambda: float(point_obj.Z), default=None)
684
+ if x is not None and y is not None and z is not None:
685
+ return (x, y, z)
686
+
687
+ coords = self._adapter._attempt(lambda: point_obj.GetCoords(), default=None)
688
+ if isinstance(coords, (list, tuple)) and len(coords) >= 3:
689
+ return (float(coords[0]), float(coords[1]), float(coords[2]))
690
+
691
+ return None
692
+
693
+ def set_point_xyz(self, point_obj: Any, x: float, y: float, z: float) -> bool:
694
+ """Set coordinates on a SolidWorks point-like COM object.
695
+
696
+ Tries ``SetCoords(x, y, z)`` first (modern API), then
697
+ ``SetCoords2(x, y, z)`` (older variant). All values should be in
698
+ **metres**.
699
+
700
+ Args:
701
+ point_obj: COM point object to update, or ``None``.
702
+ x: New X coordinate in metres.
703
+ y: New Y coordinate in metres.
704
+ z: New Z coordinate in metres.
705
+
706
+ Returns:
707
+ bool: ``True`` when a setter call succeeded; ``False`` otherwise
708
+ (including when ``point_obj`` is ``None``).
709
+ """
710
+ if point_obj is None:
711
+ return False
712
+ if (
713
+ self._adapter._attempt(lambda: point_obj.SetCoords(x, y, z), default=None)
714
+ is not None
715
+ ):
716
+ return True
717
+ if (
718
+ self._adapter._attempt(lambda: point_obj.SetCoords2(x, y, z), default=None)
719
+ is not None
720
+ ):
721
+ return True
722
+ return False
723
+
724
+ def read_segment_endpoints(
725
+ self, entity: Any
726
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None:
727
+ """Read the start and end coordinates of a sketch line or arc segment.
728
+
729
+ Accesses ``entity.GetStartPoint`` and ``entity.GetEndPoint`` (as
730
+ properties, not method calls). Both must return sequences of at least
731
+ three numeric values.
732
+
733
+ Args:
734
+ entity: SolidWorks ``ISketchLine`` or ``ISketchArc`` COM object.
735
+
736
+ Returns:
737
+ tuple[tuple, tuple] | None: ``((x1, y1, z1), (x2, y2, z2))`` in
738
+ metres, or ``None`` when the attributes are absent or empty.
739
+ """
740
+ start = self._adapter._attempt(lambda: entity.GetStartPoint, default=None)
741
+ end = self._adapter._attempt(lambda: entity.GetEndPoint, default=None)
742
+ if (
743
+ isinstance(start, tuple)
744
+ and len(start) >= 3
745
+ and isinstance(end, tuple)
746
+ and len(end) >= 3
747
+ ):
748
+ return (
749
+ (float(start[0]), float(start[1]), float(start[2])),
750
+ (float(end[0]), float(end[1]), float(end[2])),
751
+ )
752
+ return None
753
+
754
+ def segment_point_objects(self, entity: Any) -> tuple[Any | None, Any | None]:
755
+ """Return the COM point objects at the start and end of a segment.
756
+
757
+ Unlike :meth:`read_segment_endpoints`, this returns the raw COM
758
+ objects (``ISketchPoint``) rather than coordinate tuples, so they can
759
+ be used in selection calls for angular dimensioning.
760
+
761
+ Args:
762
+ entity: SolidWorks sketch segment COM object.
763
+
764
+ Returns:
765
+ tuple[Any | None, Any | None]: ``(start_point_obj, end_point_obj)``;
766
+ either element may be ``None`` if the attribute is absent.
767
+ """
768
+ start = self._adapter._attempt(lambda: entity.GetStartPoint2, default=None)
769
+ end = self._adapter._attempt(lambda: entity.GetEndPoint2, default=None)
770
+ return (start, end)
771
+
772
+ def shared_segment_vertex(
773
+ self, entity1: Any, entity2: Any
774
+ ) -> tuple[Any, Any, Any] | None:
775
+ """Find a vertex point shared by two sketch segments.
776
+
777
+ Compares all four endpoint-object pairs (2 from each segment) using a
778
+ coordinate tolerance of 1e-6 m. Used by the angular-dimension
779
+ pipeline to locate the pivot vertex between two lines.
780
+
781
+ Args:
782
+ entity1: First SolidWorks sketch segment COM object.
783
+ entity2: Second SolidWorks sketch segment COM object.
784
+
785
+ Returns:
786
+ tuple[Any, Any, Any] | None: ``(shared_point_obj, point1_obj,
787
+ point2_obj)`` where ``shared_point_obj`` is the common vertex
788
+ (same object as ``point1_obj`` in current implementation), or
789
+ ``None`` when no shared vertex is found.
790
+ """
791
+ points1 = self.segment_point_objects(entity1)
792
+ points2 = self.segment_point_objects(entity2)
793
+ tol = 1e-6
794
+ for point1 in points1:
795
+ xyz1 = self.point_xyz(point1)
796
+ if xyz1 is None:
797
+ continue
798
+ for point2 in points2:
799
+ xyz2 = self.point_xyz(point2)
800
+ if xyz2 is None:
801
+ continue
802
+ if (
803
+ abs(xyz1[0] - xyz2[0]) <= tol
804
+ and abs(xyz1[1] - xyz2[1]) <= tol
805
+ and abs(xyz1[2] - xyz2[2]) <= tol
806
+ ):
807
+ return (point1, point1, point2)
808
+ return None
809
+
810
+ def smart_dimension_direction(self, dx: float, dy: float) -> int:
811
+ """Map a 2-D direction vector to a SolidWorks dimension-direction constant.
812
+
813
+ The dominant axis determines horizontal vs vertical; within each axis
814
+ the sign selects left/right or up/down.
815
+
816
+ Args:
817
+ dx: Horizontal component of the dimension normal vector.
818
+ dy: Vertical component of the dimension normal vector.
819
+
820
+ Returns:
821
+ int: One of the four ``swSmartDimensionDirection*`` constants
822
+ from ``adapter.constants``:
823
+ ``swSmartDimensionDirectionRight``,
824
+ ``swSmartDimensionDirectionLeft``,
825
+ ``swSmartDimensionDirectionUp``, or
826
+ ``swSmartDimensionDirectionDown``.
827
+ """
828
+ if abs(dx) >= abs(dy):
829
+ return (
830
+ self._adapter.constants["swSmartDimensionDirectionRight"]
831
+ if dx >= 0.0
832
+ else self._adapter.constants["swSmartDimensionDirectionLeft"]
833
+ )
834
+ return (
835
+ self._adapter.constants["swSmartDimensionDirectionUp"]
836
+ if dy >= 0.0
837
+ else self._adapter.constants["swSmartDimensionDirectionDown"]
838
+ )
839
+
840
+ def single_line_dimension_placement(
841
+ self, entity: Any
842
+ ) -> tuple[float, float, float, int] | None:
843
+ """Compute the text-placement point and direction for a linear dimension.
844
+
845
+ Reads the segment midpoint, computes the outward normal (perpendicular
846
+ to the segment direction), offsets the placement point by 35 % of the
847
+ segment length (clamped to 10–20 mm), and converts the normal to a
848
+ SolidWorks dimension-direction constant.
849
+
850
+ Args:
851
+ entity: SolidWorks ``ISketchLine`` or ``ISketchArc`` COM object.
852
+
853
+ Returns:
854
+ tuple[float, float, float, int] | None:
855
+ ``(text_x, text_y, text_z, direction)`` in metres, or ``None``
856
+ when endpoint data is unavailable.
857
+
858
+ Example::
859
+
860
+ placement = service.single_line_dimension_placement(line_obj)
861
+ if placement:
862
+ text_x, text_y, text_z, direction = placement
863
+ """
864
+ endpoints = self.read_segment_endpoints(entity)
865
+ if endpoints is None:
866
+ return None
867
+
868
+ (x1, y1, z1), (x2, y2, z2) = endpoints
869
+ dx = x2 - x1
870
+ dy = y2 - y1
871
+ length = (dx * dx + dy * dy) ** 0.5
872
+ if length <= 1e-9:
873
+ return None
874
+
875
+ mid_x = (x1 + x2) / 2.0
876
+ mid_y = (y1 + y2) / 2.0
877
+ mid_z = (z1 + z2) / 2.0
878
+
879
+ normal_x = -dy / length
880
+ normal_y = dx / length
881
+ offset = max(0.01, min(0.02, length * 0.35))
882
+ text_x = mid_x + normal_x * offset
883
+ text_y = mid_y + normal_y * offset
884
+ direction = self.smart_dimension_direction(normal_x, normal_y)
885
+ return (text_x, text_y, mid_z, direction)
886
+
887
+ def angular_dimension_placement(
888
+ self, entity1: Any, entity2: Any
889
+ ) -> tuple[float, float, float, int] | None:
890
+ """Compute the text-placement point for an angular dimension between two lines.
891
+
892
+ Detects the shared vertex, derives the two ray directions, bisects
893
+ the angle, offsets the placement point by 15–30 mm along the bisector
894
+ (clamped to 60 % of the shorter leg), and converts the bisector to a
895
+ dimension-direction constant.
896
+
897
+ Args:
898
+ entity1: First SolidWorks sketch-line COM object.
899
+ entity2: Second SolidWorks sketch-line COM object (must share a
900
+ vertex with ``entity1``).
901
+
902
+ Returns:
903
+ tuple[float, float, float, int] | None:
904
+ ``(text_x, text_y, text_z, direction)`` in metres, or ``None``
905
+ when no shared vertex is found or segment data is unavailable.
906
+ """
907
+ endpoints1 = self.read_segment_endpoints(entity1)
908
+ endpoints2 = self.read_segment_endpoints(entity2)
909
+ if endpoints1 is None or endpoints2 is None:
910
+ return None
911
+
912
+ pts1 = endpoints1
913
+ pts2 = endpoints2
914
+ tol = 1e-6
915
+ vertex = None
916
+ ray1 = None
917
+ ray2 = None
918
+ for p1 in pts1:
919
+ for p2 in pts2:
920
+ if (
921
+ abs(p1[0] - p2[0]) <= tol
922
+ and abs(p1[1] - p2[1]) <= tol
923
+ and abs(p1[2] - p2[2]) <= tol
924
+ ):
925
+ vertex = p1
926
+ ray1 = pts1[1] if pts1[0] == p1 else pts1[0]
927
+ ray2 = pts2[1] if pts2[0] == p2 else pts2[0]
928
+ break
929
+ if vertex is not None:
930
+ break
931
+
932
+ if vertex is None or ray1 is None or ray2 is None:
933
+ return None
934
+
935
+ v1x = ray1[0] - vertex[0]
936
+ v1y = ray1[1] - vertex[1]
937
+ v2x = ray2[0] - vertex[0]
938
+ v2y = ray2[1] - vertex[1]
939
+ l1 = (v1x * v1x + v1y * v1y) ** 0.5
940
+ l2 = (v2x * v2x + v2y * v2y) ** 0.5
941
+ if l1 <= 1e-9 or l2 <= 1e-9:
942
+ return None
943
+
944
+ b1x = v1x / l1
945
+ b1y = v1y / l1
946
+ b2x = v2x / l2
947
+ b2y = v2y / l2
948
+ bis_x = b1x + b2x
949
+ bis_y = b1y + b2y
950
+ if abs(bis_x) <= 1e-9 and abs(bis_y) <= 1e-9:
951
+ bis_x = -b1y
952
+ bis_y = b1x
953
+
954
+ bis_len = (bis_x * bis_x + bis_y * bis_y) ** 0.5
955
+ bis_x /= bis_len
956
+ bis_y /= bis_len
957
+ offset = max(0.015, min(0.03, min(l1, l2) * 0.6))
958
+ text_x = vertex[0] + bis_x * offset
959
+ text_y = vertex[1] + bis_y * offset
960
+ direction = self.smart_dimension_direction(bis_x, bis_y)
961
+ return (text_x, text_y, vertex[2], direction)
962
+
963
+
964
+ class _DocumentRoutingService:
965
+ """Resolve document identity and select the export-target document.
966
+
967
+ Provides two helpers that isolate the policy for identifying which COM
968
+ document object to use during export operations. The service prefers the
969
+ SolidWorks ``ActiveDoc`` when it matches the adapter\'s current model, and
970
+ falls back gracefully when path or title data is unavailable.
971
+
972
+ Attributes:
973
+ _adapter: Back-reference to the owning ``PyWin32Adapter`` instance.
974
+ """
975
+
976
+ def __init__(self, adapter: "PyWin32Adapter") -> None:
977
+ """Store a back-reference to the owning adapter.
978
+
979
+ Args:
980
+ adapter: The ``PyWin32Adapter`` instance that created this
981
+ service.
982
+ """
983
+ self._adapter = adapter
984
+
985
+ def document_identity(self, document: Any) -> tuple[str | None, str | None]:
986
+ """Return the normalised absolute path and display title for a COM document.
987
+
988
+ Tries ``document.GetPathName()`` first; falls back to
989
+ ``getattr(document, 'GetPathName', None)`` for COM objects that expose
990
+ the path as a property rather than a method. Applies the same
991
+ two-step pattern for ``GetTitle``.
992
+
993
+ Args:
994
+ document: A SolidWorks ``IModelDoc2`` COM object, or ``None``.
995
+
996
+ Returns:
997
+ tuple[str | None, str | None]: ``(absolute_path, title)`` where
998
+ ``absolute_path`` is ``os.path.abspath`` normalised and
999
+ ``title`` is the raw display title. Either element is ``None``
1000
+ when the corresponding attribute is absent or empty.
1001
+ """
1002
+ if document is None:
1003
+ return None, None
1004
+
1005
+ raw_path = self._adapter._attempt(lambda: document.GetPathName(), default=None)
1006
+ if raw_path is None:
1007
+ raw_path = self._adapter._attempt(
1008
+ lambda: getattr(document, "GetPathName", None), default=None
1009
+ )
1010
+ path_value = str(raw_path).strip() if raw_path else ""
1011
+ normalized_path = os.path.abspath(path_value) if path_value else None
1012
+
1013
+ raw_title = self._adapter._attempt(lambda: document.GetTitle(), default=None)
1014
+ if raw_title is None:
1015
+ raw_title = self._adapter._attempt(
1016
+ lambda: getattr(document, "GetTitle", None), default=None
1017
+ )
1018
+ title_value = str(raw_title).strip() if raw_title else ""
1019
+
1020
+ return normalized_path, title_value or None
1021
+
1022
+ def resolve_export_target_doc(self) -> Any:
1023
+ """Resolve the safest document to use for export operations.
1024
+
1025
+ Policy (in order):
1026
+
1027
+ 1. If ``adapter.currentModel`` is ``None``, use ``swApp.ActiveDoc``.
1028
+ 2. If ``swApp.ActiveDoc`` is ``None``, use ``adapter.currentModel``.
1029
+ 3. If both have path strings and the paths match, prefer
1030
+ ``swApp.ActiveDoc`` (it may have a more up-to-date state).
1031
+ 4. If both have title strings that match, prefer ``swApp.ActiveDoc``.
1032
+ 5. Otherwise fall back to ``adapter.currentModel``.
1033
+
1034
+ Returns:
1035
+ Any: The SolidWorks ``IModelDoc2`` COM object to use for export,
1036
+ or ``None`` when neither ``swApp`` nor ``currentModel`` is set.
1037
+ """
1038
+ active_doc = (
1039
+ getattr(self._adapter.swApp, "ActiveDoc", None)
1040
+ if self._adapter.swApp
1041
+ else None
1042
+ )
1043
+ if self._adapter.currentModel is None:
1044
+ return active_doc
1045
+ if active_doc is None:
1046
+ return self._adapter.currentModel
1047
+
1048
+ current_path, current_title = self.document_identity(self._adapter.currentModel)
1049
+ active_path, active_title = self.document_identity(active_doc)
1050
+
1051
+ if current_path and active_path:
1052
+ return (
1053
+ active_doc
1054
+ if current_path == active_path
1055
+ else self._adapter.currentModel
1056
+ )
1057
+ if current_title and active_title and current_title == active_title:
1058
+ return active_doc
1059
+ return self._adapter.currentModel
1060
+
1061
+
1062
+ #: Members the ``list_features`` tree walk actually reads on each feature
1063
+ #: dispatch. Flagging just these instead of the whole ``IFeature`` interface
1064
+ #: (~100 names, ~27 ms per object) is what keeps the walk cheap: the flag
1065
+ #: cache in ``sw_type_info`` is keyed by ``id(obj)``, and every feature here
1066
+ #: is a freshly returned dispatch, so a full-interface flag never hits that
1067
+ #: cache and pays its full cost on every single feature.
1068
+ _WALK_MEMBERS = ("GetNextFeature", "GetTypeName2", "IsSuppressed", "IsSuppressed2")
1069
+
1070
+
1071
+ class _FeatureSelectionService:
1072
+ """Encapsulate feature-selection strategies and name-candidate expansion.
1073
+
1074
+ SolidWorks allows features to be selected by bare name or by the
1075
+ ``<name>@<document>`` qualified syntax. Several selection APIs also
1076
+ exist with different levels of support across SolidWorks versions.
1077
+ This service centralises all of that complexity so the main adapter
1078
+ methods stay thin.
1079
+
1080
+ Attributes:
1081
+ _adapter: Back-reference to the owning ``PyWin32Adapter`` instance.
1082
+ """
1083
+
1084
+ def __init__(self, adapter: "PyWin32Adapter") -> None:
1085
+ """Store a back-reference to the owning adapter.
1086
+
1087
+ Args:
1088
+ adapter: The ``PyWin32Adapter`` instance that created this
1089
+ service.
1090
+ """
1091
+ self._adapter = adapter
1092
+
1093
+ @staticmethod
1094
+ def normalize_feature_name(raw_name: str | None) -> str:
1095
+ """Normalise a feature name for case-insensitive comparison.
1096
+
1097
+ Strips surrounding whitespace and quotes then converts to
1098
+ ``casefold``-lowercase so that mixed-case names such as
1099
+ ``'Boss-Extrude1'``, ``" boss-extrude1 "`` and ``'"Boss-Extrude1"'``
1100
+ all compare equal.
1101
+
1102
+ Args:
1103
+ raw_name: Raw feature name string, or ``None``.
1104
+
1105
+ Returns:
1106
+ str: Normalised name. Empty string when ``raw_name`` is
1107
+ ``None`` or blank.
1108
+
1109
+ Example::
1110
+
1111
+ svc.normalize_feature_name('"Boss-Extrude1"') == 'boss-extrude1'
1112
+ """
1113
+ return str(raw_name or "").strip().strip('"').casefold()
1114
+
1115
+ def build_feature_candidate_names(
1116
+ self, feature_name: str, target_doc: Any
1117
+ ) -> list[str]:
1118
+ """Build a list of bare and document-qualified selection candidates.
1119
+
1120
+ SolidWorks requires the ``"<name>@<document>"`` syntax for
1121
+ ``SelectByID2`` on assembly contexts. This method tries to read the
1122
+ document title and produces both bare and qualified variants so that
1123
+ callers do not need to duplicate that logic.
1124
+
1125
+ Args:
1126
+ feature_name: The bare feature name as supplied by the caller
1127
+ (e.g. ``"Boss-Extrude1"``).
1128
+ target_doc: SolidWorks ``IModelDoc2`` COM object whose title is
1129
+ used to form the qualified name. Errors reading the title
1130
+ are silently swallowed.
1131
+
1132
+ Returns:
1133
+ list[str]: Candidate list, always including ``feature_name`` as
1134
+ the first element. Qualified variants are appended when a non-
1135
+ empty title is available. Example::
1136
+
1137
+ ["Boss-Extrude1", "Boss-Extrude1@Part1", "Boss-Extrude1@Part1.SLDPRT"]
1138
+ """
1139
+ doc_title = ""
1140
+ doc_stem = ""
1141
+ try:
1142
+ raw_title = str(target_doc.GetTitle() or "").strip()
1143
+ if raw_title:
1144
+ doc_title = raw_title
1145
+ doc_stem = raw_title.rsplit(".", 1)[0]
1146
+ except Exception:
1147
+ pass
1148
+
1149
+ candidates: list[str] = [feature_name]
1150
+ if doc_stem:
1151
+ candidates.append(f"{feature_name}@{doc_stem}")
1152
+ if doc_title and doc_title != doc_stem:
1153
+ candidates.append(f"{feature_name}@{doc_title}")
1154
+ return candidates
1155
+
1156
+ def try_select_by_feature_name(
1157
+ self,
1158
+ target_doc: Any,
1159
+ feature_name: str,
1160
+ append: bool = False,
1161
+ ) -> dict[str, Any] | None:
1162
+ """Select via ``FeatureByName`` + ``IFeature::Select2`` (first strategy).
1163
+
1164
+ Delegates to the shared ``features._select_feature_by_name`` helper -
1165
+ the path suppress/rename already rely on, and the one confirmed live
1166
+ on R2026x for solid features, sketches and reference planes. It does
1167
+ not resolve assembly components, which fall through to
1168
+ :meth:`try_select_by_component`.
1169
+
1170
+ Args:
1171
+ target_doc: SolidWorks ``IModelDoc2`` COM object (the adapter's
1172
+ ``currentModel``; the helper reads it from the adapter).
1173
+ feature_name: Feature name; any ``@document`` qualifier is
1174
+ stripped by the helper.
1175
+ append: Add to the current selection. Defaults to False.
1176
+
1177
+ Returns:
1178
+ dict[str, Any] | None: Success payload (same schema as
1179
+ :meth:`try_select_by_extension`) with ``entity_type`` set to
1180
+ ``"feature-name"``, or ``None`` when it could not be selected.
1181
+ """
1182
+ if "@" in feature_name:
1183
+ # FeatureByName only sees this document's own tree. A name
1184
+ # qualified with anything but this document (e.g. a component
1185
+ # path "Front Plane@Part1-1@Assem1") must reach the qualified
1186
+ # SelectByID2 strategies, or the assembly's own "Front Plane"
1187
+ # would be selected instead.
1188
+ bare = feature_name.split("@", 1)[0]
1189
+ own_names = self.build_feature_candidate_names(bare, target_doc)
1190
+ if feature_name not in own_names:
1191
+ return None
1192
+ feature = _select_feature_by_name(self._adapter, feature_name, append)
1193
+ if feature is None:
1194
+ return None
1195
+ selected_name = self._adapter._attempt(
1196
+ lambda: str(feature.Name or ""), default=""
1197
+ )
1198
+ return {
1199
+ "selected": True,
1200
+ "feature_name": feature_name,
1201
+ "selected_name": selected_name or feature_name.split("@", 1)[0],
1202
+ "entity_type": "feature-name",
1203
+ }
1204
+
1205
+ def try_select_by_extension(
1206
+ self,
1207
+ target_doc: Any,
1208
+ candidate_names: list[str],
1209
+ feature_name: str,
1210
+ append: bool = False,
1211
+ ) -> dict[str, Any] | None:
1212
+ """Attempt feature selection via ``Extension.SelectByID2``.
1213
+
1214
+ Iterates the Cartesian product of ``candidate_names`` × entity-type
1215
+ strings. Entity types tried (in order): ``"BODYFEATURE"``,
1216
+ ``"COMPONENT"``, ``"SKETCH"``, ``"PLANE"``, ``"MATE"``, ``""`` (auto).
1217
+ Returns on the first successful selection.
1218
+
1219
+ Args:
1220
+ target_doc: SolidWorks ``IModelDoc2`` COM object.
1221
+ candidate_names: Ordered list of name strings to try (bare and
1222
+ qualified variants from :meth:`build_feature_candidate_names`).
1223
+ feature_name: The original caller-supplied name, included in the
1224
+ result payload for traceability.
1225
+ append: Add to the current selection. Defaults to False.
1226
+
1227
+ Returns:
1228
+ dict[str, Any] | None: On success, a result dict with keys
1229
+ ``selected``, ``feature_name``, ``selected_name``,
1230
+ ``entity_type``. ``None`` when all attempts fail.
1231
+ """
1232
+ entity_types = ["BODYFEATURE", "COMPONENT", "SKETCH", "PLANE", "MATE", ""]
1233
+ for candidate in candidate_names:
1234
+ for entity_type in entity_types:
1235
+ try:
1236
+ # Callout must be a VT_DISPATCH null; Python None is a
1237
+ # Type mismatch (runbook #11, confirmed live 2026-10-13).
1238
+ selected = target_doc.Extension.SelectByID2(
1239
+ candidate,
1240
+ entity_type,
1241
+ 0,
1242
+ 0,
1243
+ 0,
1244
+ append,
1245
+ 0,
1246
+ _null_callout(),
1247
+ 0,
1248
+ )
1249
+ if selected:
1250
+ return {
1251
+ "selected": True,
1252
+ "feature_name": feature_name,
1253
+ "selected_name": candidate,
1254
+ "entity_type": entity_type or "auto",
1255
+ }
1256
+ except Exception:
1257
+ continue
1258
+ return None
1259
+
1260
+ def try_select_by_component(
1261
+ self,
1262
+ target_doc: Any,
1263
+ candidate_names: list[str],
1264
+ feature_name: str,
1265
+ append: bool = False,
1266
+ ) -> dict[str, Any] | None:
1267
+ """Attempt component selection for assembly-context features.
1268
+
1269
+ Calls ``target_doc.GetComponentByName`` (when available) and then
1270
+ tries three selector methods on the resulting component object:
1271
+ ``Select4(False, None, False)``, ``Select(False,)``, and
1272
+ ``Select2(False, 0)``.
1273
+
1274
+ This path handles assembly components that are not reachable through
1275
+ ``SelectByID2`` alone.
1276
+
1277
+ Args:
1278
+ target_doc: SolidWorks ``IAssemblyDoc`` or ``IModelDoc2`` COM
1279
+ object.
1280
+ candidate_names: List of candidate name strings. Only the part
1281
+ before the first ``@`` is used as the component name.
1282
+ feature_name: Original caller-supplied name for the result
1283
+ payload.
1284
+ append: Add to the current selection. Defaults to False.
1285
+
1286
+ Returns:
1287
+ dict[str, Any] | None: Success dict (same schema as
1288
+ :meth:`try_select_by_extension`) or ``None`` when not applicable
1289
+ or all attempts fail.
1290
+ """
1291
+ get_component_by_name = getattr(target_doc, "GetComponentByName", None)
1292
+ if not callable(get_component_by_name):
1293
+ return None
1294
+
1295
+ for candidate in candidate_names:
1296
+ component_name = candidate.split("@", 1)[0]
1297
+ component = self._adapter._attempt(
1298
+ lambda name=component_name: get_component_by_name(name), # type: ignore[misc]
1299
+ default=None,
1300
+ )
1301
+ if component is None:
1302
+ continue
1303
+ for method_name, args in [
1304
+ ("Select4", (append, None, False)),
1305
+ ("Select", (append,)),
1306
+ ("Select2", (append, 0)),
1307
+ ]:
1308
+ selector = getattr(component, method_name, None)
1309
+ if not callable(selector):
1310
+ continue
1311
+ try:
1312
+ if bool(selector(*args)):
1313
+ return {
1314
+ "selected": True,
1315
+ "feature_name": feature_name,
1316
+ "selected_name": component_name,
1317
+ "entity_type": f"component:{method_name}",
1318
+ }
1319
+ except Exception:
1320
+ continue
1321
+ return None
1322
+
1323
+ def try_select_by_feature_tree(
1324
+ self,
1325
+ target_doc: Any,
1326
+ feature_name: str,
1327
+ candidate_names: list[str],
1328
+ append: bool = False,
1329
+ ) -> dict[str, Any] | None:
1330
+ """Walk the SolidWorks feature tree and select the first matching feature.
1331
+
1332
+ Iterates via ``FirstFeature`` / ``GetNextFeature`` (up to 10 000
1333
+ features as a runaway guard) and compares each feature\'s ``Name``
1334
+ attribute against the normalised candidate set. Matching is
1335
+ case-insensitive and also strips the ``@document`` qualifier.
1336
+
1337
+ This is the most expensive selection strategy and is only attempted
1338
+ after :meth:`try_select_by_extension` and
1339
+ :meth:`try_select_by_component` have both failed.
1340
+
1341
+ Args:
1342
+ target_doc: SolidWorks ``IModelDoc2`` COM object.
1343
+ feature_name: Original caller-supplied name for the result
1344
+ payload.
1345
+ candidate_names: Candidate names used to build the normalised
1346
+ match set.
1347
+ append: Add to the current selection. Defaults to False.
1348
+
1349
+ Returns:
1350
+ dict[str, Any] | None: Success dict (same schema as
1351
+ :meth:`try_select_by_extension`) with ``entity_type`` set to
1352
+ ``"feature-tree"``, or ``None`` when no match is found.
1353
+ """
1354
+ normalized_candidates = {
1355
+ self.normalize_feature_name(c)
1356
+ for c in candidate_names
1357
+ if self.normalize_feature_name(c)
1358
+ }
1359
+ normalized_bases = {c.split("@", 1)[0] for c in normalized_candidates if c}
1360
+
1361
+ feature = self._adapter._attempt(lambda: target_doc.FirstFeature())
1362
+ guard = 0
1363
+ while feature and guard < 10000:
1364
+ guard += 1
1365
+ feature_ref = feature
1366
+ # Unflagged, GetNextFeature/Select2 resolve as properties and the
1367
+ # walk dies with "Member not found" (confirmed live 2026-10-13).
1368
+ self._adapter._attempt(
1369
+ lambda f=feature_ref: sw_type_info.flag_members( # type: ignore[misc]
1370
+ f, "GetNextFeature", "Select2"
1371
+ ),
1372
+ default=0,
1373
+ )
1374
+ tree_name = self._adapter._attempt(
1375
+ lambda current_feature=feature_ref: str(current_feature.Name or ""), # type: ignore[misc]
1376
+ default="",
1377
+ )
1378
+ if self._matches_candidate_name(
1379
+ tree_name, normalized_candidates, normalized_bases
1380
+ ):
1381
+ try:
1382
+ if feature.Select2(append, 0):
1383
+ return {
1384
+ "selected": True,
1385
+ "feature_name": feature_name,
1386
+ "selected_name": tree_name or feature_name,
1387
+ "entity_type": "feature-tree",
1388
+ }
1389
+ except Exception:
1390
+ pass
1391
+ next_feature = self._adapter._attempt(
1392
+ lambda current_feature=feature_ref: current_feature.GetNextFeature() # type: ignore[misc]
1393
+ )
1394
+ if next_feature is None:
1395
+ break
1396
+ feature = next_feature
1397
+ return None
1398
+
1399
+ @classmethod
1400
+ def _matches_candidate_name(
1401
+ cls,
1402
+ raw_name: str | None,
1403
+ normalized_candidates: set[str],
1404
+ normalized_bases: set[str],
1405
+ ) -> bool:
1406
+ """Check whether a raw feature name matches candidate name sets.
1407
+
1408
+ Args:
1409
+ raw_name: Name read from the feature tree.
1410
+ normalized_candidates: Full normalized candidate names.
1411
+ normalized_bases: Candidate names with any ``@doc`` suffix removed.
1412
+
1413
+ Returns:
1414
+ bool: ``True`` when name matches either full or base candidate sets.
1415
+ """
1416
+ normalized_name = cls.normalize_feature_name(raw_name)
1417
+ if not normalized_name:
1418
+ return False
1419
+ if normalized_name in normalized_candidates:
1420
+ return True
1421
+ return normalized_name.split("@", 1)[0] in normalized_bases
1422
+
1423
+ def select_feature(self, feature_name: str, append: bool = False) -> dict[str, Any]:
1424
+ """Run the feature-selection strategies in order; first success wins.
1425
+
1426
+ Order: ``FeatureByName`` (cheapest, proven live), ``SelectByID2``
1427
+ over candidate names x entity types (planes, mates), component
1428
+ selection (assemblies), then a feature-tree walk.
1429
+
1430
+ Args:
1431
+ feature_name: Feature name requested by caller.
1432
+ append: Add to the current selection. Defaults to False.
1433
+
1434
+ Returns:
1435
+ dict[str, Any]: Selection payload; ``selected`` is False only when
1436
+ every strategy failed (the adapter maps that to an ERROR).
1437
+ """
1438
+ target_doc = self._adapter.currentModel
1439
+
1440
+ result = self.try_select_by_feature_name(target_doc, feature_name, append)
1441
+ if result:
1442
+ return result
1443
+
1444
+ candidate_names = self.build_feature_candidate_names(feature_name, target_doc)
1445
+
1446
+ result = self.try_select_by_extension(
1447
+ target_doc, candidate_names, feature_name, append
1448
+ )
1449
+ if result:
1450
+ return result
1451
+
1452
+ result = self.try_select_by_component(
1453
+ target_doc, candidate_names, feature_name, append
1454
+ )
1455
+ if result:
1456
+ return result
1457
+
1458
+ result = self.try_select_by_feature_tree(
1459
+ target_doc, feature_name, candidate_names, append
1460
+ )
1461
+ if result:
1462
+ return result
1463
+
1464
+ return {
1465
+ "selected": False,
1466
+ "feature_name": feature_name,
1467
+ "selected_name": feature_name,
1468
+ }
1469
+
1470
+ def list_features(
1471
+ self, include_suppressed: bool = False, max_assembly_depth: int = 2
1472
+ ) -> list[dict[str, Any]]:
1473
+ """Enumerate model features, including assembly component trees.
1474
+
1475
+ For a Part document, returns that document's own feature list only
1476
+ (unchanged from prior behavior). For an Assembly document, also
1477
+ flattens in every resolved top-level component's features — and,
1478
+ recursively, sub-assembly components' features up to
1479
+ ``max_assembly_depth`` — tagged with ``component``/``component_path``
1480
+ so callers can tell which document each descriptor came from.
1481
+
1482
+ Args:
1483
+ include_suppressed: Include suppressed features when ``True``.
1484
+ max_assembly_depth: Sub-assembly recursion budget. Ignored for
1485
+ Part documents.
1486
+
1487
+ Returns:
1488
+ list[dict[str, Any]]: Ordered, flattened feature descriptors.
1489
+ """
1490
+ document = self._adapter.currentModel
1491
+ features = self._list_document_features(document, include_suppressed)
1492
+
1493
+ # GetType resolves as a property (not a method) on some COM dispatch
1494
+ # instances - notably a freshly-fetched swApp.ActiveDoc, as opposed
1495
+ # to a document just returned by OpenDoc6/NewDocument. Calling it
1496
+ # with parens unconditionally raises "'int' object is not callable"
1497
+ # on those, silently swallowed by _attempt, which would otherwise
1498
+ # make every Assembly document look like doc_type 0. Route through
1499
+ # _get_attr_or_call so both forms work regardless of flagging state.
1500
+ doc_type = self._adapter._attempt(
1501
+ lambda: int(self._adapter._get_attr_or_call(document, "GetType") or 0),
1502
+ default=0,
1503
+ )
1504
+ if doc_type == self._adapter.constants.get("swDocASSEMBLY", 2):
1505
+ sw_type_info.flag_doc(document, doc_type)
1506
+ features.extend(
1507
+ self._list_assembly_component_features(
1508
+ document, include_suppressed, max_assembly_depth - 1, None
1509
+ )
1510
+ )
1511
+
1512
+ return features
1513
+
1514
+ def _list_document_features(
1515
+ self,
1516
+ document: Any,
1517
+ include_suppressed: bool,
1518
+ component: str | None = None,
1519
+ component_path: str | None = None,
1520
+ component_parent: str | None = None,
1521
+ ) -> list[dict[str, Any]]:
1522
+ """Enumerate one document's own features using primary and fallback
1523
+ traversal paths.
1524
+
1525
+ Args:
1526
+ document: SolidWorks ``IModelDoc2`` COM object to traverse.
1527
+ include_suppressed: Include suppressed features when ``True``.
1528
+ component: Owning component name to tag every descriptor with,
1529
+ or ``None`` for the document's own (non-component) features.
1530
+ component_path: Owning component's resolved document path, or
1531
+ ``None`` to match ``component``.
1532
+ component_parent: Immediate parent component's name (the
1533
+ sub-assembly instance ``component`` is nested inside), or
1534
+ ``None`` when ``component`` is a top-level component (or
1535
+ itself ``None``). Lets callers reconstruct the component
1536
+ tree from the flat result — see ``build_component_tree``.
1537
+
1538
+ Returns:
1539
+ list[dict[str, Any]]: Ordered feature descriptors.
1540
+ """
1541
+ features: list[dict[str, Any]] = []
1542
+ seen: set[tuple[str, str]] = set()
1543
+
1544
+ feature = self._adapter._attempt(lambda: document.FirstFeature())
1545
+ # Flag only the members this walk reads (see ``_WALK_MEMBERS``) so
1546
+ # methods like GetNextFeature work, without paying the cost of
1547
+ # flagging the entire IFeature interface on every fresh dispatch.
1548
+ if feature is not None:
1549
+ self._adapter._attempt(
1550
+ lambda f=feature: sw_type_info.flag_members(f, *_WALK_MEMBERS), # type: ignore[misc]
1551
+ default=0,
1552
+ )
1553
+ pos = 0
1554
+ guard = 0
1555
+ while feature and guard < 10000:
1556
+ self._append_feature_to(
1557
+ features,
1558
+ seen,
1559
+ feature,
1560
+ pos,
1561
+ include_suppressed,
1562
+ component=component,
1563
+ component_path=component_path,
1564
+ component_parent=component_parent,
1565
+ )
1566
+ pos += 1
1567
+ guard += 1
1568
+ next_feature = self._adapter._attempt(
1569
+ lambda current_feature=feature: current_feature.GetNextFeature() # type: ignore[misc]
1570
+ )
1571
+ if next_feature is None:
1572
+ break
1573
+ feature = next_feature
1574
+ # Flag each new feature dispatch
1575
+ if feature is not None:
1576
+ self._adapter._attempt(
1577
+ lambda f=feature: sw_type_info.flag_members(f, *_WALK_MEMBERS), # type: ignore[misc]
1578
+ default=0,
1579
+ )
1580
+
1581
+ # Always augment the forward traversal with FeatureByPositionReverse.
1582
+ # Some SolidWorks COM/type-library combinations stop GetNextFeature at
1583
+ # the system folders even though later sketches and body features
1584
+ # exist. The dedupe set keeps this fallback safe when both traversals
1585
+ # return the same entries.
1586
+ feature_manager = getattr(document, "FeatureManager", None)
1587
+ count = self._adapter._attempt(
1588
+ lambda: int(feature_manager.GetFeatureCount(True) or 0), # type: ignore[union-attr]
1589
+ default=0,
1590
+ )
1591
+ # FeatureByPositionReverse is zero-based. Starting at one silently
1592
+ # skipped the newest feature (typically the just-created extrusion).
1593
+ for reverse_pos in range(0, count or 0):
1594
+ feature = self._adapter._attempt(
1595
+ lambda pos=reverse_pos: document.FeatureByPositionReverse(pos)
1596
+ )
1597
+ if feature is None:
1598
+ continue
1599
+ self._append_feature_to(
1600
+ features,
1601
+ seen,
1602
+ feature,
1603
+ (count or 0) - reverse_pos - 1,
1604
+ include_suppressed,
1605
+ component=component,
1606
+ component_path=component_path,
1607
+ component_parent=component_parent,
1608
+ )
1609
+
1610
+ return features
1611
+
1612
+ def _list_assembly_component_features(
1613
+ self,
1614
+ assembly_doc: Any,
1615
+ include_suppressed: bool,
1616
+ depth_remaining: int,
1617
+ parent_component: str | None,
1618
+ ) -> list[dict[str, Any]]:
1619
+ """Traverse an assembly's top-level components and flatten their
1620
+ features into the same shape ``_list_document_features`` produces.
1621
+
1622
+ A component that resolves to another Assembly is expanded (its own
1623
+ features plus a recursive pass over its components) only while
1624
+ ``depth_remaining > 0``; beyond that it is represented by a single
1625
+ bare descriptor (name + path) instead of being expanded further.
1626
+ Part components are always expanded fully — they have no further
1627
+ nesting, so there is no unbounded-recursion risk from doing so.
1628
+
1629
+ Args:
1630
+ assembly_doc: SolidWorks ``IAssemblyDoc``/``IModelDoc2`` COM
1631
+ object whose top-level components to traverse.
1632
+ include_suppressed: Include suppressed features when ``True``.
1633
+ depth_remaining: Sub-assembly recursion budget remaining.
1634
+ parent_component: Name of the component ``assembly_doc`` itself
1635
+ is nested inside, or ``None`` when ``assembly_doc`` is the
1636
+ top-level document. Tags every descriptor produced here
1637
+ with this as ``component_parent``.
1638
+
1639
+ Returns:
1640
+ list[dict[str, Any]]: Flattened, component-tagged descriptors.
1641
+ """
1642
+ results: list[dict[str, Any]] = []
1643
+ assembly_swdoc_type = self._adapter.constants.get("swDocASSEMBLY", 2)
1644
+
1645
+ components = self._adapter._attempt(
1646
+ lambda: assembly_doc.GetComponents(True), default=None
1647
+ )
1648
+ if not components:
1649
+ return results
1650
+
1651
+ for component in components:
1652
+ component_name = str(
1653
+ self._adapter._attempt(
1654
+ lambda c=component: self._adapter._get_attr_or_call(c, "Name2"),
1655
+ default="",
1656
+ )
1657
+ or ""
1658
+ )
1659
+
1660
+ # IComponent2.GetModelDoc2 raises "Member not found" on an
1661
+ # unflagged component dispatch under late binding - dynamic
1662
+ # dispatch returns a speculative callable for unknown attribute
1663
+ # names that only fails once actually invoked. Flag before
1664
+ # resolving, per the CLAUDE.md convention of flagging every
1665
+ # intermediate dispatch this code acquires.
1666
+ self._adapter._attempt(
1667
+ lambda c=component: sw_type_info.flag_methods(c, "IComponent2"),
1668
+ default=0,
1669
+ )
1670
+ resolved_doc = self._adapter._attempt(
1671
+ lambda c=component: self._adapter._get_attr_or_call(c, "GetModelDoc2"),
1672
+ default=None,
1673
+ )
1674
+ if resolved_doc is None:
1675
+ results.append(
1676
+ {
1677
+ "name": component_name,
1678
+ "type": "UnresolvedComponent",
1679
+ "suppressed": False,
1680
+ "position": -1,
1681
+ "component": component_name,
1682
+ "component_path": None,
1683
+ "component_parent": parent_component,
1684
+ }
1685
+ )
1686
+ continue
1687
+
1688
+ # Same property-vs-method ambiguity as the top-level GetType
1689
+ # check above - route through _get_attr_or_call rather than
1690
+ # calling GetType() directly.
1691
+ resolved_doc_type = self._adapter._attempt(
1692
+ lambda d=resolved_doc: int(
1693
+ self._adapter._get_attr_or_call(d, "GetType") or 0
1694
+ ),
1695
+ default=0,
1696
+ )
1697
+ sw_type_info.flag_doc(resolved_doc, resolved_doc_type)
1698
+ component_path, _title = self._adapter._document_routing.document_identity(
1699
+ resolved_doc
1700
+ )
1701
+
1702
+ if resolved_doc_type == assembly_swdoc_type and depth_remaining <= 0:
1703
+ # Depth limit reached: identify the sub-assembly component
1704
+ # without expanding its own features or its components.
1705
+ results.append(
1706
+ {
1707
+ "name": component_name,
1708
+ "type": "Component",
1709
+ "suppressed": False,
1710
+ "position": -1,
1711
+ "component": component_name,
1712
+ "component_path": component_path,
1713
+ "component_parent": parent_component,
1714
+ }
1715
+ )
1716
+ continue
1717
+
1718
+ results.extend(
1719
+ self._list_document_features(
1720
+ resolved_doc,
1721
+ include_suppressed,
1722
+ component=component_name,
1723
+ component_path=component_path,
1724
+ component_parent=parent_component,
1725
+ )
1726
+ )
1727
+
1728
+ if resolved_doc_type == assembly_swdoc_type:
1729
+ results.extend(
1730
+ self._list_assembly_component_features(
1731
+ resolved_doc,
1732
+ include_suppressed,
1733
+ depth_remaining - 1,
1734
+ component_name,
1735
+ )
1736
+ )
1737
+
1738
+ return results
1739
+
1740
+ def _append_feature_to(
1741
+ self,
1742
+ features: list[dict[str, Any]],
1743
+ seen: set[tuple[str, str]],
1744
+ feature: Any,
1745
+ position: int,
1746
+ include_suppressed: bool,
1747
+ component: str | None = None,
1748
+ component_path: str | None = None,
1749
+ component_parent: str | None = None,
1750
+ ) -> None:
1751
+ """Append one feature descriptor when dedupe and suppression rules allow.
1752
+
1753
+ Args:
1754
+ features: Output list being populated.
1755
+ seen: Dedupe set of ``(name, type)`` keys.
1756
+ feature: Feature COM object.
1757
+ position: Display position index.
1758
+ include_suppressed: Include suppressed entries when ``True``.
1759
+ component: Owning component name, or ``None`` for a document's
1760
+ own (non-component) feature.
1761
+ component_path: Owning component's resolved document path, or
1762
+ ``None`` to match ``component``.
1763
+ component_parent: Immediate parent component's name, or
1764
+ ``None`` for a top-level component or a document's own
1765
+ feature.
1766
+ """
1767
+ name = str(getattr(feature, "Name", ""))
1768
+ feature_type = str(
1769
+ self._adapter._attempt(
1770
+ lambda: self._adapter._get_attr_or_call(feature, "GetTypeName2"),
1771
+ default="Unknown",
1772
+ )
1773
+ )
1774
+ dedupe_key = (name, feature_type)
1775
+ if dedupe_key in seen:
1776
+ return
1777
+ seen.add(dedupe_key)
1778
+
1779
+ suppressed = self._is_feature_suppressed(feature)
1780
+ if not include_suppressed and suppressed:
1781
+ return
1782
+
1783
+ features.append(
1784
+ {
1785
+ "name": name,
1786
+ "type": feature_type,
1787
+ "suppressed": suppressed,
1788
+ "position": position,
1789
+ "component": component,
1790
+ "component_path": component_path,
1791
+ "component_parent": component_parent,
1792
+ }
1793
+ )
1794
+
1795
+ def _is_feature_suppressed(self, feature: Any) -> bool:
1796
+ """Determine feature suppression state across COM API variants.
1797
+
1798
+ Args:
1799
+ feature: Feature COM object.
1800
+
1801
+ Returns:
1802
+ bool: ``True`` when feature is suppressed.
1803
+ """
1804
+ suppressed_direct = self._adapter._attempt(
1805
+ lambda: feature.IsSuppressed(), default=None
1806
+ )
1807
+ if suppressed_direct is not None:
1808
+ return bool(suppressed_direct)
1809
+
1810
+ suppressed_result = self._adapter._attempt(
1811
+ lambda: feature.IsSuppressed2(0, []), default=None
1812
+ )
1813
+ if isinstance(suppressed_result, (tuple, list)):
1814
+ return bool(suppressed_result[0]) if suppressed_result else False
1815
+ return bool(suppressed_result) if suppressed_result is not None else False
1816
+
1817
+
1818
+ class PyWin32Adapter(
1819
+ SolidWorksDrawingStandardMixin,
1820
+ SolidWorksDocumentationMixin,
1821
+ SolidWorksSketchMixin,
1822
+ SolidWorksFeaturesMixin,
1823
+ SolidWorksIOMixin,
1824
+ SolidWorksSelectionMixin,
1825
+ SolidWorksAdapter,
1826
+ ):
1827
+ """SolidWorks adapter using pywin32 COM integration.
1828
+
1829
+ This adapter provides direct COM integration with SolidWorks using pywin32, enabling
1830
+ real-time automation and control of SolidWorks applications on Windows.
1831
+
1832
+ Args:
1833
+ config (dict[str, Any] | None): Configuration values for the operation. Defaults to
1834
+ None.
1835
+
1836
+ Raises:
1837
+ SolidWorksMCPError: PyWin32Adapter requires Windows platform.
1838
+
1839
+ Attributes:
1840
+ constants (Any): The constants value.
1841
+
1842
+ Example:
1843
+ ```python
1844
+ adapter = PyWin32Adapter({'timeout': 30})
1845
+ result = await adapter.connect()
1846
+ if result.status == AdapterResultStatus.SUCCESS:
1847
+ print("Connected to SolidWorks successfully")
1848
+ ```
1849
+ """
1850
+
1851
+ def __init__(self, config: dict[str, Any] | None = None) -> None:
1852
+ """Initialize PyWin32Adapter with configuration.
1853
+
1854
+ Args:
1855
+ config (dict[str, Any] | None): Configuration values for the operation. Defaults to
1856
+ None.
1857
+
1858
+ Returns:
1859
+ None: None.
1860
+
1861
+ Raises:
1862
+ SolidWorksMCPError: PyWin32Adapter requires Windows platform.
1863
+
1864
+ Example:
1865
+ ```python
1866
+ config = {
1867
+ "timeout": 30,
1868
+ "auto_connect": True,
1869
+ "startup_timeout": 60
1870
+ }
1871
+ adapter = PyWin32Adapter(config)
1872
+ ```
1873
+ """
1874
+ if not PYWIN32_AVAILABLE:
1875
+ raise SolidWorksMCPError(
1876
+ "pywin32 is not available. Install with: pip install pywin32"
1877
+ )
1878
+
1879
+ if platform.system() != "Windows":
1880
+ raise SolidWorksMCPError("PyWin32Adapter requires Windows platform")
1881
+
1882
+ super().__init__(config)
1883
+
1884
+ self.swApp: Any | None = None
1885
+ self.currentModel: Any | None = None
1886
+ self.currentSketch: Any | None = None
1887
+ self.currentSketchManager: Any | None = None
1888
+ self._last_sketch_name: str | None = None
1889
+ self._sketch_count: int = 0 # incremented each time a sketch is created
1890
+ self._sketch_entities: dict[str, Any] = {}
1891
+ # Cached (center_x_mm, center_y_mm) for entities whose center can't be
1892
+ # recovered via ``GetCenterPoint`` — currently polygons, which register
1893
+ # as a SAFEARRAY of segment handles (no single dispatch to read from).
1894
+ # ``sketch_circular_pattern`` reads this to derive the seed-to-axis
1895
+ # offset for polygon seeds.
1896
+ self._sketch_entity_centers: dict[str, tuple[float, float]] = {}
1897
+ self._sketch_entity_counter = 0
1898
+ self._com_initialized = False
1899
+ # Thread that initialised the COM apartment (see _handle_com_operation).
1900
+ self._com_thread_id: int | None = None
1901
+
1902
+ # COM constants (equivalent to SolidWorks API constants)
1903
+ self.constants = {
1904
+ # Document types
1905
+ "swDocPART": 1,
1906
+ "swDocASSEMBLY": 2,
1907
+ "swDocDRAWING": 3,
1908
+ # Selection types
1909
+ "swSelFACES": 1,
1910
+ "swSelEDGES": 2,
1911
+ "swSelVERTICES": 3,
1912
+ "swSelSKETCHSEGS": 4,
1913
+ "swSelSKETCHPOINTS": 5,
1914
+ "swSelDATUMPLANES": 6,
1915
+ # Feature end conditions
1916
+ "swEndCondBlind": 0,
1917
+ "swEndCondThroughAll": 1,
1918
+ "swEndCondUpToNext": 2,
1919
+ "swEndCondUpToSurface": 3,
1920
+ "swEndCondOffset": 4,
1921
+ "swEndCondUpToVertex": 5,
1922
+ "swEndCondMidPlane": 6,
1923
+ "swEndCondThroughAllBoth": 9,
1924
+ # Dimension preferences / directions
1925
+ "swInputDimValOnCreate": 10,
1926
+ "swSketchAcceptNumericInput": 372,
1927
+ "swSketchCreateDimensionOnlyWhenEntered": 520,
1928
+ "swScaleSketchOnFirstDimension": 642,
1929
+ "swSmartDimensionDirectionRight": 0,
1930
+ "swSmartDimensionDirectionUp": 1,
1931
+ "swSmartDimensionDirectionLeft": 2,
1932
+ "swSmartDimensionDirectionDown": 3,
1933
+ }
1934
+
1935
+ self._session_coordinator = _ComSessionCoordinator(self)
1936
+ self._sketch_geometry = _SketchGeometryService(self)
1937
+ self._document_routing = _DocumentRoutingService(self)
1938
+ self._feature_selector = _FeatureSelectionService(self)
1939
+
1940
+ def _initialize_com_apartment(self) -> None:
1941
+ """Initialize COM apartment once per adapter lifetime until disconnect.
1942
+
1943
+ This helper keeps COM initialization balanced and explicit so failure paths can
1944
+ deterministically release COM resources.
1945
+ """
1946
+ self._session_coordinator.initialize_com_apartment()
1947
+
1948
+ def _uninitialize_com_apartment(self) -> None:
1949
+ """Uninitialize COM apartment when it was initialized by this adapter."""
1950
+ self._session_coordinator.uninitialize_com_apartment()
1951
+
1952
+ async def _acquire_solidworks_application(self) -> Any:
1953
+ """Acquire a running SolidWorks COM server or start one with retries.
1954
+
1955
+ Returns:
1956
+ Any: SolidWorks application COM object.
1957
+
1958
+ Raises:
1959
+ SolidWorksMCPError: If no application instance can be obtained.
1960
+ """
1961
+ return await self._session_coordinator.acquire_solidworks_application()
1962
+
1963
+ async def _wait_for_server_ready(self, app: Any) -> None:
1964
+ """Wait until the SolidWorks COM server reports readiness.
1965
+
1966
+ Args:
1967
+ app: SolidWorks application COM object.
1968
+
1969
+ Raises:
1970
+ SolidWorksMCPError: If readiness probe times out.
1971
+ """
1972
+ await self._session_coordinator.wait_for_server_ready(app)
1973
+
1974
+ async def connect(self) -> None:
1975
+ """Connect to SolidWorks COM and prepare automation-safe session state.
1976
+
1977
+ Raises:
1978
+ SolidWorksMCPError: If connection or readiness checks fail.
1979
+ """
1980
+ await self._session_coordinator.connect()
1981
+
1982
+ async def disconnect(self) -> None:
1983
+ """Disconnect from SolidWorks application.
1984
+
1985
+ Properly disconnects from SolidWorks COM interface and cleans up resources. This method
1986
+ should always be called when finished to prevent memory leaks.
1987
+
1988
+ Note: - Clears references to current model and application - Uninitialize COM apartment
1989
+ - Does not close SolidWorks application itself
1990
+
1991
+ Returns:
1992
+ None: None.
1993
+
1994
+ Example:
1995
+ ```python
1996
+ try:
1997
+ await adapter.connect()
1998
+ # ... do work ...
1999
+ finally:
2000
+ await adapter.disconnect()
2001
+ ```
2002
+ """
2003
+ await self._session_coordinator.disconnect()
2004
+
2005
+ def is_connected(self) -> bool:
2006
+ """Check if connected to SolidWorks.
2007
+
2008
+ Returns:
2009
+ bool: True if connected, otherwise False.
2010
+
2011
+ Example:
2012
+ ```python
2013
+ if adapter.is_connected():
2014
+ print("Ready to automate SolidWorks")
2015
+ else:
2016
+ await adapter.connect()
2017
+ ```
2018
+ """
2019
+ return self.swApp is not None
2020
+
2021
+ async def health_check(self) -> AdapterHealth:
2022
+ """Get adapter health status.
2023
+
2024
+ Performs comprehensive health check including connection status, operation metrics, and
2025
+ SolidWorks application responsiveness.
2026
+
2027
+ Returns:
2028
+ AdapterHealth: The result produced by the operation.
2029
+
2030
+ Example:
2031
+ ```python
2032
+ health = await adapter.health_check()
2033
+ if health.healthy:
2034
+ print(f"Adapter healthy, {health.success_count} operations completed")
2035
+ else:
2036
+ print(f"Adapter unhealthy: {health.error_count} errors")
2037
+ ```
2038
+ """
2039
+ healthy = self.is_connected()
2040
+
2041
+ # Support both callable COM method and property-style RevisionNumber.
2042
+ sw_version: str | None = None
2043
+ if self.swApp:
2044
+ sw_version = self._attempt(
2045
+ lambda: self._get_attr_or_call(self.swApp, "RevisionNumber")
2046
+ )
2047
+
2048
+ # Try a simple operation to verify connection
2049
+ if healthy:
2050
+ healthy = sw_version is not None
2051
+
2052
+ return AdapterHealth(
2053
+ healthy=healthy,
2054
+ last_check=datetime.now(),
2055
+ error_count=int(self._metrics["errors_count"]),
2056
+ success_count=int(
2057
+ self._metrics["operations_count"] - self._metrics["errors_count"]
2058
+ ),
2059
+ average_response_time=self._metrics["average_response_time"],
2060
+ connection_status="connected" if healthy else "disconnected",
2061
+ metrics={
2062
+ "adapter_type": "pywin32",
2063
+ "sw_version": sw_version or "Unknown",
2064
+ "current_model": self.currentModel.GetTitle()
2065
+ if self.currentModel
2066
+ else None,
2067
+ },
2068
+ )
2069
+
2070
+ def _handle_com_operation( # type: ignore[override]
2071
+ self,
2072
+ operation_name: str,
2073
+ operation_func: Callable[..., T],
2074
+ *operation_args: Any,
2075
+ **operation_kwargs: Any,
2076
+ ) -> AdapterResult[T]:
2077
+ """Helper to handle COM operations with error handling and timing.
2078
+
2079
+ Wraps COM operations with comprehensive error handling, performance metrics, and
2080
+ standardized result formatting. All SolidWorks COM calls should use this.
2081
+
2082
+ Args:
2083
+ operation_name (str): The operation name value.
2084
+ operation_func (Callable[[], T]): The operation func value.
2085
+
2086
+ Returns:
2087
+ AdapterResult[T]: The result produced by the operation.
2088
+
2089
+ Example:
2090
+ ```python
2091
+ result = self._handle_com_operation(
2092
+ "create_sketch",
2093
+ lambda: self.swApp.ActiveDoc.SketchManager.InsertSketch(True)
2094
+ )
2095
+ if result.status == AdapterResultStatus.SUCCESS:
2096
+ print("Sketch created successfully")
2097
+ ```
2098
+ """
2099
+ owner = self._com_thread_id
2100
+ if owner is not None and threading.get_ident() != owner:
2101
+ # Using an STA-bound IDispatch from another thread fails with a
2102
+ # cryptic late-binding AttributeError (runbook #5); refuse clearly
2103
+ # before touching COM.
2104
+ return AdapterResult(
2105
+ status=AdapterResultStatus.ERROR,
2106
+ error=(
2107
+ f"{operation_name} was called from thread "
2108
+ f"{threading.current_thread().name!r}, but SolidWorks COM "
2109
+ "objects belong to the thread that called connect()."
2110
+ ),
2111
+ execution_time=0.0,
2112
+ )
2113
+ start_time = time.time()
2114
+
2115
+ try:
2116
+ result = operation_func(*operation_args, **operation_kwargs)
2117
+ execution_time = time.time() - start_time
2118
+ self.update_metrics(execution_time, True)
2119
+ return AdapterResult(
2120
+ status=AdapterResultStatus.SUCCESS,
2121
+ data=result,
2122
+ execution_time=execution_time,
2123
+ )
2124
+ except pywintypes.com_error as e:
2125
+ execution_time = time.time() - start_time
2126
+ self.update_metrics(execution_time, False)
2127
+ # Full traceback goes to the log file; the returned error string is
2128
+ # necessarily flattened (runbook item #5). Without this, a
2129
+ # late-binding AttributeError surfaces only as "Error in <op>: ..."
2130
+ # with no way to see where it came from.
2131
+ logger.exception("COM operation {} failed", operation_name)
2132
+ return AdapterResult(
2133
+ status=AdapterResultStatus.ERROR,
2134
+ error=f"COM error in {operation_name}: {e}",
2135
+ execution_time=execution_time,
2136
+ )
2137
+ except Exception as e:
2138
+ execution_time = time.time() - start_time
2139
+ self.update_metrics(execution_time, False)
2140
+ if isinstance(e, LookupError):
2141
+ # An expected answer ("no feature named X"), not a fault:
2142
+ # one line, no traceback.
2143
+ logger.warning("Operation {} found nothing: {}", operation_name, e)
2144
+ else:
2145
+ logger.exception("Operation {} failed", operation_name)
2146
+ return AdapterResult(
2147
+ status=AdapterResultStatus.ERROR,
2148
+ error=f"Error in {operation_name}: {e}",
2149
+ execution_time=execution_time,
2150
+ )
2151
+ finally:
2152
+ # After the timing above, so execution_time stays the call itself.
2153
+ _settle_after(operation_name)
2154
+
2155
+ def _attempt(
2156
+ self, operation: Callable[[], T], default: T | None = None
2157
+ ) -> T | None:
2158
+ """Build internal attempt.
2159
+
2160
+ Keep non-critical fallback handling in one place instead of scattering broad try/except
2161
+ blocks throughout operation code.
2162
+
2163
+ Args:
2164
+ operation (Callable[[], T]): Callable object executed by the helper.
2165
+ default (T | None): Fallback value returned when the operation fails. Defaults to
2166
+ None.
2167
+
2168
+ Returns:
2169
+ T | None: The result produced by the operation.
2170
+ """
2171
+ try:
2172
+ return operation()
2173
+ except Exception:
2174
+ return default
2175
+
2176
+ def _attempt_with_error(
2177
+ self, operation: Callable[[], T]
2178
+ ) -> tuple[T | None, Exception | None]:
2179
+ """Build internal attempt with error.
2180
+
2181
+ Args:
2182
+ operation (Callable[[], T]): Callable object executed by the helper.
2183
+
2184
+ Returns:
2185
+ tuple[T | None, Exception | None]: A tuple containing the resulting values.
2186
+ """
2187
+ try:
2188
+ return operation(), None
2189
+ except Exception as exc:
2190
+ return None, exc
2191
+
2192
+ def _get_attr_or_call(self, obj: Any, attr_name: str) -> Any:
2193
+ """Read COM attribute exposed as a property or zero-arg method.
2194
+
2195
+ Args:
2196
+ obj (Any): The obj value.
2197
+ attr_name (str): The attr name value.
2198
+
2199
+ Returns:
2200
+ Any: The result produced by the operation.
2201
+ """
2202
+ attr = getattr(obj, attr_name, None)
2203
+ return attr() if callable(attr) else attr
2204
+
2205
+ def _get_feature_id(self, feature: Any) -> str:
2206
+ """Extract a stable string feature ID from COM feature objects.
2207
+
2208
+ Some SolidWorks COM bindings return an int-like value from GetID(), while others return
2209
+ a .NET object exposing ToString().
2210
+
2211
+ Args:
2212
+ feature (Any): The feature value.
2213
+
2214
+ Returns:
2215
+ str: The resulting text value.
2216
+ """
2217
+ feature_id_getter = getattr(feature, "GetID", None)
2218
+ feature_id_value = (
2219
+ feature_id_getter() if callable(feature_id_getter) else feature_id_getter
2220
+ )
2221
+ to_string = getattr(feature_id_value, "ToString", None)
2222
+ return str(to_string() if callable(to_string) else feature_id_value)
2223
+
2224
+ def _reset_sketch_entity_registry(self) -> None:
2225
+ """Clear transient sketch entity handles tracked for dimensioning."""
2226
+ self._sketch_geometry.reset_registry()
2227
+
2228
+ def _register_sketch_entity(self, prefix: str, entity: Any) -> str:
2229
+ """Store sketch entity COM handle and return a stable entity identifier."""
2230
+ return self._sketch_geometry.register_entity(prefix, entity)
2231
+
2232
+ def _select_sketch_entity(self, entity: Any, append: bool) -> bool:
2233
+ """Select a sketch entity using compatible COM select methods."""
2234
+ return self._sketch_geometry.select_entity(entity, append)
2235
+
2236
+ def _set_display_dimension_value(self, display_dim: Any, value_mm: float) -> None:
2237
+ """Set created display dimension to the requested value in meters."""
2238
+ self._sketch_geometry.set_display_dimension_value(display_dim, value_mm)
2239
+
2240
+ def _set_view_orientation(
2241
+ self, target_doc: Any, orientation: str, view_const: int
2242
+ ) -> None:
2243
+ """Set SolidWorks view orientation with graceful fallback.
2244
+
2245
+ ShowNamedView2 can fail for assemblies with lightweight components,
2246
+ but screenshot can still succeed with current view. Logs warning on failure.
2247
+
2248
+ Args:
2249
+ target_doc: SolidWorks model document
2250
+ orientation: Orientation name (for logging)
2251
+ view_const: SolidWorks view constant (1-9)
2252
+
2253
+ Returns:
2254
+ None (operation always succeeds or fails silently)
2255
+ """
2256
+ try:
2257
+ target_doc.ShowNamedView2("", view_const)
2258
+ except Exception as exc:
2259
+ logger.warning(
2260
+ "[pywin32.export_image] ShowNamedView2({}) failed ({}), "
2261
+ "continuing with current view",
2262
+ orientation,
2263
+ exc,
2264
+ )
2265
+
2266
+ def _zoom_to_fit(self, target_doc: Any) -> None:
2267
+ """Redraw, zoom the model to fit its viewport, and redraw again.
2268
+
2269
+ The member is ``ViewZoomtofit2`` (lower-case "tofit"). The old
2270
+ ``ViewZoomToFit2`` spelling is not in the type library's method map,
2271
+ so late binding resolved it as a property read and then raised on the
2272
+ call, and the ``IModelView.ZoomToFit`` fallback does not exist at
2273
+ all. Exports of a reopened part used to come out a few pixels tall;
2274
+ redrawing before the zoom fits it to the current view, and with this
2275
+ sequence a closed-and-reopened part exported framed as before
2276
+ (checked live on SW 2026). ``ViewZoomtofit`` is tried when the ``2``
2277
+ variant fails.
2278
+
2279
+ Args:
2280
+ target_doc: SolidWorks model document
2281
+
2282
+ Returns:
2283
+ None (operation always succeeds or fails silently)
2284
+ """
2285
+ self._attempt(
2286
+ lambda: sw_type_info.flag_members(
2287
+ target_doc, "GraphicsRedraw2", "ViewZoomtofit2", "ViewZoomtofit"
2288
+ )
2289
+ )
2290
+ self._attempt(lambda: target_doc.GraphicsRedraw2())
2291
+ try:
2292
+ target_doc.ViewZoomtofit2()
2293
+ except Exception:
2294
+ try:
2295
+ target_doc.ViewZoomtofit()
2296
+ except Exception as exc:
2297
+ logger.warning(
2298
+ "[pywin32.export_image] ViewZoomtofit failed ({}), "
2299
+ "screenshot may be zoomed out",
2300
+ exc,
2301
+ )
2302
+ self._attempt(lambda: target_doc.GraphicsRedraw2())
2303
+
2304
+ def _save_screenshot_sized(
2305
+ self, target_doc: Any, resolved_path: str, width: int, height: int
2306
+ ) -> bool:
2307
+ """Write a ``width`` x ``height`` image of the view via ``IModelDoc2.SaveBMP``.
2308
+
2309
+ ``SaveBMP`` is the only call that takes a pixel size (an earlier
2310
+ ``SaveBitmapWithVariableSize`` is not a SolidWorks API, so every export
2311
+ fell through to ``SaveAs3`` at the window's size). It keeps the
2312
+ window's framing, so it renders at the window's aspect (see
2313
+ :meth:`_window_aspect`) inside the requested size, and the picture is
2314
+ padded out to that size; rendering straight at a narrower aspect
2315
+ cropped the zoomed-to-fit model. It writes BMP only, so the output is
2316
+ converted from a temporary BMP next to it.
2317
+
2318
+ Args:
2319
+ target_doc: SolidWorks model document
2320
+ resolved_path: Full path where to save image
2321
+ width: Image width in pixels
2322
+ height: Image height in pixels
2323
+
2324
+ Returns:
2325
+ True when the image was written at the requested size; False to
2326
+ fall back (unsupported extension, ``SaveBMP`` failed, or JPEG
2327
+ without Pillow).
2328
+ """
2329
+ stem, suffix = os.path.splitext(resolved_path)
2330
+ if suffix.lower() not in (".bmp", ".png", ".jpg", ".jpeg"):
2331
+ return False
2332
+ aspect = self._window_aspect(target_doc)
2333
+ render_w, render_h = (
2334
+ bitmap.fit_aspect(width, height, aspect) if aspect else (width, height)
2335
+ )
2336
+ bmp_path = stem + ".sizing.bmp"
2337
+ self._attempt(lambda: sw_type_info.flag_members(target_doc, "SaveBMP"))
2338
+ written = self._attempt(
2339
+ lambda: target_doc.SaveBMP(bmp_path, render_w, render_h), default=False
2340
+ )
2341
+ if not written or not os.path.exists(bmp_path):
2342
+ logger.debug("[pywin32.export_image] SaveBMP wrote nothing")
2343
+ return False
2344
+ try:
2345
+ bitmap.convert_bmp(bmp_path, resolved_path, size=(width, height))
2346
+ except (ImportError, ValueError) as exc:
2347
+ logger.debug("[pywin32.export_image] BMP conversion failed: {}", exc)
2348
+ return False
2349
+ finally:
2350
+ os.remove(bmp_path)
2351
+ return True
2352
+
2353
+ def _window_aspect(self, target_doc: Any) -> float | None:
2354
+ """Width / height of the document's graphics window, or ``None``.
2355
+
2356
+ Read from ``IModelView.FrameWidth`` / ``FrameHeight`` on
2357
+ ``ActiveView``; ``None`` when either is unreadable or not positive.
2358
+ """
2359
+ view = self._attempt(lambda: target_doc.ActiveView)
2360
+ size = [
2361
+ self._attempt(lambda name=name: int(getattr(view, name)))
2362
+ for name in ("FrameWidth", "FrameHeight")
2363
+ ]
2364
+ if not all(isinstance(v, int) and v > 0 for v in size):
2365
+ return None
2366
+ return size[0] / size[1]
2367
+
2368
+ def _save_screenshot_with_saveas3(
2369
+ self, target_doc: Any, resolved_path: str
2370
+ ) -> None:
2371
+ """Final fallback: SaveAs3 with image extension for screenshot.
2372
+
2373
+ SolidWorks determines export format from file extension (.png, .jpg, .bmp, etc.).
2374
+ This path works on all SolidWorks versions without needing COM vtable access.
2375
+
2376
+ Args:
2377
+ target_doc: SolidWorks model document
2378
+ resolved_path: Full path where to save image
2379
+
2380
+ Raises:
2381
+ RuntimeError: If SaveAs3 fails or file is not created.
2382
+ """
2383
+ try:
2384
+ target_doc.SaveAs3(
2385
+ resolved_path, 0, 2
2386
+ ) # swSaveAsCurrentVersion=0, Silent=2
2387
+ if os.path.exists(resolved_path):
2388
+ return
2389
+ except Exception as exc:
2390
+ logger.debug(
2391
+ "[pywin32.export_image] SaveAs3 failed: {}",
2392
+ exc,
2393
+ )
2394
+
2395
+ raise RuntimeError(f"All screenshot methods failed for {resolved_path}")
2396
+
2397
+ def _document_identity(self, document: Any) -> tuple[str | None, str | None]:
2398
+ """Return normalized path and title for a SolidWorks document.
2399
+
2400
+ Args:
2401
+ document: SolidWorks document COM object.
2402
+
2403
+ Returns:
2404
+ Tuple of normalized absolute path and title, either may be ``None``.
2405
+ """
2406
+ return self._document_routing.document_identity(document)
2407
+
2408
+ def _resolve_export_target_doc(self) -> Any:
2409
+ """Choose the document that export operations should target.
2410
+
2411
+ Prefer the adapter's tracked model. Use ``ActiveDoc`` only when it is the same
2412
+ document, which preserves the typed COM surface without exporting a different
2413
+ SolidWorks window that happens to be active.
2414
+ """
2415
+ return self._document_routing.resolve_export_target_doc()
2416
+
2417
+ async def export_image(self, payload: dict) -> AdapterResult[dict]:
2418
+ """Export a screenshot of the current model to a PNG/JPG file.
2419
+
2420
+ Payload keys (matching ExportImageInput): file_path (str): Output path including
2421
+ extension. format_type (str): "png" or "jpg". Default "png". width (int): Pixel width.
2422
+ Default 1280. height (int): Pixel height. Default 720. view_orientation (str): "front" |
2423
+ "top" | "right" | "isometric" | "current".
2424
+
2425
+ Args:
2426
+ payload (dict): The payload value.
2427
+
2428
+ Returns:
2429
+ AdapterResult[dict]: The result produced by the operation.
2430
+
2431
+ Raises:
2432
+ RuntimeError: If the operation cannot be completed.
2433
+ """
2434
+ if not self.currentModel:
2435
+ return AdapterResult(
2436
+ status=AdapterResultStatus.ERROR, error="No active model"
2437
+ )
2438
+ if not self.swApp:
2439
+ return AdapterResult(
2440
+ status=AdapterResultStatus.ERROR, error="SolidWorks not connected"
2441
+ )
2442
+
2443
+ orientation = str(payload.get("view_orientation", "current")).lower()
2444
+ file_path = payload.get("file_path", "")
2445
+ width = int(payload.get("width", 1280))
2446
+ height = int(payload.get("height", 720))
2447
+
2448
+ # Map orientation names to SolidWorks swStandardViews_e constants
2449
+ _VIEW_CONSTANTS = {
2450
+ "front": 1, # swFrontView
2451
+ "back": 2, # swBackView
2452
+ "left": 3, # swLeftView
2453
+ "right": 4, # swRightView
2454
+ "top": 5, # swTopView
2455
+ "bottom": 6, # swBottomView
2456
+ "isometric": 7, # swIsometricView
2457
+ "dimetric": 8, # swDimetricView
2458
+ "trimetric": 9, # swTriMetricView
2459
+ }
2460
+
2461
+ def _screenshot_operation() -> dict:
2462
+ """Build internal screenshot operation.
2463
+
2464
+ Returns:
2465
+ dict: A dictionary containing the resulting values.
2466
+
2467
+ Raises:
2468
+ RuntimeError: If the operation cannot be completed.
2469
+ """
2470
+
2471
+ import os as _os
2472
+
2473
+ resolved = _os.path.abspath(file_path)
2474
+ _os.makedirs(_os.path.dirname(resolved), exist_ok=True)
2475
+
2476
+ target_doc = self._resolve_export_target_doc()
2477
+
2478
+ # Activate target_doc's own MDI window before capturing. Without
2479
+ # this, SaveBitmapWithVariableSize renders whatever document
2480
+ # window is currently frontmost in the SW frame -- which can be
2481
+ # a completely different open document if the user has clicked
2482
+ # around in SolidWorks since -- even though it's called on the
2483
+ # correct target_doc COM object. See com-api-pitfalls.md #21.
2484
+ _, target_title = self._document_identity(target_doc)
2485
+ if target_title:
2486
+ self._attempt(
2487
+ lambda: self.swApp.ActivateDoc3( # type: ignore[union-attr]
2488
+ target_title, False, 0, _byref_int()
2489
+ )
2490
+ )
2491
+
2492
+ # Ensure SolidWorks window is focused so the viewport is rendered.
2493
+ # Required for both view changes and bitmap capture.
2494
+ self._attempt(lambda: self.swApp.Frame.SetFocus()) # type: ignore[union-attr]
2495
+
2496
+ # Set view orientation if requested
2497
+ if orientation != "current" and orientation in _VIEW_CONSTANTS:
2498
+ view_const = _VIEW_CONSTANTS[orientation]
2499
+ self._set_view_orientation(target_doc, orientation, view_const)
2500
+
2501
+ # Zoom to fit so the model fills the viewport before capture
2502
+ self._zoom_to_fit(target_doc)
2503
+
2504
+ # Remove any pre-existing file so SolidWorks never shows the
2505
+ # "already exists — replace?" confirmation dialog.
2506
+ if _os.path.exists(resolved):
2507
+ _os.remove(resolved)
2508
+
2509
+ # SaveBMP at the requested size; SaveAs3 (window size) otherwise.
2510
+ saved = self._save_screenshot_sized(target_doc, resolved, width, height)
2511
+ if not saved:
2512
+ self._save_screenshot_with_saveas3(target_doc, resolved)
2513
+ saved = _os.path.exists(resolved)
2514
+
2515
+ if not saved:
2516
+ raise RuntimeError(
2517
+ f"All screenshot methods produced no output for {resolved}"
2518
+ )
2519
+
2520
+ # Report the size actually written, not the one asked for: the
2521
+ # SaveAs3 fallback ignores width/height.
2522
+ size = bitmap.image_size(resolved)
2523
+ return {
2524
+ "file_path": resolved,
2525
+ "format": _os.path.splitext(resolved)[1].lstrip(".").upper() or "PNG",
2526
+ "dimensions": f"{size[0]}x{size[1]}" if size else "unknown",
2527
+ "requested_dimensions": f"{width}x{height}",
2528
+ "view": orientation,
2529
+ }
2530
+
2531
+ return self._handle_com_operation("export_image", _screenshot_operation)
2532
+
2533
+ def _prepare_stl_export_data(self) -> Any | None:
2534
+ """Prepare ISTLExportData for STL export with merged bodies option.
2535
+
2536
+ Attempts to get and configure STL export settings from swApp.
2537
+ Returns None if swApp is unavailable or if COM operation fails.
2538
+
2539
+ Returns:
2540
+ ISTLExportData object or None if unavailable/failed.
2541
+ """
2542
+ if self.swApp is None:
2543
+ return None
2544
+
2545
+ try:
2546
+ # swExportDataFileType_e.swExportSTL = 2
2547
+ stl_data = self.swApp.GetExportFileData(2)
2548
+ if stl_data is None:
2549
+ return None
2550
+
2551
+ # Configure for merged single-file export (best for assemblies)
2552
+ self._attempt(lambda: setattr(stl_data, "Merge", True))
2553
+ # swExportBodiesAs = 0 → swExportAsOneFile
2554
+ self._attempt(lambda: setattr(stl_data, "ExportBodiesAs", 0))
2555
+
2556
+ return stl_data
2557
+ except Exception:
2558
+ return None
2559
+
2560
+ def _save_stl_with_extension(
2561
+ self, ext: Any, stl_data: Any | None, resolved_path: str
2562
+ ) -> bool:
2563
+ """Attempt STL export using Extension.SaveAs2 with optional ISTLExportData.
2564
+
2565
+ Tries SaveAs2 with stl_data first (enables body merging); falls back to
2566
+ SaveAs2(None) if type mismatch occurs (late-bound IDispatch can't marshal
2567
+ ISTLExportData* through IDispatch::Invoke).
2568
+
2569
+ Args:
2570
+ ext: Extension object from target document
2571
+ stl_data: Optional ISTLExportData configuration
2572
+ resolved_path: Full path where to save STL file
2573
+
2574
+ Returns:
2575
+ True if file was created, False otherwise.
2576
+ """
2577
+ try:
2578
+ # Try with stl_data first
2579
+ try:
2580
+ # swSaveAsVersion_e.swSaveAsCurrentVersion = 0
2581
+ # swSaveAsOptions_e.swSaveAsOptions_Silent = 2
2582
+ ext.SaveAs2(resolved_path, 0, 2, stl_data, None, "")
2583
+ except Exception:
2584
+ # Fallback: try without stl_data (type mismatch on IDispatch)
2585
+ ext.SaveAs2(resolved_path, 0, 2, None, None, "")
2586
+
2587
+ return os.path.exists(resolved_path)
2588
+ except Exception as exc:
2589
+ logger.warning(
2590
+ "[pywin32.export_file] Extension.SaveAs2 failed: {}",
2591
+ str(exc),
2592
+ )
2593
+ return False
2594
+
2595
+ def _save_stl_with_fallback(self, target_doc: Any, resolved_path: str) -> None:
2596
+ """Fallback STL export using SaveAs3 if SaveAs2 didn't create file.
2597
+
2598
+ Args:
2599
+ target_doc: SolidWorks model document
2600
+ resolved_path: Full path to save file to
2601
+
2602
+ Raises:
2603
+ Exception: If both SaveAs2 and SaveAs3 fail to produce file.
2604
+ """
2605
+ logger.warning(
2606
+ "[pywin32.export_file] SaveAs2 did not produce {}, falling back to SaveAs3",
2607
+ resolved_path,
2608
+ )
2609
+ try:
2610
+ target_doc.SaveAs3(
2611
+ resolved_path,
2612
+ 0, # swSaveAsCurrentVersion
2613
+ 2, # swSaveAsOptions_Silent
2614
+ )
2615
+ except Exception as exc:
2616
+ logger.warning(
2617
+ "[pywin32.export_file] SaveAs3 also failed: {}",
2618
+ str(exc),
2619
+ )
2620
+
2621
+ if not os.path.exists(resolved_path):
2622
+ raise Exception(
2623
+ f"STL export failed for {resolved_path} "
2624
+ "(tried Extension.SaveAs2 and SaveAs3)"
2625
+ )
2626
+
2627
+ async def export_file(
2628
+ self, file_path: str, format_type: str
2629
+ ) -> AdapterResult[None]:
2630
+ """Export the current model to a file.
2631
+
2632
+ Args:
2633
+ file_path (str): Path to the target file.
2634
+ format_type (str): The format type value.
2635
+
2636
+ Returns:
2637
+ AdapterResult[None]: The result produced by the operation.
2638
+
2639
+ Raises:
2640
+ Exception: If the operation cannot be completed.
2641
+ RuntimeError: No active SolidWorks document for export.
2642
+ """
2643
+ if not self.currentModel:
2644
+ return AdapterResult(
2645
+ status=AdapterResultStatus.ERROR, error="No active model"
2646
+ )
2647
+
2648
+ def _export_operation() -> None:
2649
+ """Build internal export operation.
2650
+
2651
+ Returns:
2652
+ None: None.
2653
+
2654
+ Raises:
2655
+ Exception: If the operation cannot be completed.
2656
+ RuntimeError: No active SolidWorks document for export.
2657
+ """
2658
+ format_map = {
2659
+ "step": 0, # swSaveAsSTEP
2660
+ "iges": 1, # swSaveAsIGS
2661
+ "stl": 2, # swSaveAsSTL
2662
+ "pdf": 3, # swSaveAsPDF
2663
+ "dwg": 4, # swSaveAsDWG
2664
+ "jpg": 5, # swSaveAsJPEG
2665
+ "glb": 41, # swSaveAsGLTF (binary GLTF, SW 2023+)
2666
+ "gltf": 41, # same enum value, text GLTF
2667
+ }
2668
+
2669
+ format_lower = format_type.lower()
2670
+ if format_lower not in format_map:
2671
+ raise Exception(f"Unsupported export format: {format_type}")
2672
+
2673
+ resolved_path = os.path.abspath(file_path)
2674
+ os.makedirs(os.path.dirname(resolved_path), exist_ok=True)
2675
+
2676
+ if os.path.exists(resolved_path):
2677
+ self._attempt(lambda: os.remove(resolved_path))
2678
+
2679
+ # Prefer swApp.ActiveDoc — more reliably typed than the late-bound
2680
+ # IDispatch reference stored in self.currentModel after OpenDoc6.
2681
+ # Use getattr so tests can pass a SimpleNamespace without ActiveDoc.
2682
+ target_doc = (
2683
+ getattr(self.swApp, "ActiveDoc", None) if self.swApp else None
2684
+ ) or self.currentModel
2685
+
2686
+ # ----------------------------------------------------------------
2687
+ # STL export: use Extension.SaveAs2 + ISTLExportData
2688
+ # for both parts AND assemblies. SaveAs3 with format=2 works for
2689
+ # parts but is unreliable for assemblies (only exports first body).
2690
+ # ----------------------------------------------------------------
2691
+ if format_lower == "stl":
2692
+ # For assemblies, resolve lightweight components first so all
2693
+ # geometry is available for the mesh export.
2694
+ self._attempt(lambda: target_doc.ResolveAllLightweightComponents(True)) # type: ignore[union-attr]
2695
+
2696
+ ext = getattr(target_doc, "Extension", None)
2697
+ if ext is None:
2698
+ raise RuntimeError("No Extension object available for STL export")
2699
+
2700
+ stl_data = self._prepare_stl_export_data()
2701
+ if not self._save_stl_with_extension(ext, stl_data, resolved_path):
2702
+ # SaveAs2 didn't produce file — try SaveAs3 fallback
2703
+ self._save_stl_with_fallback(target_doc, resolved_path)
2704
+
2705
+ return None
2706
+
2707
+ # ----------------------------------------------------------------
2708
+ # All other formats — classic SaveAs3 path.
2709
+ # SaveAs3 signature: SaveAs3(FileName, Version, Options)
2710
+ # Version = 0 means "current version" (swSaveAsCurrentVersion).
2711
+ # SolidWorks infers the export format from the file extension, so
2712
+ # we must NOT pass the format-enum value as the Version argument.
2713
+ # ----------------------------------------------------------------
2714
+ _ = format_map[format_lower] # validate format is known; value unused
2715
+ logger.debug(
2716
+ "[pywin32.export_file] SaveAs3 {} (version=0, options=Silent)",
2717
+ resolved_path,
2718
+ )
2719
+ success = target_doc.SaveAs3( # type: ignore[union-attr]
2720
+ resolved_path,
2721
+ 0, # swSaveAsCurrentVersion — format inferred from file extension
2722
+ 2, # swSaveAsOptions_Silent
2723
+ )
2724
+
2725
+ if not success and not os.path.exists(resolved_path):
2726
+ raise Exception(
2727
+ f"SaveAs3 returned False and no file produced: {resolved_path}"
2728
+ )
2729
+
2730
+ return None
2731
+
2732
+ return self._handle_com_operation("export_file", _export_operation)
2733
+
2734
+ def _get_document_type(self) -> str:
2735
+ """Helper method to get document type.
2736
+
2737
+ Returns:
2738
+ str: The resulting text value.
2739
+ """
2740
+ if not self.currentModel:
2741
+ return "Unknown"
2742
+
2743
+ doc_type = self._get_attr_or_call(self.currentModel, "GetType")
2744
+ type_map = {1: "Part", 2: "Assembly", 3: "Drawing"}
2745
+ return type_map.get(doc_type, "Unknown")
2746
+
2747
+ def _invoke_run_macro2(
2748
+ self, macro_path: str, module_name: str, proc_name: str
2749
+ ) -> dict[str, Any]:
2750
+ """Call swApp.RunMacro2 and parse the result into a result dict.
2751
+
2752
+ Args:
2753
+ macro_path: Absolute path to the VBA macro file.
2754
+ module_name: VB module name (parsed from the file or stem fallback).
2755
+ proc_name: Entry-point procedure name, typically "main".
2756
+
2757
+ Returns:
2758
+ dict[str, Any]: {"macro_path", "module_name", "errors"} on success.
2759
+
2760
+ Raises:
2761
+ SolidWorksMCPError: If RunMacro2 reports failure.
2762
+ """
2763
+ # nRetval (5th arg) is ByRef Long. Passing a plain 0 raises
2764
+ # DISP_E_TYPEMISMATCH ('Type mismatch.', arg index 5) under pywin32
2765
+ # late binding - it must be a VT_BYREF|VT_I4 VARIANT (issue #91).
2766
+ logger.debug(
2767
+ "RunMacro2: path={!r} module={!r} proc={!r}",
2768
+ macro_path,
2769
+ module_name,
2770
+ proc_name,
2771
+ )
2772
+ retval = _byref_long(0)
2773
+ result = self.swApp.RunMacro2( # type: ignore[union-attr]
2774
+ macro_path, module_name, proc_name, 0, retval
2775
+ )
2776
+ if isinstance(result, (list, tuple)):
2777
+ success, errors = result[0], result[1]
2778
+ else:
2779
+ success, errors = bool(result), getattr(retval, "value", 0)
2780
+ logger.debug(
2781
+ "RunMacro2 returned success={} error={}",
2782
+ bool(success),
2783
+ _run_macro_error_name(errors),
2784
+ )
2785
+ if not success:
2786
+ hint = ""
2787
+ if _run_macro_error_name(errors).endswith("(Invalidindex)"):
2788
+ hint = (
2789
+ " - RunMacro2 could not resolve the module/procedure; "
2790
+ f"check that module {module_name!r} and procedure "
2791
+ f"{proc_name!r} exist in the .swp"
2792
+ )
2793
+ raise SolidWorksMCPError(
2794
+ f"RunMacro2 failed for {macro_path}, module={module_name!r}, "
2795
+ f"proc={proc_name!r}, errors={_run_macro_error_name(errors)}{hint}"
2796
+ )
2797
+ return {
2798
+ "macro_path": macro_path,
2799
+ "module_name": module_name,
2800
+ "proc_name": proc_name,
2801
+ "errors": errors,
2802
+ }
2803
+
2804
+ async def execute_macro(
2805
+ self, params: dict[str, Any]
2806
+ ) -> AdapterResult[dict[str, Any]]:
2807
+ """Provide execute macro support for the py win32 adapter.
2808
+
2809
+ Args:
2810
+ params (dict[str, Any]): The params value.
2811
+
2812
+ Returns:
2813
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2814
+
2815
+ Raises:
2816
+ SolidWorksMCPError: If the operation cannot be completed.
2817
+ """
2818
+ macro_path = params.get("macro_path") or params.get("macro_file") or ""
2819
+ if not macro_path:
2820
+ return AdapterResult(
2821
+ status=AdapterResultStatus.ERROR, error="No macro_path provided"
2822
+ )
2823
+ if not os.path.isfile(macro_path):
2824
+ return AdapterResult(
2825
+ status=AdapterResultStatus.ERROR,
2826
+ error=f"Macro file not found: {macro_path}",
2827
+ )
2828
+
2829
+ def _run() -> dict[str, Any]:
2830
+ """Resolve module/proc names and delegate to _invoke_run_macro2.
2831
+
2832
+ Returns:
2833
+ dict[str, Any]: A dictionary containing the resulting values.
2834
+
2835
+ Raises:
2836
+ SolidWorksMCPError: If the operation cannot be completed.
2837
+ """
2838
+ module_name = _parse_vb_module_name(macro_path)
2839
+ proc_name = params.get("proc_name", "main")
2840
+ return self._invoke_run_macro2(macro_path, module_name, proc_name)
2841
+
2842
+ return self._handle_com_operation("execute_macro", _run)