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.
- solidworks_mcp/__init__.py +57 -0
- solidworks_mcp/adapters/__init__.py +40 -0
- solidworks_mcp/adapters/base.py +1882 -0
- solidworks_mcp/adapters/circuit_breaker.py +2052 -0
- solidworks_mcp/adapters/com_executor.py +252 -0
- solidworks_mcp/adapters/complexity_analyzer.py +272 -0
- solidworks_mcp/adapters/connection_pool.py +1884 -0
- solidworks_mcp/adapters/factory.py +366 -0
- solidworks_mcp/adapters/intelligent_router.py +218 -0
- solidworks_mcp/adapters/mock_adapter.py +4016 -0
- solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
- solidworks_mcp/adapters/solidworks/__init__.py +17 -0
- solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
- solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
- solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
- solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
- solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
- solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
- solidworks_mcp/adapters/solidworks/features.py +3362 -0
- solidworks_mcp/adapters/solidworks/io.py +3649 -0
- solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
- solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
- solidworks_mcp/adapters/solidworks/selection.py +308 -0
- solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
- solidworks_mcp/adapters/sw_feature_errors.py +66 -0
- solidworks_mcp/adapters/sw_type_info.py +317 -0
- solidworks_mcp/adapters/vba_adapter.py +318 -0
- solidworks_mcp/adapters/vba_macro_executor.py +215 -0
- solidworks_mcp/agents/__init__.py +68 -0
- solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
- solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
- solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
- solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
- solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
- solidworks_mcp/agents/harness.py +216 -0
- solidworks_mcp/agents/history_db.py +1508 -0
- solidworks_mcp/agents/retrieval_index.py +164 -0
- solidworks_mcp/agents/schemas.py +165 -0
- solidworks_mcp/agents/smoke_test.py +285 -0
- solidworks_mcp/agents/soc_exporter.py +650 -0
- solidworks_mcp/agents/soc_pickup.py +332 -0
- solidworks_mcp/agents/soc_rewind.py +221 -0
- solidworks_mcp/agents/vector_rag.py +665 -0
- solidworks_mcp/cache/__init__.py +5 -0
- solidworks_mcp/cache/response_cache.py +164 -0
- solidworks_mcp/config.py +729 -0
- solidworks_mcp/exceptions.py +37 -0
- solidworks_mcp/security/__init__.py +68 -0
- solidworks_mcp/security/auth.py +128 -0
- solidworks_mcp/security/cors.py +27 -0
- solidworks_mcp/security/rate_limiting.py +90 -0
- solidworks_mcp/security/runtime.py +155 -0
- solidworks_mcp/server.py +777 -0
- solidworks_mcp/server_cli_fixed.py +120 -0
- solidworks_mcp/setup/__init__.py +1 -0
- solidworks_mcp/setup/cli.py +399 -0
- solidworks_mcp/setup/clients.py +157 -0
- solidworks_mcp/setup/config_file.py +230 -0
- solidworks_mcp/setup/handshake.py +202 -0
- solidworks_mcp/setup/launcher.py +102 -0
- solidworks_mcp/setup/legacy.py +100 -0
- solidworks_mcp/setup/registration.py +193 -0
- solidworks_mcp/setup/solidworks.py +49 -0
- solidworks_mcp/tools/__init__.py +64 -0
- solidworks_mcp/tools/analysis.py +723 -0
- solidworks_mcp/tools/automation.py +578 -0
- solidworks_mcp/tools/docs_discovery.py +1476 -0
- solidworks_mcp/tools/drawing.py +1015 -0
- solidworks_mcp/tools/drawing_analysis.py +456 -0
- solidworks_mcp/tools/drawing_automation.py +627 -0
- solidworks_mcp/tools/engineering_data.py +466 -0
- solidworks_mcp/tools/error_classifier.py +232 -0
- solidworks_mcp/tools/export.py +833 -0
- solidworks_mcp/tools/file_facts.py +40 -0
- solidworks_mcp/tools/file_management.py +1473 -0
- solidworks_mcp/tools/input_compat.py +17 -0
- solidworks_mcp/tools/macro_recording.py +464 -0
- solidworks_mcp/tools/modeling.py +2360 -0
- solidworks_mcp/tools/sketching.py +1422 -0
- solidworks_mcp/tools/template_management.py +367 -0
- solidworks_mcp/tools/tool_boundary.py +280 -0
- solidworks_mcp/tools/vba_generation.py +1145 -0
- solidworks_mcp/ui/__init__.py +9 -0
- solidworks_mcp/ui/local_llm.py +725 -0
- solidworks_mcp/ui/routers/__init__.py +10 -0
- solidworks_mcp/ui/routers/checkpoint.py +24 -0
- solidworks_mcp/ui/routers/docs.py +47 -0
- solidworks_mcp/ui/routers/llm.py +83 -0
- solidworks_mcp/ui/routers/local_model.py +69 -0
- solidworks_mcp/ui/routers/model.py +75 -0
- solidworks_mcp/ui/routers/preview.py +38 -0
- solidworks_mcp/ui/routers/session.py +161 -0
- solidworks_mcp/ui/routers/viewer.py +154 -0
- solidworks_mcp/ui/schemas.py +502 -0
- solidworks_mcp/ui/server.py +298 -0
- solidworks_mcp/ui/service.py +481 -0
- solidworks_mcp/ui/services/__init__.py +136 -0
- solidworks_mcp/ui/services/_utils.py +958 -0
- solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
- solidworks_mcp/ui/services/docs_service.py +282 -0
- solidworks_mcp/ui/services/llm_service.py +956 -0
- solidworks_mcp/ui/services/model_service.py +459 -0
- solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
- solidworks_mcp/ui/services/preview_service.py +489 -0
- solidworks_mcp/ui/services/session_service.py +1389 -0
- solidworks_mcp/utils/__init__.py +9 -0
- solidworks_mcp/utils/feature_tree_classifier.py +342 -0
- solidworks_mcp/utils/logging.py +76 -0
- solidworks_mcp/utils/validation.py +77 -0
- solidworks_mcp/version.py +8 -0
- solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
- solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
- solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
- solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
- 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)
|