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,3649 @@
|
|
|
1
|
+
"""Model I/O mixin for PyWin32 SolidWorks operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
from .. import sw_type_info as _sw_type_info
|
|
16
|
+
from ..base import AdapterResult, AdapterResultStatus, MassProperties, SolidWorksModel
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import pythoncom
|
|
20
|
+
import win32com.client
|
|
21
|
+
import win32com.client.dynamic as _dynamic
|
|
22
|
+
except ImportError:
|
|
23
|
+
pythoncom = SimpleNamespace()
|
|
24
|
+
win32com = SimpleNamespace(client=SimpleNamespace())
|
|
25
|
+
_dynamic = SimpleNamespace(Dispatch=lambda *_a, **_kw: None)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import comtypes # type: ignore[import-untyped]
|
|
29
|
+
import comtypes.client as _comtypes_client # type: ignore[import-untyped]
|
|
30
|
+
|
|
31
|
+
_COMTYPES_AVAILABLE = True
|
|
32
|
+
except ImportError:
|
|
33
|
+
_COMTYPES_AVAILABLE = False
|
|
34
|
+
|
|
35
|
+
# SolidWorks type library GUID (stable across SW versions)
|
|
36
|
+
_SW_TLB_GUID = "{83A33D31-27C5-11CE-BFD4-00400513BB57}"
|
|
37
|
+
# Cached comtypes wrapper module (loaded once per process)
|
|
38
|
+
_sw_comtypes_lib: Any = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_sw_comtypes_lib() -> Any:
|
|
42
|
+
"""Return the comtypes wrapper for the SolidWorks type library.
|
|
43
|
+
|
|
44
|
+
Tries SW version numbers 35..30 (newest to oldest). Returns ``None`` when
|
|
45
|
+
comtypes is unavailable or the TLB cannot be found in the registry.
|
|
46
|
+
"""
|
|
47
|
+
global _sw_comtypes_lib
|
|
48
|
+
if _sw_comtypes_lib is not None:
|
|
49
|
+
return _sw_comtypes_lib
|
|
50
|
+
if not _COMTYPES_AVAILABLE:
|
|
51
|
+
return None
|
|
52
|
+
for major in (35, 34, 33, 32, 31, 30):
|
|
53
|
+
try:
|
|
54
|
+
_sw_comtypes_lib = _comtypes_client.GetModule(
|
|
55
|
+
(comtypes.GUID(_SW_TLB_GUID), major, 0)
|
|
56
|
+
)
|
|
57
|
+
return _sw_comtypes_lib
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bridge_com_to_comtypes(pywin32_obj: Any, iface: Any) -> Any:
|
|
64
|
+
"""Bridge a pywin32 CDispatch/COM object to a comtypes interface pointer.
|
|
65
|
+
|
|
66
|
+
Extracts the raw IUnknown pointer from pywin32's repr string, then
|
|
67
|
+
QueryInterface-es for *iface* via comtypes. The AddRef keeps the object
|
|
68
|
+
alive through the Python reference.
|
|
69
|
+
"""
|
|
70
|
+
unk = pywin32_obj._oleobj_.QueryInterface(pythoncom.IID_IUnknown)
|
|
71
|
+
m = re.search(r"obj at (0x[0-9a-fA-F]+)", repr(unk))
|
|
72
|
+
if not m:
|
|
73
|
+
raise RuntimeError(f"Could not extract COM pointer from {repr(unk)!r}")
|
|
74
|
+
ptr_int = int(m.group(1), 16)
|
|
75
|
+
ct_unk = ctypes.cast(ptr_int, ctypes.POINTER(comtypes.IUnknown))
|
|
76
|
+
ct_unk.AddRef()
|
|
77
|
+
return ct_unk.QueryInterface(iface)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_MATE_TYPES: dict[str, int] = {
|
|
81
|
+
"coincident": 0,
|
|
82
|
+
"concentric": 1,
|
|
83
|
+
"perpendicular": 2,
|
|
84
|
+
"parallel": 3,
|
|
85
|
+
"tangent": 4,
|
|
86
|
+
"distance": 5,
|
|
87
|
+
"angle": 6,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_MATE_ALIGNMENTS: dict[str, int] = {
|
|
92
|
+
"aligned": 0,
|
|
93
|
+
"anti_aligned": 1,
|
|
94
|
+
"closest": 2,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
#: ``swDwgTemplates_e`` / ``swDwgPaperSizes_e`` values (read from swconst.tlb).
|
|
99
|
+
_SW_DWG_TEMPLATE_NONE = 13
|
|
100
|
+
_SW_DWG_PAPER_A_SIZE = 0
|
|
101
|
+
_SW_DWG_PAPERS_USER_DEFINED = 12
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: SolidWorks named views. The leading asterisk is part of the name and
|
|
105
|
+
#: ``CreateDrawViewFromModelView3`` rejects the name without it.
|
|
106
|
+
_NAMED_VIEWS: dict[str, str] = {
|
|
107
|
+
"front": "*Front",
|
|
108
|
+
"back": "*Back",
|
|
109
|
+
"left": "*Left",
|
|
110
|
+
"right": "*Right",
|
|
111
|
+
"top": "*Top",
|
|
112
|
+
"bottom": "*Bottom",
|
|
113
|
+
"isometric": "*Isometric",
|
|
114
|
+
"iso": "*Isometric",
|
|
115
|
+
"trimetric": "*Trimetric",
|
|
116
|
+
"dimetric": "*Dimetric",
|
|
117
|
+
"current": "*Current",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
#: Points to millimetres. Note schemas express text height in points.
|
|
122
|
+
_POINTS_TO_MM = 25.4 / 72.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _payload(data: Any) -> dict[str, Any]:
|
|
126
|
+
"""Coerce a tool payload into a plain dict.
|
|
127
|
+
|
|
128
|
+
The drawing tools hand the adapter either a ``model_dump()`` result or a
|
|
129
|
+
raw dict, depending on the tool. Accept a Pydantic model too, so a direct
|
|
130
|
+
adapter call from a test or script behaves the same as one through a tool.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
data: A dict, a Pydantic model, or an object with attributes.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
dict[str, Any]: The payload as a dict; empty when nothing was given.
|
|
137
|
+
"""
|
|
138
|
+
if data is None:
|
|
139
|
+
return {}
|
|
140
|
+
if isinstance(data, dict):
|
|
141
|
+
return data
|
|
142
|
+
dump = getattr(data, "model_dump", None)
|
|
143
|
+
if callable(dump):
|
|
144
|
+
return cast("dict[str, Any]", dump())
|
|
145
|
+
return {key: value for key, value in vars(data).items() if not key.startswith("_")}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _first(payload: dict[str, Any], *keys: str, default: Any = None) -> Any:
|
|
149
|
+
"""Return the first key present and not None.
|
|
150
|
+
|
|
151
|
+
The drawing tools disagree on field names for the same thing — a model is
|
|
152
|
+
``model_path`` on one schema and ``model_file`` on another — so each
|
|
153
|
+
adapter method accepts every spelling its callers use.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
payload: The tool payload.
|
|
157
|
+
*keys: Candidate key names, in priority order.
|
|
158
|
+
default: Returned when no key is present.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Any: The first value found, otherwise ``default``.
|
|
162
|
+
"""
|
|
163
|
+
for key in keys:
|
|
164
|
+
value = payload.get(key)
|
|
165
|
+
if value is not None:
|
|
166
|
+
return value
|
|
167
|
+
return default
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _view_names(adapter: Any, drawing: Any) -> list[str]:
|
|
171
|
+
"""Return the drawing's view names, excluding sheet formats.
|
|
172
|
+
|
|
173
|
+
``CreateDrawViewFromModelView3`` returns ``None`` for a model SolidWorks
|
|
174
|
+
could not resolve, and ``Create3rdAngleViews2`` returns a bare boolean, so
|
|
175
|
+
the view list is the ground truth for whether views were really added.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
179
|
+
drawing: The drawing document, flagged for ``IDrawingDoc``.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
list[str]: View names in sheet order.
|
|
183
|
+
"""
|
|
184
|
+
sheets = adapter._attempt(lambda: drawing.GetViews(), default=None)
|
|
185
|
+
if not isinstance(sheets, (list, tuple)):
|
|
186
|
+
return []
|
|
187
|
+
|
|
188
|
+
names: list[str] = []
|
|
189
|
+
for sheet in sheets:
|
|
190
|
+
views = sheet if isinstance(sheet, (list, tuple)) else [sheet]
|
|
191
|
+
for index, view in enumerate(views):
|
|
192
|
+
# GetViews returns (sheet, view, view, ...) per sheet; entry 0 is
|
|
193
|
+
# the sheet itself, not a drawing view.
|
|
194
|
+
if index == 0 and isinstance(sheet, (list, tuple)):
|
|
195
|
+
continue
|
|
196
|
+
wrapped = _as_com(adapter, view, "IView")
|
|
197
|
+
if wrapped is None:
|
|
198
|
+
continue
|
|
199
|
+
name = adapter._attempt(lambda w=wrapped: w.GetName2(), default=None)
|
|
200
|
+
if name:
|
|
201
|
+
names.append(str(name))
|
|
202
|
+
return names
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _component_transforms(adapter: Any, assembly: Any) -> dict[str, tuple[float, ...]]:
|
|
206
|
+
"""Snapshot every component's placement, for before/after comparison.
|
|
207
|
+
|
|
208
|
+
``IComponent2.Transform2`` is the component's position relative to the
|
|
209
|
+
assembly root, as a ``MathTransform`` whose ``ArrayData`` is 16 doubles
|
|
210
|
+
(a 3x3 rotation, a translation, and a scale). Comparing the snapshot
|
|
211
|
+
before and after a mate is what distinguishes a mate that actually
|
|
212
|
+
positioned geometry from one that SolidWorks accepted and ignored.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
216
|
+
assembly: The ``IAssemblyDoc`` dispatch to snapshot.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
dict[str, tuple[float, ...]]: Component name to transform matrix.
|
|
220
|
+
Components whose transform cannot be read are omitted rather than
|
|
221
|
+
recorded as unchanged — an unreadable transform is not evidence of
|
|
222
|
+
anything, and treating it as "same" would fake a passing comparison.
|
|
223
|
+
"""
|
|
224
|
+
snapshot: dict[str, tuple[float, ...]] = {}
|
|
225
|
+
for name, component in _component_pairs(adapter, assembly):
|
|
226
|
+
if component is None:
|
|
227
|
+
continue
|
|
228
|
+
transform = adapter._attempt(lambda c=component: c.Transform2, default=None)
|
|
229
|
+
if transform is None:
|
|
230
|
+
continue
|
|
231
|
+
data = adapter._attempt(lambda t=transform: t.ArrayData, default=None)
|
|
232
|
+
if not isinstance(data, (list, tuple)):
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
snapshot[name] = tuple(round(float(value), 9) for value in data)
|
|
236
|
+
except (TypeError, ValueError):
|
|
237
|
+
continue
|
|
238
|
+
return snapshot
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class _ByrefFallback:
|
|
242
|
+
"""Stand-in for a byref VARIANT when pywin32 is unavailable.
|
|
243
|
+
|
|
244
|
+
The callers' contract for a byref holder is "an object whose ``.value``
|
|
245
|
+
the COM call fills in". Returning a bare ``0`` or ``""`` broke that: on any
|
|
246
|
+
machine without pywin32 (Linux CI, mock runs) ``_read_material_name`` could
|
|
247
|
+
never read its database out-parameter back, so it silently reported
|
|
248
|
+
``None``. This keeps the contract on both platforms.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
__slots__ = ("value",)
|
|
252
|
+
|
|
253
|
+
def __init__(self, value: Any) -> None:
|
|
254
|
+
self.value = value
|
|
255
|
+
|
|
256
|
+
def __eq__(self, other: Any) -> bool:
|
|
257
|
+
"""Compare equal to the seeded value, so callers can treat it as one."""
|
|
258
|
+
if isinstance(other, _ByrefFallback):
|
|
259
|
+
return bool(self.value == other.value)
|
|
260
|
+
return bool(self.value == other)
|
|
261
|
+
|
|
262
|
+
def __hash__(self) -> int:
|
|
263
|
+
"""Hash as the seeded value."""
|
|
264
|
+
return hash(self.value)
|
|
265
|
+
|
|
266
|
+
def __bool__(self) -> bool:
|
|
267
|
+
"""Truthiness follows the seeded value."""
|
|
268
|
+
return bool(self.value)
|
|
269
|
+
|
|
270
|
+
def __repr__(self) -> str:
|
|
271
|
+
"""Show the seeded value for readable assertion output."""
|
|
272
|
+
return f"_ByrefFallback({self.value!r})"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _byref_bstr() -> Any:
|
|
276
|
+
"""Return a byref string VARIANT for a SolidWorks ``String`` out-parameter.
|
|
277
|
+
|
|
278
|
+
Under forced late binding an omitted out-parameter raises "Parameter not
|
|
279
|
+
optional", and ``pythoncom.Missing`` cannot be converted to a COM VARIANT,
|
|
280
|
+
so pass ``VARIANT(VT_BYREF | VT_BSTR, "")`` and read ``.value`` afterwards
|
|
281
|
+
(e.g. ``IPartDoc::GetMaterialPropertyName2``'s ``Database``; confirmed live
|
|
282
|
+
on R2026x).
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
Any: The byref VARIANT, or a ``_ByrefFallback("")`` without pywin32.
|
|
286
|
+
"""
|
|
287
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
288
|
+
if not callable(variant_ctor):
|
|
289
|
+
return _ByrefFallback("")
|
|
290
|
+
return variant_ctor(
|
|
291
|
+
int(getattr(pythoncom, "VT_BYREF", 0)) | int(getattr(pythoncom, "VT_BSTR", 0)),
|
|
292
|
+
"",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _byref_int() -> Any:
|
|
297
|
+
"""Return a byref long VARIANT for a SolidWorks out-parameter.
|
|
298
|
+
|
|
299
|
+
See :func:`_byref_bstr` for why ``pythoncom.Missing`` does not work.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Any: A ``VARIANT(VT_BYREF | VT_I4, 0)``, or ``0`` when pywin32 is
|
|
303
|
+
unavailable (test/mock environments).
|
|
304
|
+
"""
|
|
305
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
306
|
+
if not callable(variant_ctor):
|
|
307
|
+
return _ByrefFallback(0)
|
|
308
|
+
return variant_ctor(
|
|
309
|
+
int(getattr(pythoncom, "VT_BYREF", 0)) | int(getattr(pythoncom, "VT_I4", 0)), 0
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _byref_variant() -> Any:
|
|
314
|
+
"""Return a byref VARIANT for an array/variant out-parameter.
|
|
315
|
+
|
|
316
|
+
``IModelDocExtension::GetWhatsWrong`` fills three of these (features,
|
|
317
|
+
error codes, warning flags); confirmed live on R2026x that late binding
|
|
318
|
+
accepts ``VARIANT(VT_BYREF | VT_VARIANT, None)`` and exposes the result on
|
|
319
|
+
``.value``. ``pythoncom.Missing`` is rejected ("can not be converted to a
|
|
320
|
+
COM VARIANT").
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Any: The byref VARIANT, or a ``_ByrefFallback(None)`` without pywin32.
|
|
324
|
+
"""
|
|
325
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
326
|
+
if not callable(variant_ctor):
|
|
327
|
+
return _ByrefFallback(None)
|
|
328
|
+
return variant_ctor(
|
|
329
|
+
int(getattr(pythoncom, "VT_BYREF", 0))
|
|
330
|
+
| int(getattr(pythoncom, "VT_VARIANT", 0)),
|
|
331
|
+
None,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _byref_bool() -> Any:
|
|
336
|
+
"""Return a byref bool VARIANT (e.g. ``IFeature::GetErrorCode2(IsWarning)``).
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Any: The byref VARIANT, or a ``_ByrefFallback(False)`` without pywin32.
|
|
340
|
+
"""
|
|
341
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
342
|
+
if not callable(variant_ctor):
|
|
343
|
+
return _ByrefFallback(False)
|
|
344
|
+
return variant_ctor(
|
|
345
|
+
int(getattr(pythoncom, "VT_BYREF", 0)) | int(getattr(pythoncom, "VT_BOOL", 0)),
|
|
346
|
+
False,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _byref_double() -> Any:
|
|
351
|
+
"""Return a byref double VARIANT for a SolidWorks ``Double`` out-parameter.
|
|
352
|
+
|
|
353
|
+
``IBody2::GetExtremePoint`` reports its point through three of these. See
|
|
354
|
+
:func:`_byref_bstr` for why ``pythoncom.Missing`` does not work.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
Any: A ``VARIANT(VT_BYREF | VT_R8, 0.0)``, or a ``_ByrefFallback(0.0)``
|
|
358
|
+
without pywin32.
|
|
359
|
+
"""
|
|
360
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
361
|
+
if not callable(variant_ctor):
|
|
362
|
+
return _ByrefFallback(0.0)
|
|
363
|
+
return variant_ctor(
|
|
364
|
+
int(getattr(pythoncom, "VT_BYREF", 0)) | int(getattr(pythoncom, "VT_R8", 0)),
|
|
365
|
+
0.0,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _doc_type(adapter: Any) -> int | None:
|
|
370
|
+
"""Return the active document's type: 1 part, 2 assembly, 3 drawing.
|
|
371
|
+
|
|
372
|
+
``GetType`` is one of the members pywin32 late binding may expose as either
|
|
373
|
+
a bound method or a plain value, so calling it directly returns ``None`` on
|
|
374
|
+
some documents. ``_get_attr_or_call`` handles both shapes.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
int | None: The document type, or ``None`` when it cannot be read.
|
|
381
|
+
"""
|
|
382
|
+
value = adapter._attempt(
|
|
383
|
+
lambda: adapter._get_attr_or_call(adapter.currentModel, "GetType"),
|
|
384
|
+
default=None,
|
|
385
|
+
)
|
|
386
|
+
return int(value) if isinstance(value, (int, float)) else None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _payload_dict(data: Any) -> dict[str, Any]:
|
|
390
|
+
"""Coerce a tool payload into a plain dict.
|
|
391
|
+
|
|
392
|
+
``tools/analysis.py`` hands the adapter ``input_data.model_dump()``, but a
|
|
393
|
+
direct call from a test or script may pass the model itself or nothing.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
data: A dict, a Pydantic model, or ``None``.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
dict[str, Any]: The payload as a dict; empty when nothing was given.
|
|
400
|
+
"""
|
|
401
|
+
if data is None:
|
|
402
|
+
return {}
|
|
403
|
+
if isinstance(data, dict):
|
|
404
|
+
return data
|
|
405
|
+
dump = getattr(data, "model_dump", None)
|
|
406
|
+
if callable(dump):
|
|
407
|
+
return cast("dict[str, Any]", dump())
|
|
408
|
+
return {key: value for key, value in vars(data).items() if not key.startswith("_")}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _interference_details(adapter: Any, interferences: Any) -> list[dict[str, Any]]:
|
|
412
|
+
"""Describe each interference: the components involved and the overlap.
|
|
413
|
+
|
|
414
|
+
``IInterference::Volume`` is in cubic metres and is converted to mm^3 to
|
|
415
|
+
match every other volume this adapter reports.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
419
|
+
interferences: The sequence returned by ``GetInterferences``.
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
list[dict[str, Any]]: One entry per interference. Empty when the
|
|
423
|
+
sequence is absent, which is what SolidWorks returns for a clean
|
|
424
|
+
assembly.
|
|
425
|
+
"""
|
|
426
|
+
if not isinstance(interferences, (list, tuple)):
|
|
427
|
+
return []
|
|
428
|
+
|
|
429
|
+
details: list[dict[str, Any]] = []
|
|
430
|
+
for item in interferences:
|
|
431
|
+
wrapped = _as_com(adapter, item, "IInterference")
|
|
432
|
+
if wrapped is None:
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
volume_m3 = adapter._attempt(lambda w=wrapped: w.Volume, default=None)
|
|
436
|
+
try:
|
|
437
|
+
volume_mm3 = (
|
|
438
|
+
round(float(volume_m3) * 1e9, 6) if volume_m3 is not None else None
|
|
439
|
+
)
|
|
440
|
+
except (TypeError, ValueError):
|
|
441
|
+
volume_mm3 = None
|
|
442
|
+
|
|
443
|
+
component_count = adapter._attempt(
|
|
444
|
+
lambda w=wrapped: w.GetComponentCount(), default=None
|
|
445
|
+
)
|
|
446
|
+
# ``Components`` is a property, not a method. ``GetComponents()``
|
|
447
|
+
# does not exist on IInterference and ``IGetComponents(n)`` rejects
|
|
448
|
+
# its own documented argument with "Invalid number of parameters".
|
|
449
|
+
raw_components = adapter._attempt(lambda w=wrapped: w.Components, default=None)
|
|
450
|
+
|
|
451
|
+
names: list[str] = []
|
|
452
|
+
if isinstance(raw_components, (list, tuple)):
|
|
453
|
+
for component in raw_components:
|
|
454
|
+
comp = _as_com(adapter, component, "IComponent2")
|
|
455
|
+
if comp is None:
|
|
456
|
+
continue
|
|
457
|
+
name = adapter._attempt(lambda c=comp: c.Name2, default=None)
|
|
458
|
+
names.append(str(name) if name else "<unnamed>")
|
|
459
|
+
|
|
460
|
+
details.append(
|
|
461
|
+
{
|
|
462
|
+
"components": names,
|
|
463
|
+
"component_count": int(component_count)
|
|
464
|
+
if component_count
|
|
465
|
+
else len(names),
|
|
466
|
+
"volume_mm3": volume_mm3,
|
|
467
|
+
}
|
|
468
|
+
)
|
|
469
|
+
return details
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _component_pairs(adapter: Any, assembly: Any) -> list[tuple[str, Any]]:
|
|
473
|
+
"""Return an assembly's top-level components as ``(name, dispatch)``.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
477
|
+
assembly: The assembly document, flagged for ``IAssemblyDoc``.
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
list[tuple[str, Any]]: Component name and its ``IComponent2``
|
|
481
|
+
dispatch. A component whose dispatch cannot be wrapped is reported
|
|
482
|
+
as ``("<unnamed>", None)`` so callers still see the correct count.
|
|
483
|
+
"""
|
|
484
|
+
components = adapter._attempt(lambda: assembly.GetComponents(True), default=None)
|
|
485
|
+
if not isinstance(components, (list, tuple)):
|
|
486
|
+
return []
|
|
487
|
+
|
|
488
|
+
pairs: list[tuple[str, Any]] = []
|
|
489
|
+
for component in components:
|
|
490
|
+
wrapped = _as_com(adapter, component, "IComponent2")
|
|
491
|
+
if wrapped is None:
|
|
492
|
+
pairs.append(("<unnamed>", None))
|
|
493
|
+
continue
|
|
494
|
+
name = adapter._attempt(lambda c=wrapped: c.Name2, default=None)
|
|
495
|
+
if not name:
|
|
496
|
+
name = adapter._attempt(lambda c=wrapped: c.GetPathName(), default=None)
|
|
497
|
+
pairs.append((str(name) if name else "<unnamed>", wrapped))
|
|
498
|
+
return pairs
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _component_names(adapter: Any, assembly: Any) -> list[str]:
|
|
502
|
+
"""Return the names of an assembly's top-level components.
|
|
503
|
+
|
|
504
|
+
``AddComponent*`` can return an object having added nothing, so the
|
|
505
|
+
component list is the ground truth for whether an insert worked.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
509
|
+
assembly: The assembly document, flagged for ``IAssemblyDoc``.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
list[str]: Component names, empty when the assembly holds none.
|
|
513
|
+
"""
|
|
514
|
+
return [name for name, _ in _component_pairs(adapter, assembly)]
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _as_com(adapter: Any, obj: Any, interface: str) -> Any:
|
|
518
|
+
"""Wrap a raw dispatch and flag its methods for an interface.
|
|
519
|
+
|
|
520
|
+
Objects handed back inside arrays (``GetViews`` and friends) arrive as raw
|
|
521
|
+
``PyIDispatch``. Method flagging is a no-op on those, so every call
|
|
522
|
+
against them raises until they are wrapped through ``dynamic.Dispatch``.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
526
|
+
obj: The raw dispatch.
|
|
527
|
+
interface: Interface name, e.g. ``"IView"``.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Any: The wrapped, flagged object, or ``None``.
|
|
531
|
+
"""
|
|
532
|
+
wrapped = adapter._attempt(lambda: _dynamic.Dispatch(obj), default=None)
|
|
533
|
+
if wrapped is None:
|
|
534
|
+
return None
|
|
535
|
+
adapter._attempt(
|
|
536
|
+
lambda: _sw_type_info.flag_methods(wrapped, interface), default=None
|
|
537
|
+
)
|
|
538
|
+
return wrapped
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --- Document unit system -------------------------------------------------
|
|
542
|
+
# swUserPreferenceIntegerValue_e - values transcribed from swconst.tlb on a
|
|
543
|
+
# live SW 2026 (3DEXPERIENCE R2026x) install; the published API help does not
|
|
544
|
+
# print the integers and they are NOT the "obvious" small numbers.
|
|
545
|
+
_SW_PREF_UNIT_SYSTEM = 263
|
|
546
|
+
_SW_PREF_UNITS_LINEAR = 47
|
|
547
|
+
_SW_PREF_UNITS_LINEAR_DECIMALS = 49
|
|
548
|
+
# swUserPreferenceOption_e.swDetailingNoOptionSpecified
|
|
549
|
+
_SW_PREF_OPTION_NONE = 0
|
|
550
|
+
# swUnitSystem_e.swUnitSystem_Custom
|
|
551
|
+
_SW_UNIT_SYSTEM_CUSTOM = 4
|
|
552
|
+
|
|
553
|
+
# unit-system token -> (swUnitSystem_e, swLengthUnit_e)
|
|
554
|
+
# swUnitSystem_e: CGS=1 MKS=2 IPS=3 Custom=4 MMGS=5
|
|
555
|
+
# swLengthUnit_e: swMM=0 swCM=1 swMETER=2 swINCHES=3 swFEET=4
|
|
556
|
+
_SW_UNIT_SYSTEMS: dict[str, tuple[int, int]] = {
|
|
557
|
+
"mm": (5, 0),
|
|
558
|
+
"cm": (1, 1),
|
|
559
|
+
"m": (2, 2),
|
|
560
|
+
"in": (3, 3),
|
|
561
|
+
"ft": (4, 4),
|
|
562
|
+
}
|
|
563
|
+
# Accepted aliases normalised to the canonical token above.
|
|
564
|
+
_SW_UNIT_ALIASES: dict[str, str] = {
|
|
565
|
+
"mm": "mm",
|
|
566
|
+
"millimeter": "mm",
|
|
567
|
+
"millimeters": "mm",
|
|
568
|
+
"millimetre": "mm",
|
|
569
|
+
"millimetres": "mm",
|
|
570
|
+
"cm": "cm",
|
|
571
|
+
"centimeter": "cm",
|
|
572
|
+
"centimeters": "cm",
|
|
573
|
+
"m": "m",
|
|
574
|
+
"meter": "m",
|
|
575
|
+
"meters": "m",
|
|
576
|
+
"metre": "m",
|
|
577
|
+
"metres": "m",
|
|
578
|
+
"in": "in",
|
|
579
|
+
"inch": "in",
|
|
580
|
+
"inches": "in",
|
|
581
|
+
'"': "in",
|
|
582
|
+
"ft": "ft",
|
|
583
|
+
"foot": "ft",
|
|
584
|
+
"feet": "ft",
|
|
585
|
+
"'": "ft",
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _normalise_unit_system(unit_system: str) -> str | None:
|
|
590
|
+
"""Map a user-supplied unit token onto a canonical key of ``_SW_UNIT_SYSTEMS``.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
unit_system: e.g. ``"mm"``, ``"inch"``, ``"Millimeters"``.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
str | None: ``"mm" | "cm" | "m" | "in" | "ft"``, or ``None`` when
|
|
597
|
+
the token is not recognised.
|
|
598
|
+
"""
|
|
599
|
+
return _SW_UNIT_ALIASES.get((unit_system or "").strip().lower())
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _pref_int(adapter: Any, ext: Any, pref: int) -> int | None:
|
|
603
|
+
"""Read one ``IModelDocExtension`` integer user preference.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
adapter: A connected adapter (for ``_attempt``).
|
|
607
|
+
ext: The document's ``IModelDocExtension`` dispatch.
|
|
608
|
+
pref: A ``swUserPreferenceIntegerValue_e`` value.
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
int | None: The preference value, or ``None`` when the COM call
|
|
612
|
+
fails or returns a non-numeric result.
|
|
613
|
+
"""
|
|
614
|
+
value = adapter._attempt(
|
|
615
|
+
lambda: ext.GetUserPreferenceInteger(pref, _SW_PREF_OPTION_NONE),
|
|
616
|
+
default=None,
|
|
617
|
+
)
|
|
618
|
+
return int(value) if isinstance(value, (int, float)) else None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _apply_unit_system(adapter: Any, model: Any, unit_system: str) -> dict[str, Any]:
|
|
622
|
+
"""Set a document's linear unit system and confirm it actually took.
|
|
623
|
+
|
|
624
|
+
``IModelDocExtension::SetUserPreferenceInteger`` accepts the
|
|
625
|
+
``swUnitSystem`` write and returns quietly even on builds where the
|
|
626
|
+
document's unit system does not move (observed on SW 2026 / 3DEXPERIENCE
|
|
627
|
+
against an assembly). The change is therefore verified by reading the
|
|
628
|
+
*unit-system preference itself* (``swUnitSystem`` = slot 263) back after
|
|
629
|
+
a rebuild - not the ``swUnitsLinear`` slot, which echoes whatever was
|
|
630
|
+
written regardless of whether the document honoured it.
|
|
631
|
+
|
|
632
|
+
For the ``mm`` / ``cm`` / ``m`` / ``in`` presets only ``swUnitSystem`` is
|
|
633
|
+
set and the preset drives the linear unit. ``ft`` has no preset, so it is
|
|
634
|
+
applied as ``swUnitSystem_Custom`` plus an explicit ``swUnitsLinear``.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
adapter: A connected adapter.
|
|
638
|
+
model: The ``IModelDoc2`` to modify.
|
|
639
|
+
unit_system: Already-normalised token (key of ``_SW_UNIT_SYSTEMS``).
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
dict[str, Any]: ``unit_system``; ``applied_unit_system`` /
|
|
643
|
+
``applied_length_unit`` (the swUnitSystem_e / swLengthUnit_e targets);
|
|
644
|
+
``observed_unit_system`` / ``observed_length_unit`` (what the document
|
|
645
|
+
reports afterwards); ``set_call_ok`` (bool the COM call returned);
|
|
646
|
+
and ``verified`` (``True`` only when the document's own unit-system
|
|
647
|
+
preference now equals the target; ``None`` when it could not be read).
|
|
648
|
+
|
|
649
|
+
Raises:
|
|
650
|
+
Exception: When the extension is unavailable, or the readback shows
|
|
651
|
+
the document did not adopt the requested unit system.
|
|
652
|
+
"""
|
|
653
|
+
system_value, length_value = _SW_UNIT_SYSTEMS[unit_system]
|
|
654
|
+
is_custom = system_value == _SW_UNIT_SYSTEM_CUSTOM
|
|
655
|
+
ext = adapter._attempt(lambda: model.Extension, default=None)
|
|
656
|
+
if ext is None:
|
|
657
|
+
raise Exception("Document has no Extension; cannot set units")
|
|
658
|
+
|
|
659
|
+
before_system = _pref_int(adapter, ext, _SW_PREF_UNIT_SYSTEM)
|
|
660
|
+
before_linear = _pref_int(adapter, ext, _SW_PREF_UNITS_LINEAR)
|
|
661
|
+
|
|
662
|
+
set_system_ok = adapter._attempt(
|
|
663
|
+
lambda: ext.SetUserPreferenceInteger(
|
|
664
|
+
_SW_PREF_UNIT_SYSTEM, _SW_PREF_OPTION_NONE, system_value
|
|
665
|
+
),
|
|
666
|
+
default=None,
|
|
667
|
+
)
|
|
668
|
+
set_linear_ok: Any = None
|
|
669
|
+
if is_custom:
|
|
670
|
+
set_linear_ok = adapter._attempt(
|
|
671
|
+
lambda: ext.SetUserPreferenceInteger(
|
|
672
|
+
_SW_PREF_UNITS_LINEAR, _SW_PREF_OPTION_NONE, length_value
|
|
673
|
+
),
|
|
674
|
+
default=None,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Nudge SolidWorks to apply and to flag the document dirty.
|
|
678
|
+
adapter._attempt(lambda: model.EditRebuild3(), default=None)
|
|
679
|
+
adapter._attempt(lambda: model.GraphicsRedraw2(), default=None)
|
|
680
|
+
adapter._attempt(lambda: model.SetSaveFlag(), default=None)
|
|
681
|
+
|
|
682
|
+
after_system = _pref_int(adapter, ext, _SW_PREF_UNIT_SYSTEM)
|
|
683
|
+
after_linear = _pref_int(adapter, ext, _SW_PREF_UNITS_LINEAR)
|
|
684
|
+
|
|
685
|
+
if after_system is None:
|
|
686
|
+
verified: bool | None = None
|
|
687
|
+
else:
|
|
688
|
+
verified = after_system == system_value
|
|
689
|
+
if is_custom and verified:
|
|
690
|
+
verified = after_linear == length_value
|
|
691
|
+
|
|
692
|
+
result = {
|
|
693
|
+
"unit_system": unit_system,
|
|
694
|
+
"applied_unit_system": system_value,
|
|
695
|
+
"applied_length_unit": length_value,
|
|
696
|
+
"observed_unit_system": after_system,
|
|
697
|
+
"observed_length_unit": after_linear,
|
|
698
|
+
"unit_system_before": before_system,
|
|
699
|
+
"length_unit_before": before_linear,
|
|
700
|
+
"set_call_ok": bool(set_system_ok) if set_system_ok is not None else None,
|
|
701
|
+
"set_linear_call_ok": (
|
|
702
|
+
bool(set_linear_ok) if set_linear_ok is not None else None
|
|
703
|
+
),
|
|
704
|
+
"verified": verified,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if verified is False:
|
|
708
|
+
raise Exception(
|
|
709
|
+
f"Unit system '{unit_system}' (swUnitSystem={system_value}) was not "
|
|
710
|
+
f"adopted: SetUserPreferenceInteger returned {result['set_call_ok']!r}, "
|
|
711
|
+
f"and the document still reports swUnitSystem={after_system}, "
|
|
712
|
+
f"swUnitsLinear={after_linear} (was {before_system}/{before_linear})."
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
return result
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# --- Open-document enumeration ------------------------------------------
|
|
719
|
+
_DOC_TYPE_NAMES: dict[int, str] = {1: "Part", 2: "Assembly", 3: "Drawing"}
|
|
720
|
+
|
|
721
|
+
# --- Multibody / drawing detailing -------------------------------------
|
|
722
|
+
# swBodyType_e.swSolidBody
|
|
723
|
+
_SW_SOLID_BODY = 0
|
|
724
|
+
# swAutoInsertCenterMarkTypes_e - bitmask for IView::AutoInsertCenterMarks2
|
|
725
|
+
_SW_CM_TYPE_HOLE = 1
|
|
726
|
+
_SW_CM_TYPE_FILLET = 2
|
|
727
|
+
_SW_CM_TYPE_SLOT = 4
|
|
728
|
+
# swCenterMarkStyle_e.swCenterMark_Single
|
|
729
|
+
_SW_CM_STYLE_SINGLE = 2
|
|
730
|
+
|
|
731
|
+
# swAnnotationType_e.swCenterMarkSym — annotation type value for centre-mark
|
|
732
|
+
# annotations auto-inserted by IView::AutoInsertCenterMarks2. There is no
|
|
733
|
+
# member literally named "swAnnotationCenterMark" in swAnnotationType_e; that
|
|
734
|
+
# name was a guess and 17 is actually swWeldBeadSymbol, which would have
|
|
735
|
+
# reproduced the 0/0/0 bug on drawings with no weld beads (and silently
|
|
736
|
+
# miscounted weld bead symbols as centre marks on drawings that have any).
|
|
737
|
+
# Value confirmed against the published swconst enum (swAnnotationType_e):
|
|
738
|
+
# https://help.solidworks.com/2026/english/api/swconst/SolidWorks.Interop.swconst~SolidWorks.Interop.swconst.swAnnotationType_e.html
|
|
739
|
+
_SW_ANNOTATION_CENTER_MARK = 13
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _variant_array(element_vt: int, values: Any) -> Any:
|
|
743
|
+
"""Wrap a Python sequence as a SAFEARRAY VARIANT for a SolidWorks call.
|
|
744
|
+
|
|
745
|
+
pywin32 late binding otherwise unpacks a bare list into positional
|
|
746
|
+
arguments, so SolidWorks array parameters (``CreateSaveBodyFeature``'s
|
|
747
|
+
bodies/paths, sketch entity lists, ...) need a single ``VT_ARRAY``
|
|
748
|
+
VARIANT.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
element_vt: The element type flag, e.g. ``pythoncom.VT_DISPATCH`` or
|
|
752
|
+
``pythoncom.VT_BSTR``.
|
|
753
|
+
values: The sequence of elements.
|
|
754
|
+
|
|
755
|
+
Returns:
|
|
756
|
+
Any: ``VARIANT(VT_ARRAY | element_vt, list(values))``, or the plain
|
|
757
|
+
list when pywin32 is unavailable (test/mock environments).
|
|
758
|
+
"""
|
|
759
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
760
|
+
array_flag = getattr(pythoncom, "VT_ARRAY", 0)
|
|
761
|
+
if not callable(variant_ctor) or not array_flag:
|
|
762
|
+
return list(values)
|
|
763
|
+
return variant_ctor(int(array_flag) | int(element_vt), list(values))
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _resolve_drawing_view(adapter: Any, drawing: Any, view_name: str) -> Any:
|
|
767
|
+
"""Return the ``IView`` on the active drawing whose name matches.
|
|
768
|
+
|
|
769
|
+
Walks ``IDrawingDoc::GetViews`` (a list of sheets, each shaped
|
|
770
|
+
``(sheet, view, view, ...)``) the same way :func:`_view_names` does. The
|
|
771
|
+
match is case-insensitive.
|
|
772
|
+
|
|
773
|
+
Args:
|
|
774
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
775
|
+
drawing: The drawing document, flagged for ``IDrawingDoc``.
|
|
776
|
+
view_name: The drawing-view name to look for.
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
Any: The flagged ``IView`` dispatch, or ``None`` when nothing matches.
|
|
780
|
+
"""
|
|
781
|
+
target = str(view_name or "").strip().lower()
|
|
782
|
+
if not target:
|
|
783
|
+
return None
|
|
784
|
+
sheets = adapter._attempt(lambda: drawing.GetViews(), default=None)
|
|
785
|
+
if not isinstance(sheets, (list, tuple)):
|
|
786
|
+
return None
|
|
787
|
+
for sheet in sheets:
|
|
788
|
+
views = sheet if isinstance(sheet, (list, tuple)) else [sheet]
|
|
789
|
+
for index, view in enumerate(views):
|
|
790
|
+
if index == 0 and isinstance(sheet, (list, tuple)):
|
|
791
|
+
continue
|
|
792
|
+
wrapped = _as_com(adapter, view, "IView")
|
|
793
|
+
if wrapped is None:
|
|
794
|
+
continue
|
|
795
|
+
name = adapter._attempt(lambda w=wrapped: w.GetName2(), default=None)
|
|
796
|
+
if name and str(name).lower() == target:
|
|
797
|
+
return wrapped
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _coerce_dispatch_sequence(raw: Any) -> list[Any]:
|
|
802
|
+
"""Normalise ``ISldWorks::GetDocuments`` output into a list of dispatches.
|
|
803
|
+
|
|
804
|
+
Depending on the SolidWorks build and how many documents are open,
|
|
805
|
+
``GetDocuments`` returns ``None``, a single ``IModelDoc2``, or a
|
|
806
|
+
tuple/list of them.
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
raw: Whatever ``GetDocuments()`` returned.
|
|
810
|
+
|
|
811
|
+
Returns:
|
|
812
|
+
list[Any]: Zero or more non-``None`` dispatches.
|
|
813
|
+
"""
|
|
814
|
+
if raw is None:
|
|
815
|
+
return []
|
|
816
|
+
if isinstance(raw, (list, tuple)):
|
|
817
|
+
return [d for d in raw if d is not None]
|
|
818
|
+
return [raw]
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _describe_open_document(
|
|
822
|
+
adapter: Any,
|
|
823
|
+
doc: Any,
|
|
824
|
+
active_title: str | None,
|
|
825
|
+
active_path: str | None,
|
|
826
|
+
) -> dict[str, Any]:
|
|
827
|
+
"""Summarise one open document: title, path, type and whether it is active.
|
|
828
|
+
|
|
829
|
+
Args:
|
|
830
|
+
adapter: A connected adapter (for ``_get_attr_or_call``).
|
|
831
|
+
doc: An ``IModelDoc2`` dispatch.
|
|
832
|
+
active_title: Title of ``ISldWorks::ActiveDoc``, if known.
|
|
833
|
+
active_path: Path of ``ISldWorks::ActiveDoc``, if known.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
dict[str, Any]: ``title``, ``path``, ``type`` and ``is_active``.
|
|
837
|
+
"""
|
|
838
|
+
title = adapter._attempt(
|
|
839
|
+
lambda: adapter._get_attr_or_call(doc, "GetTitle"), default=None
|
|
840
|
+
)
|
|
841
|
+
path = adapter._attempt(
|
|
842
|
+
lambda: adapter._get_attr_or_call(doc, "GetPathName"), default=None
|
|
843
|
+
)
|
|
844
|
+
type_raw = adapter._attempt(
|
|
845
|
+
lambda: adapter._get_attr_or_call(doc, "GetType"), default=None
|
|
846
|
+
)
|
|
847
|
+
type_name = _DOC_TYPE_NAMES.get(
|
|
848
|
+
int(type_raw) if isinstance(type_raw, (int, float)) else -1, "Unknown"
|
|
849
|
+
)
|
|
850
|
+
is_active = False
|
|
851
|
+
if title and active_title:
|
|
852
|
+
is_active = str(title) == str(active_title)
|
|
853
|
+
elif path and active_path:
|
|
854
|
+
is_active = str(path).lower() == str(active_path).lower()
|
|
855
|
+
return {
|
|
856
|
+
"title": str(title) if title else None,
|
|
857
|
+
"path": str(path) if path else "",
|
|
858
|
+
"type": type_name,
|
|
859
|
+
"is_active": is_active,
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
# --- Geometry summary ------------------------------------------------------
|
|
864
|
+
# Read-only helpers for ``get_model_geometry_summary``. None of them rebuilds
|
|
865
|
+
# the model or touches the selection (COM pitfall #23), so the summary can be
|
|
866
|
+
# read immediately before a feature call.
|
|
867
|
+
|
|
868
|
+
_FEATURE_WALK_MEMBERS = (
|
|
869
|
+
"GetTypeName2",
|
|
870
|
+
"GetNextFeature",
|
|
871
|
+
"GetFirstSubFeature",
|
|
872
|
+
"GetNextSubFeature",
|
|
873
|
+
"IsSuppressed",
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
#: The six ``GetExtremePoint`` directions, as (axis, is_max, direction).
|
|
877
|
+
_EXTREME_DIRECTIONS = (
|
|
878
|
+
(0, False, (-1.0, 0.0, 0.0)),
|
|
879
|
+
(0, True, (1.0, 0.0, 0.0)),
|
|
880
|
+
(1, False, (0.0, -1.0, 0.0)),
|
|
881
|
+
(1, True, (0.0, 1.0, 0.0)),
|
|
882
|
+
(2, False, (0.0, 0.0, -1.0)),
|
|
883
|
+
(2, True, (0.0, 0.0, 1.0)),
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _box_from_metres(raw: Any, method: str) -> dict[str, Any] | None:
|
|
888
|
+
"""Turn a SolidWorks ``[x1, y1, z1, x2, y2, z2]`` box in metres into mm.
|
|
889
|
+
|
|
890
|
+
Args:
|
|
891
|
+
raw: The six-value box, or anything else.
|
|
892
|
+
method: How the box was measured, reported back to the caller.
|
|
893
|
+
|
|
894
|
+
Returns:
|
|
895
|
+
dict[str, Any] | None: ``min``, ``max``, ``size`` (mm) and ``method``,
|
|
896
|
+
or ``None`` when ``raw`` is not a six-number box.
|
|
897
|
+
"""
|
|
898
|
+
if not isinstance(raw, (list, tuple)) or len(raw) < 6:
|
|
899
|
+
return None
|
|
900
|
+
if not all(isinstance(v, (int, float)) for v in raw[:6]):
|
|
901
|
+
return None
|
|
902
|
+
values = [float(v) * 1000.0 for v in raw[:6]]
|
|
903
|
+
low = [min(values[i], values[i + 3]) for i in range(3)]
|
|
904
|
+
high = [max(values[i], values[i + 3]) for i in range(3)]
|
|
905
|
+
return {
|
|
906
|
+
"min": [round(v, 4) for v in low],
|
|
907
|
+
"max": [round(v, 4) for v in high],
|
|
908
|
+
"size": [round(high[i] - low[i], 4) for i in range(3)],
|
|
909
|
+
"method": method,
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _solid_bodies(adapter: Any, part: Any) -> list[Any] | None:
|
|
914
|
+
"""Return the part's solid bodies as wrapped ``IBody2`` dispatches.
|
|
915
|
+
|
|
916
|
+
Args:
|
|
917
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
918
|
+
part: The part document, flagged for ``IPartDoc``.
|
|
919
|
+
|
|
920
|
+
Returns:
|
|
921
|
+
list[Any] | None: The bodies (possibly empty), or ``None`` when
|
|
922
|
+
``GetBodies2`` could not be read at all. A part with no bodies yet
|
|
923
|
+
answers null rather than an empty array, so null without an error
|
|
924
|
+
is an empty list.
|
|
925
|
+
"""
|
|
926
|
+
raw, error = adapter._attempt_with_error(
|
|
927
|
+
lambda: part.GetBodies2(_SW_SOLID_BODY, False)
|
|
928
|
+
)
|
|
929
|
+
if error is not None:
|
|
930
|
+
return None
|
|
931
|
+
if raw is None:
|
|
932
|
+
return []
|
|
933
|
+
items = list(raw) if isinstance(raw, (list, tuple)) else [raw]
|
|
934
|
+
return [_as_com(adapter, body, "IBody2") or body for body in items if body]
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _extreme_point_box(adapter: Any, bodies: list[Any]) -> dict[str, Any] | None:
|
|
938
|
+
"""Tight bounding box from each body's six extreme points.
|
|
939
|
+
|
|
940
|
+
``GetPartBox`` can be loose on curved faces; ``GetExtremePoint`` returns
|
|
941
|
+
the actual farthest point of the body in a direction.
|
|
942
|
+
|
|
943
|
+
Args:
|
|
944
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
945
|
+
bodies: Wrapped ``IBody2`` dispatches.
|
|
946
|
+
|
|
947
|
+
Returns:
|
|
948
|
+
dict[str, Any] | None: The box, or ``None`` when there are no bodies
|
|
949
|
+
or any extreme point could not be read.
|
|
950
|
+
"""
|
|
951
|
+
if not bodies:
|
|
952
|
+
return None
|
|
953
|
+
low = [float("inf")] * 3
|
|
954
|
+
high = [float("-inf")] * 3
|
|
955
|
+
for body in bodies:
|
|
956
|
+
for axis, is_max, direction in _EXTREME_DIRECTIONS:
|
|
957
|
+
out = (_byref_double(), _byref_double(), _byref_double())
|
|
958
|
+
found = adapter._attempt(
|
|
959
|
+
lambda b=body, d=direction, o=out: b.GetExtremePoint(*d, *o),
|
|
960
|
+
default=None,
|
|
961
|
+
)
|
|
962
|
+
value = out[axis].value
|
|
963
|
+
if not found or not isinstance(value, (int, float)):
|
|
964
|
+
return None
|
|
965
|
+
if is_max:
|
|
966
|
+
high[axis] = max(high[axis], float(value))
|
|
967
|
+
else:
|
|
968
|
+
low[axis] = min(low[axis], float(value))
|
|
969
|
+
return _box_from_metres([*low, *high], "extreme_points")
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _bodies_volume_mm3(adapter: Any, bodies: list[Any] | None) -> float | None:
|
|
973
|
+
"""Sum the solid bodies' volumes, independent of the selection.
|
|
974
|
+
|
|
975
|
+
``IModelDocExtension.CreateMassProperty`` computes for the current
|
|
976
|
+
selection and returns no volume while a sketch or plane is selected,
|
|
977
|
+
which is the state a feature call runs in. ``IBody2.GetMassProperties``
|
|
978
|
+
reads each body directly; element 3 of its array is the volume in m³.
|
|
979
|
+
The density argument only affects mass, which is not used here.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
983
|
+
bodies: Wrapped ``IBody2`` dispatches, ``[]`` for an empty part.
|
|
984
|
+
|
|
985
|
+
Returns:
|
|
986
|
+
float | None: Volume in mm³ (``0.0`` for no bodies), or ``None`` when
|
|
987
|
+
the bodies or any body's volume could not be read.
|
|
988
|
+
"""
|
|
989
|
+
if bodies is None:
|
|
990
|
+
return None
|
|
991
|
+
total = 0.0
|
|
992
|
+
for body in bodies:
|
|
993
|
+
props = adapter._attempt(lambda b=body: b.GetMassProperties(1.0), default=None)
|
|
994
|
+
if not isinstance(props, (list, tuple)) or len(props) < 4:
|
|
995
|
+
return None
|
|
996
|
+
volume = props[3]
|
|
997
|
+
if not isinstance(volume, (int, float)):
|
|
998
|
+
return None
|
|
999
|
+
total += float(volume)
|
|
1000
|
+
return round(total * 1e9, 4)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _model_volume_mm3(adapter: Any, model: Any) -> float | None:
|
|
1004
|
+
"""Read the model's volume without forcing a rebuild.
|
|
1005
|
+
|
|
1006
|
+
Args:
|
|
1007
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
1008
|
+
model: The active ``IModelDoc2``.
|
|
1009
|
+
|
|
1010
|
+
Returns:
|
|
1011
|
+
float | None: Volume in mm³, or ``None`` when it cannot be read.
|
|
1012
|
+
"""
|
|
1013
|
+
mass_props = adapter._attempt(
|
|
1014
|
+
lambda: model.Extension.CreateMassProperty(), default=None
|
|
1015
|
+
)
|
|
1016
|
+
if mass_props is None:
|
|
1017
|
+
return None
|
|
1018
|
+
volume = adapter._attempt(lambda: mass_props.Volume, default=None)
|
|
1019
|
+
if not isinstance(volume, (int, float)):
|
|
1020
|
+
return None
|
|
1021
|
+
return round(float(volume) * 1e9, 4)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _sub_features(adapter: Any, parent: Any) -> list[Any]:
|
|
1025
|
+
"""Return a feature's sub-features, each flagged for the tree walk.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
1029
|
+
parent: The parent ``IFeature``.
|
|
1030
|
+
|
|
1031
|
+
Returns:
|
|
1032
|
+
list[Any]: The sub-features in tree order.
|
|
1033
|
+
"""
|
|
1034
|
+
children: list[Any] = []
|
|
1035
|
+
child = adapter._attempt(lambda: parent.GetFirstSubFeature(), default=None)
|
|
1036
|
+
while child is not None and len(children) < 10000:
|
|
1037
|
+
adapter._attempt(
|
|
1038
|
+
lambda c=child: _sw_type_info.flag_members(c, *_FEATURE_WALK_MEMBERS),
|
|
1039
|
+
default=0,
|
|
1040
|
+
)
|
|
1041
|
+
children.append(child)
|
|
1042
|
+
child = adapter._attempt(lambda c=child: c.GetNextSubFeature(), default=None)
|
|
1043
|
+
return children
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _count_mates(adapter: Any, mate_group: Any) -> int:
|
|
1047
|
+
"""Count the unsuppressed mates under the assembly's mate group.
|
|
1048
|
+
|
|
1049
|
+
Mates are sub-features whose type name starts with ``Mate``; a mate
|
|
1050
|
+
folder (any ``...Folder`` type) is entered one level deep.
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
1054
|
+
mate_group: The ``MateGroup`` feature.
|
|
1055
|
+
|
|
1056
|
+
Returns:
|
|
1057
|
+
int: The number of unsuppressed mates.
|
|
1058
|
+
"""
|
|
1059
|
+
count = 0
|
|
1060
|
+
for child in _sub_features(adapter, mate_group):
|
|
1061
|
+
kind = str(adapter._attempt(lambda c=child: c.GetTypeName2(), default="") or "")
|
|
1062
|
+
members = _sub_features(adapter, child) if kind.endswith("Folder") else [child]
|
|
1063
|
+
for mate in members:
|
|
1064
|
+
mate_kind = str(
|
|
1065
|
+
adapter._attempt(lambda m=mate: m.GetTypeName2(), default="") or ""
|
|
1066
|
+
)
|
|
1067
|
+
suppressed = adapter._attempt(
|
|
1068
|
+
lambda m=mate: m.IsSuppressed(), default=False
|
|
1069
|
+
)
|
|
1070
|
+
if mate_kind.startswith("Mate") and not suppressed:
|
|
1071
|
+
count += 1
|
|
1072
|
+
return count
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _assembly_mate_count(adapter: Any, model: Any) -> int | None:
|
|
1076
|
+
"""Find the ``MateGroup`` feature and count its mates.
|
|
1077
|
+
|
|
1078
|
+
Args:
|
|
1079
|
+
adapter: A connected ``PyWin32Adapter``.
|
|
1080
|
+
model: The active assembly ``IModelDoc2``.
|
|
1081
|
+
|
|
1082
|
+
Returns:
|
|
1083
|
+
int | None: The mate count (``0`` when there is no mate group), or
|
|
1084
|
+
``None`` when the feature tree could not be read.
|
|
1085
|
+
"""
|
|
1086
|
+
feature = adapter._attempt(lambda: model.FirstFeature(), default=None)
|
|
1087
|
+
if feature is None:
|
|
1088
|
+
return None
|
|
1089
|
+
guard = 0
|
|
1090
|
+
while feature is not None and guard < 10000:
|
|
1091
|
+
guard += 1
|
|
1092
|
+
adapter._attempt(
|
|
1093
|
+
lambda f=feature: _sw_type_info.flag_members(f, *_FEATURE_WALK_MEMBERS),
|
|
1094
|
+
default=0,
|
|
1095
|
+
)
|
|
1096
|
+
if adapter._attempt(lambda f=feature: f.GetTypeName2(), default="") == (
|
|
1097
|
+
"MateGroup"
|
|
1098
|
+
):
|
|
1099
|
+
return _count_mates(adapter, feature)
|
|
1100
|
+
feature = adapter._attempt(lambda f=feature: f.GetNextFeature(), default=None)
|
|
1101
|
+
return 0
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
class SolidWorksIOMixin:
|
|
1105
|
+
"""Expose model open/save/create/configuration methods through a mixin."""
|
|
1106
|
+
|
|
1107
|
+
@staticmethod
|
|
1108
|
+
def _adapter(obj: Any) -> Any:
|
|
1109
|
+
"""Return the runtime adapter object for dynamic attribute access."""
|
|
1110
|
+
return cast(Any, obj)
|
|
1111
|
+
|
|
1112
|
+
@staticmethod
|
|
1113
|
+
def _is_success(value: Any) -> bool:
|
|
1114
|
+
"""Interpret SolidWorks save API return values consistently.
|
|
1115
|
+
|
|
1116
|
+
Args:
|
|
1117
|
+
value: Return value from ``Save*`` COM calls.
|
|
1118
|
+
|
|
1119
|
+
Returns:
|
|
1120
|
+
bool: ``True`` when return value indicates success.
|
|
1121
|
+
"""
|
|
1122
|
+
if isinstance(value, bool):
|
|
1123
|
+
return value
|
|
1124
|
+
if isinstance(value, (int, float)):
|
|
1125
|
+
return value == 0
|
|
1126
|
+
return bool(value)
|
|
1127
|
+
|
|
1128
|
+
def _resolve_template_path(
|
|
1129
|
+
self, preferred_indices: list[int], extension: str
|
|
1130
|
+
) -> str | None:
|
|
1131
|
+
"""Resolve a template path from SolidWorks user preference slots.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
preferred_indices: Preference indices to probe in order.
|
|
1135
|
+
extension: Expected template extension such as ``.prtdot``.
|
|
1136
|
+
|
|
1137
|
+
Returns:
|
|
1138
|
+
str | None: First existing template match, otherwise first non-empty
|
|
1139
|
+
path candidate, or ``None`` if nothing is configured.
|
|
1140
|
+
"""
|
|
1141
|
+
adapter = self._adapter(self)
|
|
1142
|
+
existing_match: str | None = None
|
|
1143
|
+
first_non_empty: str | None = None
|
|
1144
|
+
app = adapter.swApp
|
|
1145
|
+
if app is None:
|
|
1146
|
+
return None
|
|
1147
|
+
|
|
1148
|
+
for index in preferred_indices:
|
|
1149
|
+
template = adapter._attempt(
|
|
1150
|
+
lambda idx=index: app.GetUserPreferenceStringValue(idx)
|
|
1151
|
+
)
|
|
1152
|
+
if not template or not isinstance(template, str):
|
|
1153
|
+
continue
|
|
1154
|
+
if first_non_empty is None:
|
|
1155
|
+
first_non_empty = template
|
|
1156
|
+
if template.lower().endswith(extension.lower()) and os.path.exists(
|
|
1157
|
+
template
|
|
1158
|
+
):
|
|
1159
|
+
existing_match = template
|
|
1160
|
+
break
|
|
1161
|
+
|
|
1162
|
+
return existing_match or first_non_empty
|
|
1163
|
+
|
|
1164
|
+
def _explicit_template_path(
|
|
1165
|
+
self, template: str | None, extension: str
|
|
1166
|
+
) -> str | None:
|
|
1167
|
+
"""Validate a template path the caller passed explicitly.
|
|
1168
|
+
|
|
1169
|
+
A bad explicit template is an error rather than a silent fall back to
|
|
1170
|
+
the configured default, so the caller learns their template was not
|
|
1171
|
+
used.
|
|
1172
|
+
|
|
1173
|
+
Args:
|
|
1174
|
+
template: Caller-supplied template path, or ``None``/blank.
|
|
1175
|
+
extension: Required template extension such as ``.prtdot``.
|
|
1176
|
+
|
|
1177
|
+
Returns:
|
|
1178
|
+
str | None: The stripped path, or ``None`` when no template was
|
|
1179
|
+
given.
|
|
1180
|
+
|
|
1181
|
+
Raises:
|
|
1182
|
+
Exception: When the path has the wrong extension or is not an
|
|
1183
|
+
existing file.
|
|
1184
|
+
"""
|
|
1185
|
+
if template is None or not str(template).strip():
|
|
1186
|
+
return None
|
|
1187
|
+
path = str(template).strip()
|
|
1188
|
+
if not path.lower().endswith(extension):
|
|
1189
|
+
raise Exception(f"Template {path!r} is not a {extension} file")
|
|
1190
|
+
if not os.path.isfile(path):
|
|
1191
|
+
raise Exception(f"Template not found: {path}")
|
|
1192
|
+
return path
|
|
1193
|
+
|
|
1194
|
+
def _read_model_title(self, model: Any) -> str:
|
|
1195
|
+
"""Read a model title regardless of COM exposing method or property.
|
|
1196
|
+
|
|
1197
|
+
Args:
|
|
1198
|
+
model: SolidWorks model COM object.
|
|
1199
|
+
|
|
1200
|
+
Returns:
|
|
1201
|
+
str: Best-effort model title, defaulting to ``"Untitled"``.
|
|
1202
|
+
"""
|
|
1203
|
+
adapter = self._adapter(self)
|
|
1204
|
+
title = adapter._attempt(lambda: adapter._get_attr_or_call(model, "GetTitle"))
|
|
1205
|
+
if isinstance(title, str) and title:
|
|
1206
|
+
return title
|
|
1207
|
+
|
|
1208
|
+
title_value = getattr(model, "Title", None)
|
|
1209
|
+
if isinstance(title_value, str) and title_value:
|
|
1210
|
+
return title_value
|
|
1211
|
+
|
|
1212
|
+
return "Untitled"
|
|
1213
|
+
|
|
1214
|
+
def _sync_current_model_from_active(self) -> Any:
|
|
1215
|
+
"""Point ``adapter.currentModel`` at ``ISldWorks::ActiveDoc``.
|
|
1216
|
+
|
|
1217
|
+
The adapter only tracks ``currentModel`` for documents opened or
|
|
1218
|
+
activated *through* a tool this session. When the user opens or
|
|
1219
|
+
switches documents in the SolidWorks UI, ``currentModel`` goes stale
|
|
1220
|
+
(or stays ``None``), so any tool that reads it first must resync -
|
|
1221
|
+
exactly what ``get_model_info`` already does after issue #91.
|
|
1222
|
+
|
|
1223
|
+
``ActiveDoc`` is a fresh, unflagged dispatch; it is flagged for its
|
|
1224
|
+
document type here so downstream zero-arg accessors (``GetTitle`` and
|
|
1225
|
+
friends) resolve as methods rather than property values that then
|
|
1226
|
+
raise when called (COM pitfall #5).
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
Any: The active document dispatch, or ``None`` when nothing is
|
|
1230
|
+
open.
|
|
1231
|
+
"""
|
|
1232
|
+
adapter = self._adapter(self)
|
|
1233
|
+
active_model = (
|
|
1234
|
+
getattr(adapter.swApp, "ActiveDoc", None) if adapter.swApp else None
|
|
1235
|
+
)
|
|
1236
|
+
if active_model is not None:
|
|
1237
|
+
doc_type = adapter._get_attr_or_call(active_model, "GetType")
|
|
1238
|
+
adapter._attempt(
|
|
1239
|
+
lambda: _sw_type_info.flag_doc(
|
|
1240
|
+
active_model,
|
|
1241
|
+
int(doc_type) if isinstance(doc_type, (int, float)) else 1,
|
|
1242
|
+
),
|
|
1243
|
+
default=0,
|
|
1244
|
+
)
|
|
1245
|
+
adapter.currentModel = active_model
|
|
1246
|
+
return adapter.currentModel
|
|
1247
|
+
|
|
1248
|
+
async def open_model(self, file_path: str) -> AdapterResult[SolidWorksModel]:
|
|
1249
|
+
"""Open a SolidWorks model file and set it as active on the adapter.
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
file_path: Path to a ``.sldprt``, ``.sldasm``, or ``.slddrw`` file.
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
AdapterResult[SolidWorksModel]: Model metadata for the opened document.
|
|
1256
|
+
"""
|
|
1257
|
+
adapter = self._adapter(self)
|
|
1258
|
+
if not adapter.is_connected():
|
|
1259
|
+
return AdapterResult(
|
|
1260
|
+
status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
|
|
1261
|
+
)
|
|
1262
|
+
|
|
1263
|
+
def _open() -> SolidWorksModel:
|
|
1264
|
+
"""Open the model document."""
|
|
1265
|
+
resolved_path = os.path.abspath(file_path)
|
|
1266
|
+
file_path_lower = resolved_path.lower()
|
|
1267
|
+
if file_path_lower.endswith(".sldprt"):
|
|
1268
|
+
doc_type = adapter.constants["swDocPART"]
|
|
1269
|
+
model_type = "Part"
|
|
1270
|
+
elif file_path_lower.endswith(".sldasm"):
|
|
1271
|
+
doc_type = adapter.constants["swDocASSEMBLY"]
|
|
1272
|
+
model_type = "Assembly"
|
|
1273
|
+
elif file_path_lower.endswith(".slddrw"):
|
|
1274
|
+
doc_type = adapter.constants["swDocDRAWING"]
|
|
1275
|
+
model_type = "Drawing"
|
|
1276
|
+
else:
|
|
1277
|
+
raise ValueError(f"Unsupported file type: {resolved_path}")
|
|
1278
|
+
|
|
1279
|
+
app = adapter.swApp
|
|
1280
|
+
variant_ctor = getattr(getattr(win32com, "client", None), "VARIANT", None)
|
|
1281
|
+
vt_byref = int(getattr(pythoncom, "VT_BYREF", 0))
|
|
1282
|
+
vt_i4 = int(getattr(pythoncom, "VT_I4", 0))
|
|
1283
|
+
if callable(variant_ctor):
|
|
1284
|
+
errors = variant_ctor(vt_byref | vt_i4, 0)
|
|
1285
|
+
warnings = variant_ctor(vt_byref | vt_i4, 0)
|
|
1286
|
+
else:
|
|
1287
|
+
errors = 0
|
|
1288
|
+
warnings = 0
|
|
1289
|
+
model = app.OpenDoc6(resolved_path, doc_type, 1, "", errors, warnings)
|
|
1290
|
+
if not model:
|
|
1291
|
+
raise Exception(f"Failed to open model: {resolved_path}")
|
|
1292
|
+
|
|
1293
|
+
adapter._attempt(
|
|
1294
|
+
lambda: _sw_type_info.flag_doc(model, int(doc_type)), default=0
|
|
1295
|
+
)
|
|
1296
|
+
|
|
1297
|
+
adapter.currentModel = model
|
|
1298
|
+
title = self._read_model_title(model)
|
|
1299
|
+
|
|
1300
|
+
# OpenDoc6 with swOpenDocOptions_Silent opens the file but does not
|
|
1301
|
+
# make it the active document - SolidWorks keeps the previously
|
|
1302
|
+
# focused window active. Without this, get_model_info and every
|
|
1303
|
+
# later tool call (exports especially) target the wrong document
|
|
1304
|
+
# (issue #91). ActivateDoc3 takes the titlebar name, not the path.
|
|
1305
|
+
if title:
|
|
1306
|
+
activated = adapter._attempt(
|
|
1307
|
+
lambda: app.ActivateDoc3(title, False, 0, _byref_int())
|
|
1308
|
+
)
|
|
1309
|
+
if activated is not None:
|
|
1310
|
+
adapter._attempt(
|
|
1311
|
+
lambda: _sw_type_info.flag_doc(activated, int(doc_type)),
|
|
1312
|
+
default=0,
|
|
1313
|
+
)
|
|
1314
|
+
adapter.currentModel = activated
|
|
1315
|
+
active_config = adapter._attempt(lambda: model.GetActiveConfiguration())
|
|
1316
|
+
config = (
|
|
1317
|
+
adapter._attempt(lambda: active_config.GetName(), default="Default")
|
|
1318
|
+
if active_config
|
|
1319
|
+
else "Default"
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
return SolidWorksModel(
|
|
1323
|
+
path=resolved_path,
|
|
1324
|
+
name=title,
|
|
1325
|
+
type=model_type,
|
|
1326
|
+
is_active=True,
|
|
1327
|
+
configuration=config,
|
|
1328
|
+
properties={
|
|
1329
|
+
"last_modified": (
|
|
1330
|
+
model.GetSaveTime()
|
|
1331
|
+
if callable(getattr(model, "GetSaveTime", None))
|
|
1332
|
+
else None
|
|
1333
|
+
),
|
|
1334
|
+
},
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
return cast(
|
|
1338
|
+
AdapterResult[SolidWorksModel],
|
|
1339
|
+
adapter._handle_com_operation("open_model", _open),
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
async def close_model(self, save: bool = False) -> AdapterResult[None]:
|
|
1343
|
+
"""Close the current SolidWorks model and optionally save first.
|
|
1344
|
+
|
|
1345
|
+
Args:
|
|
1346
|
+
save: When ``True``, calls ``Save`` before closing.
|
|
1347
|
+
|
|
1348
|
+
Returns:
|
|
1349
|
+
AdapterResult[None]: Result of the close operation.
|
|
1350
|
+
"""
|
|
1351
|
+
adapter = self._adapter(self)
|
|
1352
|
+
if not adapter.currentModel:
|
|
1353
|
+
return AdapterResult(
|
|
1354
|
+
status=AdapterResultStatus.WARNING, error="No active model to close"
|
|
1355
|
+
)
|
|
1356
|
+
model = adapter.currentModel
|
|
1357
|
+
app = adapter.swApp
|
|
1358
|
+
if model is None or app is None:
|
|
1359
|
+
return AdapterResult(
|
|
1360
|
+
status=AdapterResultStatus.ERROR,
|
|
1361
|
+
error="SolidWorks application is not connected",
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
def _close() -> None:
|
|
1365
|
+
"""Close the model document.
|
|
1366
|
+
|
|
1367
|
+
``currentModel`` may be a raw ``swApp.ActiveDoc`` dispatch that was
|
|
1368
|
+
never run through ``flag_doc`` (``get_model_info`` assigns it
|
|
1369
|
+
straight from ``ActiveDoc``). On such a dispatch, late binding
|
|
1370
|
+
returns zero-arg accessors property-style, so ``model.GetTitle()``
|
|
1371
|
+
tries to *call* the returned string and raises
|
|
1372
|
+
``'str' object is not callable`` (issue #91, COM pitfall #5).
|
|
1373
|
+
Read them through ``_get_attr_or_call`` which works either way.
|
|
1374
|
+
"""
|
|
1375
|
+
if save:
|
|
1376
|
+
adapter._get_attr_or_call(model, "Save")
|
|
1377
|
+
title = adapter._get_attr_or_call(model, "GetTitle")
|
|
1378
|
+
app.CloseDoc(title)
|
|
1379
|
+
adapter.currentModel = None
|
|
1380
|
+
|
|
1381
|
+
return cast(
|
|
1382
|
+
AdapterResult[None],
|
|
1383
|
+
adapter._handle_com_operation("close_model", _close),
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
async def create_part(
|
|
1387
|
+
self,
|
|
1388
|
+
name: str | None = None,
|
|
1389
|
+
units: str | None = None,
|
|
1390
|
+
template: str | None = None,
|
|
1391
|
+
) -> AdapterResult[SolidWorksModel]:
|
|
1392
|
+
"""Create a new part document and set it as active.
|
|
1393
|
+
|
|
1394
|
+
Args:
|
|
1395
|
+
name: Reserved for future naming policy.
|
|
1396
|
+
units: Optional linear unit system for the new part - ``mm``,
|
|
1397
|
+
``cm``, ``m``, ``in`` or ``ft`` (aliases such as ``inch``
|
|
1398
|
+
accepted). Applied after creation; the outcome is recorded
|
|
1399
|
+
under ``properties["units"]`` (or ``properties["units_error"]``
|
|
1400
|
+
if it could not be applied). An unrecognised token is an error.
|
|
1401
|
+
template: Optional ``.prtdot`` path. When given the part is made
|
|
1402
|
+
from it with ``NewDocument``; otherwise ``NewPart`` and then
|
|
1403
|
+
the user-preference default templates are tried.
|
|
1404
|
+
|
|
1405
|
+
Returns:
|
|
1406
|
+
AdapterResult[SolidWorksModel]: Metadata for the new part document.
|
|
1407
|
+
"""
|
|
1408
|
+
adapter = self._adapter(self)
|
|
1409
|
+
if not adapter.is_connected():
|
|
1410
|
+
return AdapterResult(
|
|
1411
|
+
status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
|
|
1412
|
+
)
|
|
1413
|
+
|
|
1414
|
+
unit_token: str | None = None
|
|
1415
|
+
if units is not None and str(units).strip():
|
|
1416
|
+
unit_token = _normalise_unit_system(str(units))
|
|
1417
|
+
if unit_token is None:
|
|
1418
|
+
return AdapterResult(
|
|
1419
|
+
status=AdapterResultStatus.ERROR,
|
|
1420
|
+
error=(
|
|
1421
|
+
f"Unrecognised units {units!r}. "
|
|
1422
|
+
"Expected one of: mm, cm, m, in, ft."
|
|
1423
|
+
),
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
def _create() -> SolidWorksModel:
|
|
1427
|
+
"""Create a new part."""
|
|
1428
|
+
_ = name
|
|
1429
|
+
model = None
|
|
1430
|
+
app = adapter.swApp
|
|
1431
|
+
if app is None:
|
|
1432
|
+
raise Exception("SolidWorks application is not connected")
|
|
1433
|
+
|
|
1434
|
+
explicit = self._explicit_template_path(template, ".prtdot")
|
|
1435
|
+
if explicit:
|
|
1436
|
+
model = app.NewDocument(explicit, 0, 0, 0)
|
|
1437
|
+
else:
|
|
1438
|
+
new_part = getattr(app, "NewPart", None)
|
|
1439
|
+
if callable(new_part):
|
|
1440
|
+
model = adapter._attempt(new_part)
|
|
1441
|
+
|
|
1442
|
+
if not model:
|
|
1443
|
+
part_template = self._resolve_template_path(
|
|
1444
|
+
[8, 0, 1, 2, 3], ".prtdot"
|
|
1445
|
+
)
|
|
1446
|
+
if not part_template:
|
|
1447
|
+
raise Exception(
|
|
1448
|
+
"No part template configured in SolidWorks; "
|
|
1449
|
+
"pass template with the path of a .prtdot file"
|
|
1450
|
+
)
|
|
1451
|
+
model = app.NewDocument(part_template, 0, 0, 0)
|
|
1452
|
+
|
|
1453
|
+
if not model:
|
|
1454
|
+
if explicit:
|
|
1455
|
+
raise Exception(f"Failed to create new part from {explicit!r}")
|
|
1456
|
+
raise Exception("Failed to create new part")
|
|
1457
|
+
|
|
1458
|
+
adapter._attempt(lambda: _sw_type_info.flag_doc(model, 1), default=0)
|
|
1459
|
+
adapter.currentModel = model
|
|
1460
|
+
title = self._read_model_title(model)
|
|
1461
|
+
properties: dict[str, Any] = {"created": datetime.now().isoformat()}
|
|
1462
|
+
if unit_token is not None:
|
|
1463
|
+
# A units failure must not lose the part that was just made;
|
|
1464
|
+
# record it and carry on.
|
|
1465
|
+
try:
|
|
1466
|
+
properties["units"] = _apply_unit_system(adapter, model, unit_token)
|
|
1467
|
+
except Exception as exc: # noqa: BLE001 - surfaced, not raised
|
|
1468
|
+
properties["units_error"] = str(exc)
|
|
1469
|
+
return SolidWorksModel(
|
|
1470
|
+
path="",
|
|
1471
|
+
name=title,
|
|
1472
|
+
type="Part",
|
|
1473
|
+
is_active=True,
|
|
1474
|
+
configuration="Default",
|
|
1475
|
+
properties=properties,
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
return cast(
|
|
1479
|
+
AdapterResult[SolidWorksModel],
|
|
1480
|
+
adapter._handle_com_operation("create_part", _create),
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
async def create_assembly(
|
|
1484
|
+
self, name: str | None = None, template: str | None = None
|
|
1485
|
+
) -> AdapterResult[SolidWorksModel]:
|
|
1486
|
+
"""Create a new assembly document and set it as active.
|
|
1487
|
+
|
|
1488
|
+
Args:
|
|
1489
|
+
name: Reserved for future naming policy.
|
|
1490
|
+
template: Optional ``.asmdot`` path. When given the assembly is
|
|
1491
|
+
made from it with ``NewDocument``; otherwise ``NewAssembly``
|
|
1492
|
+
and then the user-preference default templates are tried.
|
|
1493
|
+
|
|
1494
|
+
Returns:
|
|
1495
|
+
AdapterResult[SolidWorksModel]: Metadata for the new assembly document.
|
|
1496
|
+
"""
|
|
1497
|
+
adapter = self._adapter(self)
|
|
1498
|
+
if not adapter.is_connected():
|
|
1499
|
+
return AdapterResult(
|
|
1500
|
+
status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
def _create() -> SolidWorksModel:
|
|
1504
|
+
"""Create a new assembly."""
|
|
1505
|
+
_ = name
|
|
1506
|
+
model = None
|
|
1507
|
+
app = adapter.swApp
|
|
1508
|
+
if app is None:
|
|
1509
|
+
raise Exception("SolidWorks application is not connected")
|
|
1510
|
+
|
|
1511
|
+
explicit = self._explicit_template_path(template, ".asmdot")
|
|
1512
|
+
if explicit:
|
|
1513
|
+
model = app.NewDocument(explicit, 0, 0, 0)
|
|
1514
|
+
else:
|
|
1515
|
+
new_assembly = getattr(app, "NewAssembly", None)
|
|
1516
|
+
if callable(new_assembly):
|
|
1517
|
+
model = adapter._attempt(new_assembly)
|
|
1518
|
+
|
|
1519
|
+
if not model:
|
|
1520
|
+
asm_template = self._resolve_template_path(
|
|
1521
|
+
[9, 2, 3, 1, 0], ".asmdot"
|
|
1522
|
+
)
|
|
1523
|
+
if not asm_template:
|
|
1524
|
+
raise Exception(
|
|
1525
|
+
"No assembly template configured in SolidWorks; "
|
|
1526
|
+
"pass template with the path of a .asmdot file"
|
|
1527
|
+
)
|
|
1528
|
+
model = app.NewDocument(asm_template, 0, 0, 0)
|
|
1529
|
+
|
|
1530
|
+
if not model:
|
|
1531
|
+
if explicit:
|
|
1532
|
+
raise Exception(f"Failed to create new assembly from {explicit!r}")
|
|
1533
|
+
raise Exception("Failed to create new assembly")
|
|
1534
|
+
|
|
1535
|
+
adapter._attempt(lambda: _sw_type_info.flag_doc(model, 2), default=0)
|
|
1536
|
+
adapter.currentModel = model
|
|
1537
|
+
title = self._read_model_title(model)
|
|
1538
|
+
return SolidWorksModel(
|
|
1539
|
+
path="",
|
|
1540
|
+
name=title,
|
|
1541
|
+
type="Assembly",
|
|
1542
|
+
is_active=True,
|
|
1543
|
+
configuration="Default",
|
|
1544
|
+
properties={"created": datetime.now().isoformat()},
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
return cast(
|
|
1548
|
+
AdapterResult[SolidWorksModel],
|
|
1549
|
+
adapter._handle_com_operation("create_assembly", _create),
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
async def create_drawing(
|
|
1553
|
+
self, name: str | None = None, template: str | None = None
|
|
1554
|
+
) -> AdapterResult[SolidWorksModel]:
|
|
1555
|
+
"""Create a new drawing document and set it as active.
|
|
1556
|
+
|
|
1557
|
+
Args:
|
|
1558
|
+
name: Reserved for future naming policy.
|
|
1559
|
+
template: Optional ``.drwdot`` path, used in place of the
|
|
1560
|
+
user-preference default. An explicit template that fails is
|
|
1561
|
+
an error; it does not fall back to a blank drawing.
|
|
1562
|
+
|
|
1563
|
+
Returns:
|
|
1564
|
+
AdapterResult[SolidWorksModel]: Metadata for the new drawing document.
|
|
1565
|
+
"""
|
|
1566
|
+
adapter = self._adapter(self)
|
|
1567
|
+
if not adapter.is_connected():
|
|
1568
|
+
return AdapterResult(
|
|
1569
|
+
status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
|
|
1570
|
+
)
|
|
1571
|
+
|
|
1572
|
+
def _create() -> SolidWorksModel:
|
|
1573
|
+
"""Create a new drawing."""
|
|
1574
|
+
_ = name
|
|
1575
|
+
app = adapter.swApp
|
|
1576
|
+
if app is None:
|
|
1577
|
+
raise Exception("SolidWorks application is not connected")
|
|
1578
|
+
|
|
1579
|
+
# Slot 10 is swDefaultTemplateDrawing. Slot 1 was read here
|
|
1580
|
+
# before and comes back empty on SW 2025, after which the
|
|
1581
|
+
# fallback did GetUserPreferenceStringValue(0).replace("Part",
|
|
1582
|
+
# "Drawing") on another empty string - so NewDocument got "" and
|
|
1583
|
+
# every drawing operation was unreachable. Measured live on
|
|
1584
|
+
# SW 2025: 8=Part.prtdot, 9=Assembly.asmdot, 10=Drawing.drwdot,
|
|
1585
|
+
# 0-3 all empty.
|
|
1586
|
+
explicit = self._explicit_template_path(template, ".drwdot")
|
|
1587
|
+
drw_template = explicit or self._resolve_template_path(
|
|
1588
|
+
[10, 1, 0, 2, 3], ".drwdot"
|
|
1589
|
+
)
|
|
1590
|
+
model = None
|
|
1591
|
+
if drw_template:
|
|
1592
|
+
model = app.NewDocument(
|
|
1593
|
+
drw_template, _SW_DWG_PAPERS_USER_DEFINED, 0.2794, 0.2159
|
|
1594
|
+
)
|
|
1595
|
+
if not model and explicit:
|
|
1596
|
+
raise Exception(f"Failed to create a drawing from {explicit!r}")
|
|
1597
|
+
if not model:
|
|
1598
|
+
# No usable template (a settings reset blanks the slot, or it
|
|
1599
|
+
# names a deleted file). Like create_part's NewPart fallback,
|
|
1600
|
+
# let SolidWorks make a blank A-size drawing with no sheet
|
|
1601
|
+
# format; views can still be placed on it.
|
|
1602
|
+
model = adapter._attempt(
|
|
1603
|
+
lambda: app.NewDrawing2(
|
|
1604
|
+
_SW_DWG_TEMPLATE_NONE, "", _SW_DWG_PAPER_A_SIZE, 0.2794, 0.2159
|
|
1605
|
+
)
|
|
1606
|
+
)
|
|
1607
|
+
if not model:
|
|
1608
|
+
if drw_template:
|
|
1609
|
+
tried = f"template {drw_template!r}"
|
|
1610
|
+
else:
|
|
1611
|
+
tried = "no drawing template is configured"
|
|
1612
|
+
raise Exception(
|
|
1613
|
+
f"Failed to create a drawing: {tried}, and a blank drawing "
|
|
1614
|
+
"without a template (NewDrawing2) also failed"
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
adapter._attempt(lambda: _sw_type_info.flag_doc(model, 3), default=0)
|
|
1618
|
+
adapter.currentModel = model
|
|
1619
|
+
title = self._read_model_title(model)
|
|
1620
|
+
return SolidWorksModel(
|
|
1621
|
+
path="",
|
|
1622
|
+
name=title,
|
|
1623
|
+
type="Drawing",
|
|
1624
|
+
is_active=True,
|
|
1625
|
+
configuration="Default",
|
|
1626
|
+
properties={"created": datetime.now().isoformat()},
|
|
1627
|
+
)
|
|
1628
|
+
|
|
1629
|
+
return cast(
|
|
1630
|
+
AdapterResult[SolidWorksModel],
|
|
1631
|
+
adapter._handle_com_operation("create_drawing", _create),
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
async def get_dimension(self, name: str) -> AdapterResult[float]:
|
|
1635
|
+
"""Read a named model dimension in millimetres.
|
|
1636
|
+
|
|
1637
|
+
Args:
|
|
1638
|
+
name: Fully-qualified dimension name.
|
|
1639
|
+
|
|
1640
|
+
Returns:
|
|
1641
|
+
AdapterResult[float]: Dimension value in millimetres.
|
|
1642
|
+
"""
|
|
1643
|
+
adapter = self._adapter(self)
|
|
1644
|
+
if not adapter.currentModel:
|
|
1645
|
+
return AdapterResult(
|
|
1646
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
def _get() -> float:
|
|
1650
|
+
"""Get the dimension value."""
|
|
1651
|
+
dimension = adapter.currentModel.Parameter(name)
|
|
1652
|
+
if not dimension:
|
|
1653
|
+
raise Exception(f"Dimension '{name}' not found")
|
|
1654
|
+
# SystemValue is reliable on SW 2025 (in meters, convert to mm)
|
|
1655
|
+
value = adapter._attempt(lambda: dimension.SystemValue, default=None)
|
|
1656
|
+
if value is None:
|
|
1657
|
+
# Fall back to GetValue3 for older SW versions
|
|
1658
|
+
value = adapter._attempt(
|
|
1659
|
+
lambda: dimension.GetValue3(0, 0), default=None
|
|
1660
|
+
)
|
|
1661
|
+
if value is None:
|
|
1662
|
+
raise Exception(f"Failed to read dimension '{name}'")
|
|
1663
|
+
return float(value) * 1000
|
|
1664
|
+
|
|
1665
|
+
return cast(
|
|
1666
|
+
AdapterResult[float],
|
|
1667
|
+
adapter._handle_com_operation("get_dimension", _get),
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
async def set_dimension(self, name: str, value: float) -> AdapterResult[None]:
|
|
1671
|
+
"""Set a named model dimension in millimetres and rebuild.
|
|
1672
|
+
|
|
1673
|
+
Args:
|
|
1674
|
+
name: Fully-qualified dimension name.
|
|
1675
|
+
value: New value in millimetres.
|
|
1676
|
+
|
|
1677
|
+
Returns:
|
|
1678
|
+
AdapterResult[None]: Result of the set operation.
|
|
1679
|
+
"""
|
|
1680
|
+
adapter = self._adapter(self)
|
|
1681
|
+
if not adapter.currentModel:
|
|
1682
|
+
return AdapterResult(
|
|
1683
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
def _set() -> None:
|
|
1687
|
+
"""Set the dimension value."""
|
|
1688
|
+
dimension = adapter.currentModel.Parameter(name)
|
|
1689
|
+
if not dimension:
|
|
1690
|
+
raise Exception(f"Dimension '{name}' not found")
|
|
1691
|
+
|
|
1692
|
+
# SetValue3 has gen_py parameter mapping issues on SW 2025.
|
|
1693
|
+
# SystemValue (in meters) is reliable.
|
|
1694
|
+
value_m = value / 1000.0
|
|
1695
|
+
adapter._attempt(
|
|
1696
|
+
lambda: setattr(dimension, "SystemValue", value_m),
|
|
1697
|
+
default=None,
|
|
1698
|
+
)
|
|
1699
|
+
|
|
1700
|
+
# Rebuild: try EditRebuild3 first, fall back to ForceRebuild3
|
|
1701
|
+
rebuilt = adapter._attempt(
|
|
1702
|
+
lambda: adapter.currentModel.EditRebuild3(), default=None
|
|
1703
|
+
)
|
|
1704
|
+
if rebuilt is None:
|
|
1705
|
+
rebuilt = adapter._attempt(
|
|
1706
|
+
lambda: adapter.currentModel.ForceRebuild3(True), default=None
|
|
1707
|
+
)
|
|
1708
|
+
if rebuilt is None:
|
|
1709
|
+
raise Exception("Failed to set dimension")
|
|
1710
|
+
|
|
1711
|
+
return cast(
|
|
1712
|
+
AdapterResult[None],
|
|
1713
|
+
adapter._handle_com_operation("set_dimension", _set),
|
|
1714
|
+
)
|
|
1715
|
+
|
|
1716
|
+
async def set_units(self, unit_system: str) -> AdapterResult[dict[str, Any]]:
|
|
1717
|
+
"""Set the active document's linear unit system.
|
|
1718
|
+
|
|
1719
|
+
Accepts ``mm``, ``cm``, ``m``, ``in`` or ``ft`` (plus common aliases
|
|
1720
|
+
such as ``inch`` / ``millimeters``). The choice is applied by setting
|
|
1721
|
+
both ``swUnitSystem`` and the exact ``swUnitsLinear`` preference, then
|
|
1722
|
+
reading the linear unit back - a mismatch is reported as an error.
|
|
1723
|
+
|
|
1724
|
+
Args:
|
|
1725
|
+
unit_system: The target unit token.
|
|
1726
|
+
|
|
1727
|
+
Returns:
|
|
1728
|
+
AdapterResult[dict[str, Any]]: What was applied and whether the
|
|
1729
|
+
readback confirmed it. ``ERROR`` when there is no active model or
|
|
1730
|
+
the token is not recognised.
|
|
1731
|
+
"""
|
|
1732
|
+
adapter = self._adapter(self)
|
|
1733
|
+
# Resync from ActiveDoc in case the user switched documents in the UI.
|
|
1734
|
+
self._sync_current_model_from_active()
|
|
1735
|
+
if not adapter.currentModel:
|
|
1736
|
+
return AdapterResult(
|
|
1737
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
token = _normalise_unit_system(unit_system)
|
|
1741
|
+
if token is None:
|
|
1742
|
+
return AdapterResult(
|
|
1743
|
+
status=AdapterResultStatus.ERROR,
|
|
1744
|
+
error=(
|
|
1745
|
+
f"Unrecognised unit system {unit_system!r}. "
|
|
1746
|
+
"Expected one of: mm, cm, m, in, ft."
|
|
1747
|
+
),
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
def _apply() -> dict[str, Any]:
|
|
1751
|
+
return _apply_unit_system(adapter, adapter.currentModel, token)
|
|
1752
|
+
|
|
1753
|
+
return cast(
|
|
1754
|
+
AdapterResult[dict[str, Any]],
|
|
1755
|
+
adapter._handle_com_operation("set_units", _apply),
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1758
|
+
async def save_file(self, file_path: str | None = None) -> AdapterResult[None]:
|
|
1759
|
+
"""Save the active model to its current path or to a new file path.
|
|
1760
|
+
|
|
1761
|
+
Args:
|
|
1762
|
+
file_path: Optional target path for Save As.
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
AdapterResult[None]: Result of the save operation.
|
|
1766
|
+
"""
|
|
1767
|
+
adapter = self._adapter(self)
|
|
1768
|
+
if not adapter.currentModel:
|
|
1769
|
+
return AdapterResult(
|
|
1770
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
1771
|
+
)
|
|
1772
|
+
|
|
1773
|
+
def _save() -> None:
|
|
1774
|
+
"""Save the model."""
|
|
1775
|
+
if file_path:
|
|
1776
|
+
resolved_path = os.path.abspath(file_path)
|
|
1777
|
+
directory = os.path.dirname(resolved_path)
|
|
1778
|
+
if directory:
|
|
1779
|
+
os.makedirs(directory, exist_ok=True)
|
|
1780
|
+
|
|
1781
|
+
current_path = adapter._attempt(
|
|
1782
|
+
lambda: adapter._get_attr_or_call(
|
|
1783
|
+
adapter.currentModel, "GetPathName"
|
|
1784
|
+
),
|
|
1785
|
+
default="",
|
|
1786
|
+
)
|
|
1787
|
+
same_file = bool(current_path) and os.path.normcase(
|
|
1788
|
+
os.path.abspath(str(current_path))
|
|
1789
|
+
) == os.path.normcase(resolved_path)
|
|
1790
|
+
|
|
1791
|
+
if same_file:
|
|
1792
|
+
# Saving a document over its own path is a plain Save.
|
|
1793
|
+
# It used to fall through to the Save-As branch below, which
|
|
1794
|
+
# closed the document and deleted the file before calling
|
|
1795
|
+
# SaveAs3 on the now-closed doc. That wrote an empty part and
|
|
1796
|
+
# lost the geometry.
|
|
1797
|
+
save_result = adapter._attempt(
|
|
1798
|
+
lambda: adapter.currentModel.Save3(1, None, None)
|
|
1799
|
+
)
|
|
1800
|
+
if save_result is None:
|
|
1801
|
+
save_fn = getattr(adapter.currentModel, "Save", None)
|
|
1802
|
+
if callable(save_fn):
|
|
1803
|
+
save_fn()
|
|
1804
|
+
if not os.path.exists(resolved_path):
|
|
1805
|
+
raise Exception(f"File not written after save: {resolved_path}")
|
|
1806
|
+
return
|
|
1807
|
+
|
|
1808
|
+
# A *different* document may be holding the target path open.
|
|
1809
|
+
# Close that one by name only - never the document being saved.
|
|
1810
|
+
if adapter.swApp:
|
|
1811
|
+
adapter._attempt(
|
|
1812
|
+
lambda: adapter.swApp.CloseDoc(os.path.basename(resolved_path))
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
# Deliberately no os.remove here: SaveAs3 overwrites, and
|
|
1816
|
+
# deleting first meant a failed save destroyed the old file too.
|
|
1817
|
+
save_as3_result = adapter.currentModel.SaveAs3(resolved_path, 0, 0)
|
|
1818
|
+
if not self._is_success(save_as3_result):
|
|
1819
|
+
save_as = getattr(adapter.currentModel, "SaveAs", None)
|
|
1820
|
+
if callable(save_as):
|
|
1821
|
+
fallback_result = save_as(resolved_path)
|
|
1822
|
+
if not self._is_success(fallback_result):
|
|
1823
|
+
raise Exception(f"Failed to save as: {resolved_path}")
|
|
1824
|
+
else:
|
|
1825
|
+
raise Exception(f"Failed to save as: {resolved_path}")
|
|
1826
|
+
|
|
1827
|
+
if not os.path.exists(resolved_path):
|
|
1828
|
+
raise Exception(f"File not written after save: {resolved_path}")
|
|
1829
|
+
return
|
|
1830
|
+
|
|
1831
|
+
save_result = adapter._attempt(
|
|
1832
|
+
lambda: adapter.currentModel.Save3(1, None, None)
|
|
1833
|
+
)
|
|
1834
|
+
if save_result is None:
|
|
1835
|
+
save_fn = getattr(adapter.currentModel, "Save", None)
|
|
1836
|
+
if callable(save_fn):
|
|
1837
|
+
save_result = save_fn()
|
|
1838
|
+
else:
|
|
1839
|
+
raise Exception("Failed to save file")
|
|
1840
|
+
|
|
1841
|
+
if self._is_success(save_result):
|
|
1842
|
+
return
|
|
1843
|
+
|
|
1844
|
+
path_attr = getattr(adapter.currentModel, "GetPathName", "")
|
|
1845
|
+
model_path = path_attr() if callable(path_attr) else path_attr
|
|
1846
|
+
if model_path and os.path.exists(model_path):
|
|
1847
|
+
return
|
|
1848
|
+
raise Exception("Failed to save file")
|
|
1849
|
+
|
|
1850
|
+
return cast(
|
|
1851
|
+
AdapterResult[None],
|
|
1852
|
+
adapter._handle_com_operation("save_file", _save),
|
|
1853
|
+
)
|
|
1854
|
+
|
|
1855
|
+
async def rebuild_model(
|
|
1856
|
+
self, top_level_only: bool = False
|
|
1857
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
1858
|
+
"""Force a rebuild and report exactly which features fail and why.
|
|
1859
|
+
|
|
1860
|
+
Args:
|
|
1861
|
+
top_level_only: Rebuild only the top level of an assembly.
|
|
1862
|
+
Defaults to False (full rebuild).
|
|
1863
|
+
|
|
1864
|
+
Returns:
|
|
1865
|
+
AdapterResult[dict[str, Any]]: ``rebuilt``, ``top_level_only``,
|
|
1866
|
+
``errors`` (see ``rebuild_diagnostics.explain``), ``error_count``,
|
|
1867
|
+
``warning_count`` and a one-line ``summary``. ERROR - still
|
|
1868
|
+
carrying that ``data`` - when any error-severity entry remains or
|
|
1869
|
+
SolidWorks reports the rebuild itself failed.
|
|
1870
|
+
"""
|
|
1871
|
+
from .com_utils import strip_op_prefix
|
|
1872
|
+
from .rebuild_diagnostics import explain, summarize
|
|
1873
|
+
from .rebuild_inspector import collect_rebuild_issues
|
|
1874
|
+
|
|
1875
|
+
adapter = self._adapter(self)
|
|
1876
|
+
if not adapter.currentModel:
|
|
1877
|
+
return AdapterResult(
|
|
1878
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
def _rebuild() -> dict[str, Any]:
|
|
1882
|
+
model = adapter.currentModel
|
|
1883
|
+
# ForceRebuild3 returns False whenever a feature fails to rebuild
|
|
1884
|
+
# (confirmed live 2026-10-13), so False is not "the rebuild call
|
|
1885
|
+
# broke" - collect the per-feature issues either way and only fall
|
|
1886
|
+
# back to a generic failure when SolidWorks names none.
|
|
1887
|
+
rebuilt = bool(model.ForceRebuild3(top_level_only))
|
|
1888
|
+
entries = [explain(i) for i in collect_rebuild_issues(adapter, model)]
|
|
1889
|
+
error_count, warning_count, summary = summarize(entries)
|
|
1890
|
+
if not rebuilt and not error_count:
|
|
1891
|
+
# Keep any warnings in data; only the cause is unnamed.
|
|
1892
|
+
summary = (
|
|
1893
|
+
"SolidWorks reported that the rebuild failed (ForceRebuild3 "
|
|
1894
|
+
"returned False) but listed no failing feature."
|
|
1895
|
+
+ (f" Warnings: {summary}" if warning_count else "")
|
|
1896
|
+
)
|
|
1897
|
+
return {
|
|
1898
|
+
"rebuilt": rebuilt,
|
|
1899
|
+
"top_level_only": top_level_only,
|
|
1900
|
+
"errors": entries,
|
|
1901
|
+
"error_count": error_count,
|
|
1902
|
+
"warning_count": warning_count,
|
|
1903
|
+
"summary": summary,
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
result = cast(
|
|
1907
|
+
"AdapterResult[dict[str, Any]]",
|
|
1908
|
+
adapter._handle_com_operation("rebuild_model", _rebuild),
|
|
1909
|
+
)
|
|
1910
|
+
if not result.is_success:
|
|
1911
|
+
result.error = strip_op_prefix(result.error, "rebuild_model")
|
|
1912
|
+
elif result.data and (result.data["error_count"] or not result.data["rebuilt"]):
|
|
1913
|
+
result.status = AdapterResultStatus.ERROR
|
|
1914
|
+
result.error = result.data["summary"]
|
|
1915
|
+
return result
|
|
1916
|
+
|
|
1917
|
+
async def get_model_info(self) -> AdapterResult[dict[str, Any]]:
|
|
1918
|
+
"""Collect summary metadata about the active model.
|
|
1919
|
+
|
|
1920
|
+
Returns:
|
|
1921
|
+
AdapterResult[dict[str, Any]]: Model information payload.
|
|
1922
|
+
"""
|
|
1923
|
+
adapter = self._adapter(self)
|
|
1924
|
+
# Resync from ActiveDoc: the user may have opened or switched
|
|
1925
|
+
# documents in the SolidWorks UI since the last tool call (issue #91).
|
|
1926
|
+
self._sync_current_model_from_active()
|
|
1927
|
+
if not adapter.currentModel:
|
|
1928
|
+
return AdapterResult(
|
|
1929
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
1930
|
+
)
|
|
1931
|
+
|
|
1932
|
+
def _get_info() -> dict[str, Any]:
|
|
1933
|
+
"""Get model information."""
|
|
1934
|
+
# With late-bound SolidWorks COM, GetActiveConfiguration is
|
|
1935
|
+
# exposed as an object-valued property even though the API names
|
|
1936
|
+
# it like a method. Calling that COM object raises "member not
|
|
1937
|
+
# found", so read it directly.
|
|
1938
|
+
active_config = getattr(
|
|
1939
|
+
adapter.currentModel, "GetActiveConfiguration", None
|
|
1940
|
+
)
|
|
1941
|
+
# 'Name' on Configuration is a property, not a method.
|
|
1942
|
+
config_name = (
|
|
1943
|
+
getattr(active_config, "Name", "Default")
|
|
1944
|
+
if active_config
|
|
1945
|
+
else "Default"
|
|
1946
|
+
)
|
|
1947
|
+
# Try GetSaveFlag (method) first, fallback to property
|
|
1948
|
+
is_dirty_raw = adapter._attempt(
|
|
1949
|
+
lambda: adapter._get_attr_or_call(adapter.currentModel, "GetSaveFlag"),
|
|
1950
|
+
default=None,
|
|
1951
|
+
)
|
|
1952
|
+
is_dirty = bool(is_dirty_raw) if is_dirty_raw is not None else None
|
|
1953
|
+
feature_count = adapter._attempt(
|
|
1954
|
+
lambda: int(
|
|
1955
|
+
adapter.currentModel.FeatureManager.GetFeatureCount(True) or 0
|
|
1956
|
+
),
|
|
1957
|
+
default=0,
|
|
1958
|
+
)
|
|
1959
|
+
rebuild_status_raw = adapter._attempt(
|
|
1960
|
+
lambda: adapter.currentModel.GetRebuildStatus(), default=None
|
|
1961
|
+
)
|
|
1962
|
+
# GetRebuildStatus returns 0=ok, 1=needs rebuild, or None=failed
|
|
1963
|
+
rebuild_status = (
|
|
1964
|
+
rebuild_status_raw if rebuild_status_raw is not None else None
|
|
1965
|
+
)
|
|
1966
|
+
return {
|
|
1967
|
+
"title": adapter._get_attr_or_call(adapter.currentModel, "GetTitle"),
|
|
1968
|
+
"path": adapter._get_attr_or_call(adapter.currentModel, "GetPathName"),
|
|
1969
|
+
"type": adapter._get_document_type(),
|
|
1970
|
+
"configuration": config_name,
|
|
1971
|
+
"is_dirty": is_dirty,
|
|
1972
|
+
"feature_count": feature_count,
|
|
1973
|
+
"rebuild_status": rebuild_status,
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
return cast(
|
|
1977
|
+
AdapterResult[dict[str, Any]],
|
|
1978
|
+
adapter._handle_com_operation("get_model_info", _get_info),
|
|
1979
|
+
)
|
|
1980
|
+
|
|
1981
|
+
async def list_open_documents(self) -> AdapterResult[list[dict[str, Any]]]:
|
|
1982
|
+
"""Enumerate every document currently open in SolidWorks.
|
|
1983
|
+
|
|
1984
|
+
Read-only: uses ``ISldWorks::GetDocuments`` and does not touch the
|
|
1985
|
+
active-document selection. Each entry carries ``title``, ``path``,
|
|
1986
|
+
``type`` (``Part`` / ``Assembly`` / ``Drawing`` / ``Unknown``) and
|
|
1987
|
+
``is_active``.
|
|
1988
|
+
|
|
1989
|
+
Returns:
|
|
1990
|
+
AdapterResult[list[dict[str, Any]]]: One entry per open document
|
|
1991
|
+
(possibly empty). ``ERROR`` only when not connected.
|
|
1992
|
+
"""
|
|
1993
|
+
adapter = self._adapter(self)
|
|
1994
|
+
if not adapter.is_connected():
|
|
1995
|
+
return AdapterResult(
|
|
1996
|
+
status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
|
|
1997
|
+
)
|
|
1998
|
+
|
|
1999
|
+
def _list() -> list[dict[str, Any]]:
|
|
2000
|
+
app = adapter.swApp
|
|
2001
|
+
|
|
2002
|
+
docs = _coerce_dispatch_sequence(
|
|
2003
|
+
adapter._attempt(lambda: app.GetDocuments(), default=None)
|
|
2004
|
+
)
|
|
2005
|
+
active = adapter._attempt(
|
|
2006
|
+
lambda: getattr(app, "ActiveDoc", None), default=None
|
|
2007
|
+
)
|
|
2008
|
+
active_title = (
|
|
2009
|
+
adapter._attempt(
|
|
2010
|
+
lambda: adapter._get_attr_or_call(active, "GetTitle"), default=None
|
|
2011
|
+
)
|
|
2012
|
+
if active is not None
|
|
2013
|
+
else None
|
|
2014
|
+
)
|
|
2015
|
+
active_path = (
|
|
2016
|
+
adapter._attempt(
|
|
2017
|
+
lambda: adapter._get_attr_or_call(active, "GetPathName"),
|
|
2018
|
+
default=None,
|
|
2019
|
+
)
|
|
2020
|
+
if active is not None
|
|
2021
|
+
else None
|
|
2022
|
+
)
|
|
2023
|
+
return [
|
|
2024
|
+
_describe_open_document(adapter, d, active_title, active_path)
|
|
2025
|
+
for d in docs
|
|
2026
|
+
]
|
|
2027
|
+
|
|
2028
|
+
return cast(
|
|
2029
|
+
AdapterResult[list[dict[str, Any]]],
|
|
2030
|
+
adapter._handle_com_operation("list_open_documents", _list),
|
|
2031
|
+
)
|
|
2032
|
+
|
|
2033
|
+
async def activate_document(
|
|
2034
|
+
self, title_or_path: str
|
|
2035
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2036
|
+
"""Make an already-open document the active one.
|
|
2037
|
+
|
|
2038
|
+
Matches ``title_or_path`` against the open documents by exact title,
|
|
2039
|
+
by full path, or by file name (all case-insensitive), then calls
|
|
2040
|
+
``ISldWorks::ActivateDoc3`` and updates the adapter's tracked
|
|
2041
|
+
``currentModel``. The resulting ``ActiveDoc`` is read back and a
|
|
2042
|
+
mismatch is reported as an error.
|
|
2043
|
+
|
|
2044
|
+
Args:
|
|
2045
|
+
title_or_path: A window title (``"bracket.SLDPRT"``), a full path,
|
|
2046
|
+
or a bare file name of a document that is already open.
|
|
2047
|
+
|
|
2048
|
+
Returns:
|
|
2049
|
+
AdapterResult[dict[str, Any]]: ``activated`` (the title switched
|
|
2050
|
+
to) and ``verified`` (bool, or ``None`` when the readback could
|
|
2051
|
+
not be performed). ``ERROR`` when not connected, the argument is
|
|
2052
|
+
blank, or nothing open matches.
|
|
2053
|
+
"""
|
|
2054
|
+
adapter = self._adapter(self)
|
|
2055
|
+
if not adapter.is_connected():
|
|
2056
|
+
return AdapterResult(
|
|
2057
|
+
status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
|
|
2058
|
+
)
|
|
2059
|
+
target = (title_or_path or "").strip()
|
|
2060
|
+
if not target:
|
|
2061
|
+
return AdapterResult(
|
|
2062
|
+
status=AdapterResultStatus.ERROR, error="title_or_path is required"
|
|
2063
|
+
)
|
|
2064
|
+
|
|
2065
|
+
def _activate() -> dict[str, Any]:
|
|
2066
|
+
app = adapter.swApp
|
|
2067
|
+
|
|
2068
|
+
docs = _coerce_dispatch_sequence(
|
|
2069
|
+
adapter._attempt(lambda: app.GetDocuments(), default=None)
|
|
2070
|
+
)
|
|
2071
|
+
needle = target.lower()
|
|
2072
|
+
match: Any = None
|
|
2073
|
+
match_title: str | None = None
|
|
2074
|
+
open_titles: list[str] = []
|
|
2075
|
+
for doc in docs:
|
|
2076
|
+
title = adapter._attempt(
|
|
2077
|
+
lambda d=doc: adapter._get_attr_or_call(d, "GetTitle"),
|
|
2078
|
+
default=None,
|
|
2079
|
+
)
|
|
2080
|
+
path = adapter._attempt(
|
|
2081
|
+
lambda d=doc: adapter._get_attr_or_call(d, "GetPathName"),
|
|
2082
|
+
default=None,
|
|
2083
|
+
)
|
|
2084
|
+
if title:
|
|
2085
|
+
open_titles.append(str(title))
|
|
2086
|
+
candidates = {
|
|
2087
|
+
str(title).lower() if title else "",
|
|
2088
|
+
str(path).lower() if path else "",
|
|
2089
|
+
Path(str(path)).name.lower() if path else "",
|
|
2090
|
+
}
|
|
2091
|
+
candidates.discard("")
|
|
2092
|
+
if needle in candidates:
|
|
2093
|
+
match = doc
|
|
2094
|
+
match_title = str(title) if title else None
|
|
2095
|
+
break
|
|
2096
|
+
|
|
2097
|
+
if match is None:
|
|
2098
|
+
raise Exception(
|
|
2099
|
+
f"No open document matches {target!r}. Open: "
|
|
2100
|
+
+ (", ".join(open_titles) or "none")
|
|
2101
|
+
)
|
|
2102
|
+
if not match_title:
|
|
2103
|
+
match_title = adapter._attempt(
|
|
2104
|
+
lambda: adapter._get_attr_or_call(match, "GetTitle"), default=None
|
|
2105
|
+
)
|
|
2106
|
+
if not match_title:
|
|
2107
|
+
raise Exception(
|
|
2108
|
+
"Matched an open document but could not read its title to "
|
|
2109
|
+
"activate it"
|
|
2110
|
+
)
|
|
2111
|
+
|
|
2112
|
+
activated = adapter._attempt(
|
|
2113
|
+
lambda: app.ActivateDoc3(match_title, False, 0, _byref_int()),
|
|
2114
|
+
default=None,
|
|
2115
|
+
)
|
|
2116
|
+
model = activated or adapter._attempt(
|
|
2117
|
+
lambda: getattr(app, "ActiveDoc", None), default=None
|
|
2118
|
+
)
|
|
2119
|
+
if model is not None:
|
|
2120
|
+
type_raw = adapter._attempt(
|
|
2121
|
+
lambda: adapter._get_attr_or_call(model, "GetType"), default=1
|
|
2122
|
+
)
|
|
2123
|
+
adapter._attempt(
|
|
2124
|
+
lambda: _sw_type_info.flag_doc(
|
|
2125
|
+
model,
|
|
2126
|
+
int(type_raw) if isinstance(type_raw, (int, float)) else 1,
|
|
2127
|
+
),
|
|
2128
|
+
default=0,
|
|
2129
|
+
)
|
|
2130
|
+
adapter.currentModel = model
|
|
2131
|
+
|
|
2132
|
+
active_after = adapter._attempt(
|
|
2133
|
+
lambda: getattr(app, "ActiveDoc", None), default=None
|
|
2134
|
+
)
|
|
2135
|
+
now_title = (
|
|
2136
|
+
adapter._attempt(
|
|
2137
|
+
lambda: adapter._get_attr_or_call(active_after, "GetTitle"),
|
|
2138
|
+
default=None,
|
|
2139
|
+
)
|
|
2140
|
+
if active_after is not None
|
|
2141
|
+
else None
|
|
2142
|
+
)
|
|
2143
|
+
if now_title is None:
|
|
2144
|
+
verified: bool | None = None
|
|
2145
|
+
else:
|
|
2146
|
+
verified = str(now_title) == str(match_title)
|
|
2147
|
+
if not verified:
|
|
2148
|
+
raise Exception(
|
|
2149
|
+
f"Requested {match_title!r} but ActiveDoc is "
|
|
2150
|
+
f"{now_title!r} after ActivateDoc3; activation did not take."
|
|
2151
|
+
)
|
|
2152
|
+
return {"activated": str(match_title), "verified": verified}
|
|
2153
|
+
|
|
2154
|
+
return cast(
|
|
2155
|
+
AdapterResult[dict[str, Any]],
|
|
2156
|
+
adapter._handle_com_operation("activate_document", _activate),
|
|
2157
|
+
)
|
|
2158
|
+
|
|
2159
|
+
async def list_configurations(self) -> AdapterResult[list[str]]:
|
|
2160
|
+
"""List all configuration names on the active model.
|
|
2161
|
+
|
|
2162
|
+
Returns:
|
|
2163
|
+
AdapterResult[list[str]]: Configuration names, or empty list when unavailable.
|
|
2164
|
+
"""
|
|
2165
|
+
adapter = self._adapter(self)
|
|
2166
|
+
if not adapter.currentModel:
|
|
2167
|
+
return AdapterResult(
|
|
2168
|
+
status=AdapterResultStatus.ERROR,
|
|
2169
|
+
error="No active model",
|
|
2170
|
+
)
|
|
2171
|
+
|
|
2172
|
+
def _list() -> list[str]:
|
|
2173
|
+
"""List configurations."""
|
|
2174
|
+
raw_names = getattr(adapter.currentModel, "GetConfigurationNames", None)
|
|
2175
|
+
names = raw_names() if callable(raw_names) else raw_names
|
|
2176
|
+
if names is None:
|
|
2177
|
+
names = []
|
|
2178
|
+
if isinstance(names, str):
|
|
2179
|
+
return [names]
|
|
2180
|
+
|
|
2181
|
+
normalized_names = [str(name) for name in names]
|
|
2182
|
+
if normalized_names:
|
|
2183
|
+
return normalized_names
|
|
2184
|
+
|
|
2185
|
+
active_config = adapter._attempt(
|
|
2186
|
+
lambda: adapter.currentModel.GetActiveConfiguration(), default=None
|
|
2187
|
+
)
|
|
2188
|
+
active_name = adapter._attempt(
|
|
2189
|
+
lambda: active_config.GetName(), default=None
|
|
2190
|
+
)
|
|
2191
|
+
if active_name:
|
|
2192
|
+
return [str(active_name)]
|
|
2193
|
+
return []
|
|
2194
|
+
|
|
2195
|
+
return cast(
|
|
2196
|
+
AdapterResult[list[str]],
|
|
2197
|
+
adapter._handle_com_operation("list_configurations", _list),
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
async def get_mass_properties(self) -> AdapterResult[MassProperties]:
|
|
2201
|
+
"""Get mass properties for the active model.
|
|
2202
|
+
|
|
2203
|
+
Returns:
|
|
2204
|
+
AdapterResult[MassProperties]: Computed mass, volume, area, COM, and inertia.
|
|
2205
|
+
"""
|
|
2206
|
+
adapter = self._adapter(self)
|
|
2207
|
+
if not adapter.currentModel:
|
|
2208
|
+
return AdapterResult(
|
|
2209
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
2210
|
+
)
|
|
2211
|
+
|
|
2212
|
+
def _get() -> MassProperties:
|
|
2213
|
+
"""Get mass properties."""
|
|
2214
|
+
adapter._attempt(
|
|
2215
|
+
lambda: adapter.currentModel.ForceRebuild3(False), default=None
|
|
2216
|
+
)
|
|
2217
|
+
|
|
2218
|
+
# Primary: Extension.CreateMassProperty() object API (most detailed)
|
|
2219
|
+
mass_props = adapter._attempt(
|
|
2220
|
+
lambda: adapter.currentModel.Extension.CreateMassProperty(),
|
|
2221
|
+
default=None,
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
if mass_props:
|
|
2225
|
+
volume = mass_props.Volume * 1e9
|
|
2226
|
+
surface_area = mass_props.SurfaceArea * 1e6
|
|
2227
|
+
mass = mass_props.Mass
|
|
2228
|
+
|
|
2229
|
+
center_of_mass = [0.0, 0.0, 0.0]
|
|
2230
|
+
com = adapter._attempt(lambda: mass_props.CenterOfMass, default=None)
|
|
2231
|
+
if isinstance(com, (list, tuple)) and len(com) >= 3:
|
|
2232
|
+
center_of_mass = [com[0] * 1000, com[1] * 1000, com[2] * 1000]
|
|
2233
|
+
|
|
2234
|
+
moi = adapter._attempt(
|
|
2235
|
+
lambda: mass_props.GetMomentOfInertia(0), default=None
|
|
2236
|
+
)
|
|
2237
|
+
if not isinstance(moi, (list, tuple)) or len(moi) < 9:
|
|
2238
|
+
moi = [0.0] * 9
|
|
2239
|
+
else:
|
|
2240
|
+
# Fallback: GetMassProperties as attribute (tuple) or callable (SW 2022)
|
|
2241
|
+
gmp = getattr(adapter.currentModel, "GetMassProperties", None)
|
|
2242
|
+
if callable(gmp):
|
|
2243
|
+
raw = adapter._attempt(gmp, default=None)
|
|
2244
|
+
elif isinstance(gmp, (list, tuple)):
|
|
2245
|
+
raw = gmp
|
|
2246
|
+
else:
|
|
2247
|
+
raw = None
|
|
2248
|
+
|
|
2249
|
+
if not isinstance(raw, (list, tuple)) or len(raw) < 6:
|
|
2250
|
+
raise Exception("Failed to get mass properties")
|
|
2251
|
+
|
|
2252
|
+
center_of_mass = [
|
|
2253
|
+
raw[0] * 1000.0,
|
|
2254
|
+
raw[1] * 1000.0,
|
|
2255
|
+
raw[2] * 1000.0,
|
|
2256
|
+
]
|
|
2257
|
+
volume = raw[3] * 1e9
|
|
2258
|
+
surface_area = raw[4] * 1e6
|
|
2259
|
+
mass = raw[5]
|
|
2260
|
+
|
|
2261
|
+
moi = [0.0] * 9
|
|
2262
|
+
if len(raw) >= 12:
|
|
2263
|
+
moi[0] = raw[6]
|
|
2264
|
+
moi[4] = raw[7]
|
|
2265
|
+
moi[8] = raw[8]
|
|
2266
|
+
moi[1] = raw[9]
|
|
2267
|
+
moi[5] = raw[10]
|
|
2268
|
+
moi[2] = raw[11]
|
|
2269
|
+
|
|
2270
|
+
return MassProperties(
|
|
2271
|
+
volume=volume,
|
|
2272
|
+
surface_area=surface_area,
|
|
2273
|
+
mass=mass,
|
|
2274
|
+
center_of_mass=center_of_mass,
|
|
2275
|
+
moments_of_inertia={
|
|
2276
|
+
"Ixx": moi[0],
|
|
2277
|
+
"Iyy": moi[4],
|
|
2278
|
+
"Izz": moi[8],
|
|
2279
|
+
"Ixy": moi[1],
|
|
2280
|
+
"Ixz": moi[2],
|
|
2281
|
+
"Iyz": moi[5],
|
|
2282
|
+
},
|
|
2283
|
+
)
|
|
2284
|
+
|
|
2285
|
+
return cast(
|
|
2286
|
+
AdapterResult[MassProperties],
|
|
2287
|
+
adapter._handle_com_operation("get_mass_properties", _get),
|
|
2288
|
+
)
|
|
2289
|
+
|
|
2290
|
+
async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]:
|
|
2291
|
+
"""Summarise the active part or assembly without rebuilding it.
|
|
2292
|
+
|
|
2293
|
+
Unlike ``get_mass_properties`` this never calls ``ForceRebuild3``, so
|
|
2294
|
+
it leaves the live selection intact (COM pitfall #23) and can be read
|
|
2295
|
+
right before a feature call. A value that cannot be read is ``None``.
|
|
2296
|
+
|
|
2297
|
+
Returns:
|
|
2298
|
+
AdapterResult[dict[str, Any]]: See
|
|
2299
|
+
``SolidWorksAdapter.get_model_geometry_summary``.
|
|
2300
|
+
"""
|
|
2301
|
+
adapter = self._adapter(self)
|
|
2302
|
+
if not adapter.currentModel:
|
|
2303
|
+
return AdapterResult(
|
|
2304
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
2305
|
+
)
|
|
2306
|
+
doc_type = _doc_type(adapter)
|
|
2307
|
+
if doc_type not in (1, 2):
|
|
2308
|
+
return AdapterResult(
|
|
2309
|
+
status=AdapterResultStatus.ERROR,
|
|
2310
|
+
error=(
|
|
2311
|
+
"get_model_geometry_summary needs an active part or assembly "
|
|
2312
|
+
f"(active document type is {doc_type!r})"
|
|
2313
|
+
),
|
|
2314
|
+
)
|
|
2315
|
+
|
|
2316
|
+
def _summarise() -> dict[str, Any]:
|
|
2317
|
+
model = adapter.currentModel
|
|
2318
|
+
summary: dict[str, Any] = {}
|
|
2319
|
+
if doc_type == 1:
|
|
2320
|
+
part = _sw_type_info.flagged(model, "IPartDoc")
|
|
2321
|
+
bodies = _solid_bodies(adapter, part)
|
|
2322
|
+
volume = _bodies_volume_mm3(adapter, bodies)
|
|
2323
|
+
summary["volume_mm3"] = (
|
|
2324
|
+
volume if volume is not None else _model_volume_mm3(adapter, model)
|
|
2325
|
+
)
|
|
2326
|
+
box = _extreme_point_box(adapter, bodies or [])
|
|
2327
|
+
if box is None:
|
|
2328
|
+
box = _box_from_metres(
|
|
2329
|
+
adapter._attempt(lambda: part.GetPartBox(True), default=None),
|
|
2330
|
+
"part_box",
|
|
2331
|
+
)
|
|
2332
|
+
summary["document_type"] = "part"
|
|
2333
|
+
summary["solid_body_count"] = None if bodies is None else len(bodies)
|
|
2334
|
+
else:
|
|
2335
|
+
assembly = _sw_type_info.flagged(model, "IAssemblyDoc")
|
|
2336
|
+
summary["volume_mm3"] = _model_volume_mm3(adapter, model)
|
|
2337
|
+
box = _box_from_metres(
|
|
2338
|
+
adapter._attempt(lambda: assembly.GetBox(0), default=None),
|
|
2339
|
+
"assembly_box",
|
|
2340
|
+
)
|
|
2341
|
+
summary["document_type"] = "assembly"
|
|
2342
|
+
summary["solid_body_count"] = None
|
|
2343
|
+
summary["component_count"] = len(_component_names(adapter, assembly))
|
|
2344
|
+
summary["mate_count"] = _assembly_mate_count(adapter, model)
|
|
2345
|
+
summary["bounding_box_method"] = box.pop("method") if box else None
|
|
2346
|
+
summary["bounding_box_mm"] = box
|
|
2347
|
+
return summary
|
|
2348
|
+
|
|
2349
|
+
return cast(
|
|
2350
|
+
AdapterResult[dict[str, Any]],
|
|
2351
|
+
adapter._handle_com_operation("get_model_geometry_summary", _summarise),
|
|
2352
|
+
)
|
|
2353
|
+
|
|
2354
|
+
async def pack_and_go_assembly(
|
|
2355
|
+
self,
|
|
2356
|
+
source_path: str,
|
|
2357
|
+
target_dir: str,
|
|
2358
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2359
|
+
"""Copy an assembly and all its referenced components to a self-contained folder.
|
|
2360
|
+
|
|
2361
|
+
Uses ``IModelDocExtension.GetPackAndGo()`` → ``IPackAndGo`` via the
|
|
2362
|
+
comtypes vtable interface (bypassing the broken IDispatch path present
|
|
2363
|
+
in SolidWorks 2026's late-binding layer), then calls
|
|
2364
|
+
``IModelDocExtension.SavePackAndGo()`` to execute the copy. All file
|
|
2365
|
+
paths inside the copied assembly are automatically updated by
|
|
2366
|
+
SolidWorks — this is the native Pack-and-Go mechanism.
|
|
2367
|
+
|
|
2368
|
+
Args:
|
|
2369
|
+
source_path: Absolute path to the source ``.sldasm`` file.
|
|
2370
|
+
target_dir: Directory where the assembly and parts will be copied.
|
|
2371
|
+
Created if it does not exist.
|
|
2372
|
+
|
|
2373
|
+
Returns:
|
|
2374
|
+
AdapterResult[dict]: On success, ``data`` is a dict with keys:
|
|
2375
|
+
``source_assembly``, ``target_dir``, ``copied_files``,
|
|
2376
|
+
``source_files``, ``save_statuses``, and ``all_files_saved``.
|
|
2377
|
+
"""
|
|
2378
|
+
adapter = self._adapter(self)
|
|
2379
|
+
source = Path(source_path)
|
|
2380
|
+
out_dir = Path(target_dir)
|
|
2381
|
+
|
|
2382
|
+
def _do_pack_and_go() -> dict[str, Any]:
|
|
2383
|
+
# Load comtypes TLB (cached after first call)
|
|
2384
|
+
sw_lib = _get_sw_comtypes_lib()
|
|
2385
|
+
if sw_lib is None:
|
|
2386
|
+
raise RuntimeError(
|
|
2387
|
+
"comtypes SolidWorks type library not available. "
|
|
2388
|
+
"Ensure comtypes is installed and SolidWorks is registered."
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
# Prepare a clean target directory. SW holds file locks on previously
|
|
2392
|
+
# opened assemblies so rmtree raises WinError 32. We rename the old
|
|
2393
|
+
# dir aside (Windows allows rename with open handles) and delete the
|
|
2394
|
+
# backup afterwards; if rename also fails we just proceed and let SW
|
|
2395
|
+
# overwrite existing files.
|
|
2396
|
+
if out_dir.exists():
|
|
2397
|
+
backup = out_dir.parent / f"{out_dir.name}_bak_{uuid.uuid4().hex[:8]}"
|
|
2398
|
+
try:
|
|
2399
|
+
os.rename(out_dir, backup)
|
|
2400
|
+
try:
|
|
2401
|
+
shutil.rmtree(backup)
|
|
2402
|
+
except Exception:
|
|
2403
|
+
pass # best-effort cleanup; stale backup is harmless
|
|
2404
|
+
except OSError:
|
|
2405
|
+
pass # rename also failed — proceed; SW will overwrite files
|
|
2406
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
2407
|
+
|
|
2408
|
+
# Open the source assembly
|
|
2409
|
+
err, warn = _byref_int(), _byref_int()
|
|
2410
|
+
model = adapter.swApp.OpenDoc6(str(source), 2, 1, "", err, warn)
|
|
2411
|
+
if model is None and err.value == 65536:
|
|
2412
|
+
adapter.swApp.CloseAllDocuments(False)
|
|
2413
|
+
err, warn = _byref_int(), _byref_int()
|
|
2414
|
+
model = adapter.swApp.OpenDoc6(str(source), 2, 1, "", err, warn)
|
|
2415
|
+
if model is None:
|
|
2416
|
+
raise RuntimeError(f"OpenDoc6 failed err={err.value} warn={warn.value}")
|
|
2417
|
+
_sw_type_info.flag_doc(model, 2)
|
|
2418
|
+
adapter.currentModel = model
|
|
2419
|
+
|
|
2420
|
+
# Bridge model.Extension → IModelDocExtension via comtypes vtable
|
|
2421
|
+
ext_ct = _bridge_com_to_comtypes(model.Extension, sw_lib.IModelDocExtension)
|
|
2422
|
+
|
|
2423
|
+
# GetPackAndGo() via vtable (IDispatch path broken in SW 2026)
|
|
2424
|
+
pg = ext_ct.GetPackAndGo()
|
|
2425
|
+
|
|
2426
|
+
# Configure: flatten all files to root of target directory
|
|
2427
|
+
pg.FlattenToSingleFolder = True
|
|
2428
|
+
pg.SetSaveToName(True, str(out_dir) + "\\")
|
|
2429
|
+
|
|
2430
|
+
# Record what files will be packed
|
|
2431
|
+
names_result = pg.GetDocumentNames()
|
|
2432
|
+
source_files: list[str] = list(names_result[0]) if names_result[0] else []
|
|
2433
|
+
|
|
2434
|
+
# Execute Pack and Go — SavePackAndGo returns a tuple of per-file status codes
|
|
2435
|
+
ext_ct2 = _bridge_com_to_comtypes(
|
|
2436
|
+
model.Extension, sw_lib.IModelDocExtension
|
|
2437
|
+
)
|
|
2438
|
+
status_arr = ext_ct2.SavePackAndGo(pg)
|
|
2439
|
+
save_statuses: list[int] = list(status_arr) if status_arr else []
|
|
2440
|
+
|
|
2441
|
+
copied_files = sorted(
|
|
2442
|
+
str(p)
|
|
2443
|
+
for p in out_dir.rglob("*")
|
|
2444
|
+
if p.is_file() and p.suffix.lower() in {".sldasm", ".sldprt", ".slddrw"}
|
|
2445
|
+
)
|
|
2446
|
+
return {
|
|
2447
|
+
"source_assembly": str(source),
|
|
2448
|
+
"target_dir": str(out_dir),
|
|
2449
|
+
"copied_files": copied_files,
|
|
2450
|
+
"source_files": source_files,
|
|
2451
|
+
"save_statuses": save_statuses,
|
|
2452
|
+
"all_files_saved": all(s == 0 for s in save_statuses),
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
result = adapter._handle_com_operation("pack_and_go_assembly", _do_pack_and_go)
|
|
2456
|
+
if not result.is_success:
|
|
2457
|
+
return AdapterResult(
|
|
2458
|
+
status=AdapterResultStatus.ERROR,
|
|
2459
|
+
error=f"Pack and Go failed: {result.error}",
|
|
2460
|
+
)
|
|
2461
|
+
return AdapterResult(
|
|
2462
|
+
status=AdapterResultStatus.SUCCESS,
|
|
2463
|
+
data=result.data,
|
|
2464
|
+
execution_time=result.execution_time,
|
|
2465
|
+
)
|
|
2466
|
+
|
|
2467
|
+
async def insert_component(
|
|
2468
|
+
self, file_path: str, x: float = 0.0, y: float = 0.0, z: float = 0.0
|
|
2469
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2470
|
+
"""Insert a part or sub-assembly into the active assembly.
|
|
2471
|
+
|
|
2472
|
+
Wraps ``IAssemblyDoc::AddComponent4(CompName, ConfigName, X, Y, Z)``,
|
|
2473
|
+
falling back to ``AddComponent5``. Position is in **millimetres**.
|
|
2474
|
+
|
|
2475
|
+
**The component file must contain solid geometry.** SolidWorks
|
|
2476
|
+
silently refuses to insert an empty part — every overload returns
|
|
2477
|
+
``None`` and the component count stays put. That behaviour is what
|
|
2478
|
+
made this look unimplementable until the ``save_file`` bug that was
|
|
2479
|
+
writing empty parts got fixed.
|
|
2480
|
+
|
|
2481
|
+
Success is confirmed by the assembly's component count going up, since
|
|
2482
|
+
``AddComponent*`` gives no usable failure signal.
|
|
2483
|
+
|
|
2484
|
+
Args:
|
|
2485
|
+
file_path (str): Absolute path to the ``.sldprt`` or ``.sldasm``.
|
|
2486
|
+
x (float): X position in millimetres.
|
|
2487
|
+
y (float): Y position in millimetres.
|
|
2488
|
+
z (float): Z position in millimetres.
|
|
2489
|
+
|
|
2490
|
+
Returns:
|
|
2491
|
+
AdapterResult[dict[str, Any]]: Component name and before/after
|
|
2492
|
+
counts. ``ERROR`` when the active document is not an assembly, the
|
|
2493
|
+
file is missing, or nothing was inserted.
|
|
2494
|
+
|
|
2495
|
+
Raises:
|
|
2496
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2497
|
+
|
|
2498
|
+
Example::
|
|
2499
|
+
|
|
2500
|
+
await adapter.insert_component(r"C:\\parts\\bracket.sldprt", 0, 0, 0)
|
|
2501
|
+
"""
|
|
2502
|
+
adapter = self._adapter(self)
|
|
2503
|
+
if not adapter.currentModel:
|
|
2504
|
+
return AdapterResult(
|
|
2505
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
2506
|
+
)
|
|
2507
|
+
|
|
2508
|
+
path = os.path.abspath(file_path)
|
|
2509
|
+
if not os.path.exists(path):
|
|
2510
|
+
return AdapterResult(
|
|
2511
|
+
status=AdapterResultStatus.ERROR,
|
|
2512
|
+
error=f"Component file not found: {file_path}",
|
|
2513
|
+
)
|
|
2514
|
+
|
|
2515
|
+
doc_type = _doc_type(adapter)
|
|
2516
|
+
if doc_type != 2:
|
|
2517
|
+
return AdapterResult(
|
|
2518
|
+
status=AdapterResultStatus.ERROR,
|
|
2519
|
+
error=(
|
|
2520
|
+
"insert_component requires an assembly document "
|
|
2521
|
+
f"(active document type is {doc_type!r}, expected 2). "
|
|
2522
|
+
"Call create_assembly first."
|
|
2523
|
+
),
|
|
2524
|
+
)
|
|
2525
|
+
|
|
2526
|
+
def _insert() -> dict[str, Any]:
|
|
2527
|
+
assembly = _sw_type_info.flagged(adapter.currentModel, "IAssemblyDoc")
|
|
2528
|
+
before = _component_names(adapter, assembly)
|
|
2529
|
+
|
|
2530
|
+
# The document has to be loaded before it can be inserted, and the
|
|
2531
|
+
# errors/warnings out-parameters must be byref VARIANTs: with
|
|
2532
|
+
# pythoncom.Missing OpenDoc6 returns None and the part stays
|
|
2533
|
+
# unloaded, after which every AddComponent overload does nothing.
|
|
2534
|
+
app = adapter.swApp
|
|
2535
|
+
opened = adapter._attempt(
|
|
2536
|
+
lambda: app.OpenDoc6(
|
|
2537
|
+
path,
|
|
2538
|
+
2 if path.lower().endswith(".sldasm") else 1,
|
|
2539
|
+
1,
|
|
2540
|
+
"",
|
|
2541
|
+
_byref_int(),
|
|
2542
|
+
_byref_int(),
|
|
2543
|
+
),
|
|
2544
|
+
default=None,
|
|
2545
|
+
)
|
|
2546
|
+
if not opened:
|
|
2547
|
+
raise Exception(
|
|
2548
|
+
f"Could not load '{file_path}' - OpenDoc6 returned nothing."
|
|
2549
|
+
)
|
|
2550
|
+
|
|
2551
|
+
title = adapter._attempt(
|
|
2552
|
+
lambda: _sw_type_info.flagged(
|
|
2553
|
+
adapter.currentModel, "IModelDoc2"
|
|
2554
|
+
).GetTitle(),
|
|
2555
|
+
default=None,
|
|
2556
|
+
)
|
|
2557
|
+
if title:
|
|
2558
|
+
adapter._attempt(
|
|
2559
|
+
lambda: app.ActivateDoc3(title, False, 0, _byref_int()),
|
|
2560
|
+
default=None,
|
|
2561
|
+
)
|
|
2562
|
+
|
|
2563
|
+
component = adapter._attempt(
|
|
2564
|
+
lambda: assembly.AddComponent4(
|
|
2565
|
+
path, "", x / 1000.0, y / 1000.0, z / 1000.0
|
|
2566
|
+
),
|
|
2567
|
+
default=None,
|
|
2568
|
+
)
|
|
2569
|
+
if component is None:
|
|
2570
|
+
component = adapter._attempt(
|
|
2571
|
+
lambda: assembly.AddComponent5(
|
|
2572
|
+
path,
|
|
2573
|
+
0,
|
|
2574
|
+
"",
|
|
2575
|
+
False,
|
|
2576
|
+
"",
|
|
2577
|
+
x / 1000.0,
|
|
2578
|
+
y / 1000.0,
|
|
2579
|
+
z / 1000.0,
|
|
2580
|
+
),
|
|
2581
|
+
default=None,
|
|
2582
|
+
)
|
|
2583
|
+
|
|
2584
|
+
adapter._attempt(lambda: assembly.EditRebuild3(), default=None)
|
|
2585
|
+
|
|
2586
|
+
after = _component_names(adapter, assembly)
|
|
2587
|
+
if len(after) <= len(before):
|
|
2588
|
+
raise Exception(
|
|
2589
|
+
f"Component was not inserted - the assembly still has "
|
|
2590
|
+
f"{len(after)} component(s). The most common cause is a "
|
|
2591
|
+
f"part with no solid geometry: SolidWorks refuses those "
|
|
2592
|
+
f"silently. Check '{file_path}' opens with a body."
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
added = [n for n in after if n not in before]
|
|
2596
|
+
return {
|
|
2597
|
+
"component": added[-1] if added else after[-1],
|
|
2598
|
+
"file_path": path,
|
|
2599
|
+
"position": {"x": x, "y": y, "z": z},
|
|
2600
|
+
"components_before": len(before),
|
|
2601
|
+
"components_after": len(after),
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
return cast(
|
|
2605
|
+
AdapterResult[dict[str, Any]],
|
|
2606
|
+
adapter._handle_com_operation("insert_component", _insert),
|
|
2607
|
+
)
|
|
2608
|
+
|
|
2609
|
+
async def add_mate(
|
|
2610
|
+
self,
|
|
2611
|
+
component_a: str,
|
|
2612
|
+
component_b: str,
|
|
2613
|
+
entity_a: str = "Front Plane",
|
|
2614
|
+
entity_b: str = "Front Plane",
|
|
2615
|
+
mate_type: str = "coincident",
|
|
2616
|
+
alignment: str = "aligned",
|
|
2617
|
+
distance: float = 0.0,
|
|
2618
|
+
angle: float = 0.0,
|
|
2619
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2620
|
+
"""Mate two components together.
|
|
2621
|
+
|
|
2622
|
+
Wraps ``IAssemblyDoc::AddMate5``. The two entities are selected via
|
|
2623
|
+
``IComponent2::FeatureByName`` + ``IFeature::Select2`` rather than
|
|
2624
|
+
``SelectByID2``, which raises ``Type mismatch`` on this build.
|
|
2625
|
+
|
|
2626
|
+
That restricts the entities to *named tree features* — the reference
|
|
2627
|
+
planes and axes of each component. Plane-to-plane mating covers
|
|
2628
|
+
alignment and stacking, which is the common case; mating to a specific
|
|
2629
|
+
face or edge needs entity names this adapter cannot enumerate.
|
|
2630
|
+
|
|
2631
|
+
Args:
|
|
2632
|
+
component_a (str): First component instance name, as reported by
|
|
2633
|
+
:meth:`list_components`.
|
|
2634
|
+
component_b (str): Second component instance name.
|
|
2635
|
+
entity_a (str): Named feature on the first component.
|
|
2636
|
+
entity_b (str): Named feature on the second component.
|
|
2637
|
+
mate_type (str): ``coincident``, ``concentric``, ``perpendicular``,
|
|
2638
|
+
``parallel``, ``tangent``, ``distance`` or ``angle``.
|
|
2639
|
+
alignment (str): ``aligned``, ``anti_aligned`` or ``closest``.
|
|
2640
|
+
distance (float): Distance in millimetres, for a distance mate.
|
|
2641
|
+
angle (float): Angle in degrees, for an angle mate.
|
|
2642
|
+
|
|
2643
|
+
Returns:
|
|
2644
|
+
AdapterResult[dict[str, Any]]: The mate created, plus the bounding
|
|
2645
|
+
box before and after so the caller can see what moved. ``ERROR``
|
|
2646
|
+
when the entities cannot be selected or SolidWorks rejects the
|
|
2647
|
+
mate.
|
|
2648
|
+
|
|
2649
|
+
Raises:
|
|
2650
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2651
|
+
|
|
2652
|
+
Example::
|
|
2653
|
+
|
|
2654
|
+
await adapter.add_mate("plate-1", "plate-2")
|
|
2655
|
+
"""
|
|
2656
|
+
adapter = self._adapter(self)
|
|
2657
|
+
if _doc_type(adapter) != 2:
|
|
2658
|
+
return AdapterResult(
|
|
2659
|
+
status=AdapterResultStatus.ERROR,
|
|
2660
|
+
error="add_mate requires an assembly document",
|
|
2661
|
+
)
|
|
2662
|
+
|
|
2663
|
+
mate_key = str(mate_type).strip().lower()
|
|
2664
|
+
if mate_key not in _MATE_TYPES:
|
|
2665
|
+
return AdapterResult(
|
|
2666
|
+
status=AdapterResultStatus.ERROR,
|
|
2667
|
+
error=(
|
|
2668
|
+
f"Unknown mate type '{mate_type}'. "
|
|
2669
|
+
f"Use one of: {', '.join(sorted(_MATE_TYPES))}."
|
|
2670
|
+
),
|
|
2671
|
+
)
|
|
2672
|
+
align_key = str(alignment).strip().lower()
|
|
2673
|
+
if align_key not in _MATE_ALIGNMENTS:
|
|
2674
|
+
return AdapterResult(
|
|
2675
|
+
status=AdapterResultStatus.ERROR,
|
|
2676
|
+
error=(
|
|
2677
|
+
f"Unknown alignment '{alignment}'. "
|
|
2678
|
+
f"Use one of: {', '.join(sorted(_MATE_ALIGNMENTS))}."
|
|
2679
|
+
),
|
|
2680
|
+
)
|
|
2681
|
+
|
|
2682
|
+
def _mate() -> dict[str, Any]:
|
|
2683
|
+
import math
|
|
2684
|
+
|
|
2685
|
+
model = adapter.currentModel
|
|
2686
|
+
assembly = _sw_type_info.flagged(model, "IAssemblyDoc")
|
|
2687
|
+
|
|
2688
|
+
components = adapter._attempt(
|
|
2689
|
+
lambda: assembly.GetComponents(True), default=None
|
|
2690
|
+
)
|
|
2691
|
+
if not isinstance(components, (list, tuple)):
|
|
2692
|
+
raise Exception("Could not read the assembly's components")
|
|
2693
|
+
|
|
2694
|
+
wanted = {component_a: entity_a, component_b: entity_b}
|
|
2695
|
+
found: dict[str, Any] = {}
|
|
2696
|
+
for component in components:
|
|
2697
|
+
wrapped = _as_com(adapter, component, "IComponent2")
|
|
2698
|
+
if wrapped is None:
|
|
2699
|
+
continue
|
|
2700
|
+
name = adapter._attempt(lambda w=wrapped: w.Name2, default=None)
|
|
2701
|
+
if name and str(name) in wanted:
|
|
2702
|
+
found[str(name)] = wrapped
|
|
2703
|
+
|
|
2704
|
+
missing = [n for n in (component_a, component_b) if n not in found]
|
|
2705
|
+
if missing:
|
|
2706
|
+
available = [
|
|
2707
|
+
str(
|
|
2708
|
+
adapter._attempt(
|
|
2709
|
+
lambda c=c: _as_com(adapter, c, "IComponent2").Name2,
|
|
2710
|
+
default="?",
|
|
2711
|
+
)
|
|
2712
|
+
)
|
|
2713
|
+
for c in components
|
|
2714
|
+
]
|
|
2715
|
+
raise Exception(
|
|
2716
|
+
f"Component(s) not found: {', '.join(missing)}. "
|
|
2717
|
+
f"The assembly holds: {', '.join(available)}."
|
|
2718
|
+
)
|
|
2719
|
+
|
|
2720
|
+
adapter._attempt(lambda: model.ClearSelection2(True), default=None)
|
|
2721
|
+
for index, component_name in enumerate((component_a, component_b)):
|
|
2722
|
+
wrapped = found[component_name]
|
|
2723
|
+
entity_name = wanted[component_name]
|
|
2724
|
+
feature = adapter._attempt(
|
|
2725
|
+
lambda w=wrapped, e=entity_name: w.FeatureByName(e), default=None
|
|
2726
|
+
)
|
|
2727
|
+
if feature is None:
|
|
2728
|
+
raise Exception(
|
|
2729
|
+
f"'{entity_name}' not found on {component_name}. "
|
|
2730
|
+
"Only named tree features (reference planes and axes) "
|
|
2731
|
+
"can be selected here."
|
|
2732
|
+
)
|
|
2733
|
+
flagged = _as_com(adapter, feature, "IFeature")
|
|
2734
|
+
if flagged is None or not adapter._attempt(
|
|
2735
|
+
lambda f=flagged, a=index > 0: f.Select2(a, 0), default=False
|
|
2736
|
+
):
|
|
2737
|
+
raise Exception(
|
|
2738
|
+
f"Failed to select '{entity_name}' on {component_name}"
|
|
2739
|
+
)
|
|
2740
|
+
|
|
2741
|
+
selected = adapter._attempt(
|
|
2742
|
+
lambda: model.SelectionManager.GetSelectedObjectCount2(-1), default=0
|
|
2743
|
+
)
|
|
2744
|
+
if selected != 2:
|
|
2745
|
+
raise Exception(
|
|
2746
|
+
f"Expected 2 selected entities for the mate, got {selected}"
|
|
2747
|
+
)
|
|
2748
|
+
|
|
2749
|
+
transforms_before = _component_transforms(adapter, assembly)
|
|
2750
|
+
status = _byref_int()
|
|
2751
|
+
mate = adapter._attempt(
|
|
2752
|
+
lambda: assembly.AddMate5(
|
|
2753
|
+
_MATE_TYPES[mate_key],
|
|
2754
|
+
_MATE_ALIGNMENTS[align_key],
|
|
2755
|
+
False, # Flip
|
|
2756
|
+
distance / 1000.0, # Distance (m)
|
|
2757
|
+
distance / 1000.0, # upper limit
|
|
2758
|
+
distance / 1000.0, # lower limit
|
|
2759
|
+
0.0, # gear ratio numerator
|
|
2760
|
+
0.0, # gear ratio denominator
|
|
2761
|
+
math.radians(float(angle)),
|
|
2762
|
+
math.radians(float(angle)),
|
|
2763
|
+
math.radians(float(angle)),
|
|
2764
|
+
False, # ForPositioningOnly
|
|
2765
|
+
False, # LockRotation
|
|
2766
|
+
0, # WidthMateOption
|
|
2767
|
+
status,
|
|
2768
|
+
),
|
|
2769
|
+
default=None,
|
|
2770
|
+
)
|
|
2771
|
+
adapter._attempt(lambda: model.EditRebuild3(), default=None)
|
|
2772
|
+
|
|
2773
|
+
error_status = getattr(status, "value", None)
|
|
2774
|
+
# swAddMateError_e reports 1 for success on this build (measured:
|
|
2775
|
+
# a mate that demonstrably moved a component returned 1).
|
|
2776
|
+
if mate is None or (error_status not in (None, 1)):
|
|
2777
|
+
raise Exception(
|
|
2778
|
+
f"SolidWorks rejected the {mate_key} mate "
|
|
2779
|
+
f"(error status {error_status!r}). Check the two entities "
|
|
2780
|
+
"can actually satisfy this mate type."
|
|
2781
|
+
)
|
|
2782
|
+
|
|
2783
|
+
transforms_after = _component_transforms(adapter, assembly)
|
|
2784
|
+
moved = sorted(
|
|
2785
|
+
name
|
|
2786
|
+
for name, matrix in transforms_after.items()
|
|
2787
|
+
if name in transforms_before and transforms_before[name] != matrix
|
|
2788
|
+
)
|
|
2789
|
+
# An empty snapshot means no transform could be read, which is
|
|
2790
|
+
# not the same as "nothing moved" - report it as unknown rather
|
|
2791
|
+
# than as a negative result the caller would read as fact.
|
|
2792
|
+
comparable = bool(transforms_before and transforms_after)
|
|
2793
|
+
return {
|
|
2794
|
+
"mate_type": mate_key,
|
|
2795
|
+
"alignment": align_key,
|
|
2796
|
+
"components": [component_a, component_b],
|
|
2797
|
+
"entities": [entity_a, entity_b],
|
|
2798
|
+
"distance": distance or None,
|
|
2799
|
+
"angle": angle or None,
|
|
2800
|
+
"moved_components": moved,
|
|
2801
|
+
"geometry_moved": bool(moved) if comparable else None,
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
return cast(
|
|
2805
|
+
AdapterResult[dict[str, Any]],
|
|
2806
|
+
adapter._handle_com_operation("add_mate", _mate),
|
|
2807
|
+
)
|
|
2808
|
+
|
|
2809
|
+
async def list_components(self) -> AdapterResult[list[str]]:
|
|
2810
|
+
"""List the top-level components of the active assembly.
|
|
2811
|
+
|
|
2812
|
+
Returns:
|
|
2813
|
+
AdapterResult[list[str]]: Component names, or an error when the
|
|
2814
|
+
active document is not an assembly.
|
|
2815
|
+
"""
|
|
2816
|
+
adapter = self._adapter(self)
|
|
2817
|
+
if not adapter.currentModel:
|
|
2818
|
+
return AdapterResult(
|
|
2819
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
2820
|
+
)
|
|
2821
|
+
doc_type = _doc_type(adapter)
|
|
2822
|
+
if doc_type != 2:
|
|
2823
|
+
return AdapterResult(
|
|
2824
|
+
status=AdapterResultStatus.ERROR,
|
|
2825
|
+
error=(
|
|
2826
|
+
"list_components requires an assembly document "
|
|
2827
|
+
f"(active document type is {doc_type!r}, expected 2)"
|
|
2828
|
+
),
|
|
2829
|
+
)
|
|
2830
|
+
|
|
2831
|
+
def _list() -> list[str]:
|
|
2832
|
+
assembly = _sw_type_info.flagged(adapter.currentModel, "IAssemblyDoc")
|
|
2833
|
+
return _component_names(adapter, assembly)
|
|
2834
|
+
|
|
2835
|
+
return cast(
|
|
2836
|
+
AdapterResult[list[str]],
|
|
2837
|
+
adapter._handle_com_operation("list_components", _list),
|
|
2838
|
+
)
|
|
2839
|
+
|
|
2840
|
+
def _require_drawing(self) -> AdapterResult[Any] | None:
|
|
2841
|
+
"""Return an error result unless the active document is a drawing.
|
|
2842
|
+
|
|
2843
|
+
Returns:
|
|
2844
|
+
AdapterResult[Any] | None: ``None`` when the active document is a
|
|
2845
|
+
drawing, otherwise the error to hand back.
|
|
2846
|
+
"""
|
|
2847
|
+
adapter = self._adapter(self)
|
|
2848
|
+
if not adapter.currentModel:
|
|
2849
|
+
return AdapterResult(
|
|
2850
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
2851
|
+
)
|
|
2852
|
+
doc_type = _doc_type(adapter)
|
|
2853
|
+
if doc_type != 3:
|
|
2854
|
+
return AdapterResult(
|
|
2855
|
+
status=AdapterResultStatus.ERROR,
|
|
2856
|
+
error=(
|
|
2857
|
+
"This operation requires a drawing document "
|
|
2858
|
+
f"(active document type is {doc_type!r}, expected 3). "
|
|
2859
|
+
"Call create_drawing first."
|
|
2860
|
+
),
|
|
2861
|
+
)
|
|
2862
|
+
return None
|
|
2863
|
+
|
|
2864
|
+
def _place_view(self, payload: Any) -> AdapterResult[dict[str, Any]]:
|
|
2865
|
+
"""Place one named view of a model on the active drawing sheet.
|
|
2866
|
+
|
|
2867
|
+
Shared by ``create_drawing_view`` and ``add_drawing_view``, which are
|
|
2868
|
+
two registered tools reaching the same operation through different
|
|
2869
|
+
input schemas.
|
|
2870
|
+
|
|
2871
|
+
Wraps ``IDrawingDoc::CreateDrawViewFromModelView3(ModelName, ViewName,
|
|
2872
|
+
LocX, LocY, LocZ)``. Position is accepted in millimetres and converted
|
|
2873
|
+
to metres. Success is confirmed by the sheet's view count going up:
|
|
2874
|
+
the call returns ``None`` for a model SolidWorks could not resolve, so
|
|
2875
|
+
its return value proves nothing on its own.
|
|
2876
|
+
|
|
2877
|
+
Args:
|
|
2878
|
+
payload: Tool payload. Reads ``model_path``/``model_file``,
|
|
2879
|
+
``orientation``/``view_type``, ``position_x``/``position_y``
|
|
2880
|
+
(or a two-element ``position``), and ``scale``.
|
|
2881
|
+
|
|
2882
|
+
Returns:
|
|
2883
|
+
AdapterResult[dict[str, Any]]: The new view's name and position.
|
|
2884
|
+
"""
|
|
2885
|
+
adapter = self._adapter(self)
|
|
2886
|
+
guard = self._require_drawing()
|
|
2887
|
+
if guard is not None:
|
|
2888
|
+
return cast("AdapterResult[dict[str, Any]]", guard)
|
|
2889
|
+
|
|
2890
|
+
data = _payload(payload)
|
|
2891
|
+
model_path = _first(data, "model_path", "model_file", "path")
|
|
2892
|
+
if not model_path:
|
|
2893
|
+
return AdapterResult(
|
|
2894
|
+
status=AdapterResultStatus.ERROR,
|
|
2895
|
+
error="A model path is required (model_path or model_file)",
|
|
2896
|
+
)
|
|
2897
|
+
|
|
2898
|
+
path = os.path.abspath(str(model_path))
|
|
2899
|
+
if not os.path.exists(path):
|
|
2900
|
+
return AdapterResult(
|
|
2901
|
+
status=AdapterResultStatus.ERROR,
|
|
2902
|
+
error=f"Model file not found: {model_path}",
|
|
2903
|
+
)
|
|
2904
|
+
|
|
2905
|
+
position = data.get("position")
|
|
2906
|
+
if isinstance(position, (list, tuple)) and len(position) >= 2:
|
|
2907
|
+
x, y = float(position[0]), float(position[1])
|
|
2908
|
+
else:
|
|
2909
|
+
x = float(_first(data, "position_x", "x", default=100.0))
|
|
2910
|
+
y = float(_first(data, "position_y", "y", default=150.0))
|
|
2911
|
+
|
|
2912
|
+
requested = str(_first(data, "orientation", "view_type", default="front"))
|
|
2913
|
+
view_name = _NAMED_VIEWS.get(requested.strip().lower())
|
|
2914
|
+
if view_name is None and requested.startswith("*"):
|
|
2915
|
+
view_name = requested
|
|
2916
|
+
if view_name is None:
|
|
2917
|
+
return AdapterResult(
|
|
2918
|
+
status=AdapterResultStatus.ERROR,
|
|
2919
|
+
error=(
|
|
2920
|
+
f"Unknown orientation '{requested}'. Use one of: "
|
|
2921
|
+
f"{', '.join(sorted(_NAMED_VIEWS))}, or a raw '*Name'."
|
|
2922
|
+
),
|
|
2923
|
+
)
|
|
2924
|
+
|
|
2925
|
+
try:
|
|
2926
|
+
scale = float(_first(data, "scale", default=0.0) or 0.0)
|
|
2927
|
+
except (TypeError, ValueError):
|
|
2928
|
+
# The DrawingCreationInput schema types scale as a string ratio
|
|
2929
|
+
# ("1:1"), which is not a view scale factor. Ignore it rather than
|
|
2930
|
+
# failing the whole call over a display preference.
|
|
2931
|
+
scale = 0.0
|
|
2932
|
+
|
|
2933
|
+
def _add() -> dict[str, Any]:
|
|
2934
|
+
drawing = _sw_type_info.flagged(adapter.currentModel, "IDrawingDoc")
|
|
2935
|
+
before = _view_names(adapter, drawing)
|
|
2936
|
+
|
|
2937
|
+
# The model has to be loaded before a view of it can be placed,
|
|
2938
|
+
# and OpenDoc6's errors/warnings out-params must be byref VARIANTs
|
|
2939
|
+
# - with pythoncom.Missing it returns None, the model stays
|
|
2940
|
+
# unloaded, and CreateDrawViewFromModelView3 then silently makes
|
|
2941
|
+
# no view.
|
|
2942
|
+
app = adapter.swApp
|
|
2943
|
+
doc_type = 2 if path.lower().endswith(".sldasm") else 1
|
|
2944
|
+
opened = adapter._attempt(
|
|
2945
|
+
lambda: app.OpenDoc6(path, doc_type, 1, "", _byref_int(), _byref_int()),
|
|
2946
|
+
default=None,
|
|
2947
|
+
)
|
|
2948
|
+
if not opened:
|
|
2949
|
+
raise Exception(
|
|
2950
|
+
f"Could not load '{model_path}' - OpenDoc6 returned nothing."
|
|
2951
|
+
)
|
|
2952
|
+
|
|
2953
|
+
# Opening the model made it active; the drawing has to be active
|
|
2954
|
+
# again before a view can be added to it.
|
|
2955
|
+
drawing_title = adapter._attempt(
|
|
2956
|
+
lambda: _sw_type_info.flagged(
|
|
2957
|
+
adapter.currentModel, "IModelDoc2"
|
|
2958
|
+
).GetTitle(),
|
|
2959
|
+
default=None,
|
|
2960
|
+
)
|
|
2961
|
+
if drawing_title:
|
|
2962
|
+
adapter._attempt(
|
|
2963
|
+
lambda: app.ActivateDoc3(drawing_title, False, 0, _byref_int()),
|
|
2964
|
+
default=None,
|
|
2965
|
+
)
|
|
2966
|
+
|
|
2967
|
+
view = adapter._attempt(
|
|
2968
|
+
lambda: drawing.CreateDrawViewFromModelView3(
|
|
2969
|
+
path, view_name, x / 1000.0, y / 1000.0, 0.0
|
|
2970
|
+
),
|
|
2971
|
+
default=None,
|
|
2972
|
+
)
|
|
2973
|
+
|
|
2974
|
+
after = _view_names(adapter, drawing)
|
|
2975
|
+
if len(after) <= len(before):
|
|
2976
|
+
raise Exception(
|
|
2977
|
+
f"View was not created - the sheet still has {len(after)} "
|
|
2978
|
+
f"view(s). Check that '{model_path}' opens on its own and "
|
|
2979
|
+
f"that '{view_name}' is a valid named view for it."
|
|
2980
|
+
)
|
|
2981
|
+
|
|
2982
|
+
added = [n for n in after if n not in before]
|
|
2983
|
+
new_view = added[-1] if added else after[-1]
|
|
2984
|
+
|
|
2985
|
+
scale_applied = False
|
|
2986
|
+
if scale and view is not None:
|
|
2987
|
+
wrapped = _as_com(adapter, view, "IView")
|
|
2988
|
+
if wrapped is not None:
|
|
2989
|
+
scale_applied = (
|
|
2990
|
+
adapter._attempt(
|
|
2991
|
+
lambda: setattr(wrapped, "ScaleRatio", [scale, 1.0]),
|
|
2992
|
+
default=None,
|
|
2993
|
+
)
|
|
2994
|
+
is not None
|
|
2995
|
+
)
|
|
2996
|
+
|
|
2997
|
+
return {
|
|
2998
|
+
"name": new_view,
|
|
2999
|
+
"model_path": path,
|
|
3000
|
+
"orientation": view_name,
|
|
3001
|
+
"position": {"x": x, "y": y},
|
|
3002
|
+
"scale": scale or None,
|
|
3003
|
+
"scale_applied": scale_applied if scale else None,
|
|
3004
|
+
"views_before": len(before),
|
|
3005
|
+
"views_after": len(after),
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
return cast(
|
|
3009
|
+
AdapterResult[dict[str, Any]],
|
|
3010
|
+
adapter._handle_com_operation("create_drawing_view", _add),
|
|
3011
|
+
)
|
|
3012
|
+
|
|
3013
|
+
async def create_drawing_view(
|
|
3014
|
+
self, payload: Any = None
|
|
3015
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3016
|
+
"""Place a view of a model on the active drawing sheet.
|
|
3017
|
+
|
|
3018
|
+
Args:
|
|
3019
|
+
payload: Tool payload; see ``_place_view``.
|
|
3020
|
+
|
|
3021
|
+
Returns:
|
|
3022
|
+
AdapterResult[dict[str, Any]]: The new view's name and position.
|
|
3023
|
+
|
|
3024
|
+
Example::
|
|
3025
|
+
|
|
3026
|
+
await adapter.create_drawing_view(
|
|
3027
|
+
{"model_path": r"C:\\parts\\bracket.sldprt", "orientation": "front"}
|
|
3028
|
+
)
|
|
3029
|
+
"""
|
|
3030
|
+
return self._place_view(payload)
|
|
3031
|
+
|
|
3032
|
+
async def add_drawing_view(
|
|
3033
|
+
self, payload: Any = None
|
|
3034
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3035
|
+
"""Add a view of a model to the active drawing sheet.
|
|
3036
|
+
|
|
3037
|
+
The same operation as ``create_drawing_view``; both tool entry points
|
|
3038
|
+
exist upstream and reach this one implementation.
|
|
3039
|
+
|
|
3040
|
+
Args:
|
|
3041
|
+
payload: Tool payload; see ``_place_view``.
|
|
3042
|
+
|
|
3043
|
+
Returns:
|
|
3044
|
+
AdapterResult[dict[str, Any]]: The new view's name and position.
|
|
3045
|
+
"""
|
|
3046
|
+
return self._place_view(payload)
|
|
3047
|
+
|
|
3048
|
+
async def create_technical_drawing(
|
|
3049
|
+
self, payload: Any = None
|
|
3050
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3051
|
+
"""Lay out the three standard views of a model on the active sheet.
|
|
3052
|
+
|
|
3053
|
+
Wraps ``IDrawingDoc::Create3rdAngleViews2`` (or
|
|
3054
|
+
``Create1stAngleViews2``), which places front, top and side in one
|
|
3055
|
+
call. Both return a bare boolean, so the view list before and after is
|
|
3056
|
+
what actually confirms the views exist.
|
|
3057
|
+
|
|
3058
|
+
Args:
|
|
3059
|
+
payload: Tool payload. Reads ``model_path``/``model_file`` and
|
|
3060
|
+
``third_angle`` (default ``True``; ``projection`` set to
|
|
3061
|
+
``"first_angle"`` selects the other).
|
|
3062
|
+
|
|
3063
|
+
Returns:
|
|
3064
|
+
AdapterResult[dict[str, Any]]: The view names that appeared.
|
|
3065
|
+
|
|
3066
|
+
Example::
|
|
3067
|
+
|
|
3068
|
+
await adapter.create_technical_drawing(
|
|
3069
|
+
{"model_file": r"C:\\parts\\bracket.sldprt"}
|
|
3070
|
+
)
|
|
3071
|
+
"""
|
|
3072
|
+
adapter = self._adapter(self)
|
|
3073
|
+
guard = self._require_drawing()
|
|
3074
|
+
if guard is not None:
|
|
3075
|
+
return cast("AdapterResult[dict[str, Any]]", guard)
|
|
3076
|
+
|
|
3077
|
+
data = _payload(payload)
|
|
3078
|
+
model_path = _first(data, "model_path", "model_file", "path")
|
|
3079
|
+
if not model_path:
|
|
3080
|
+
return AdapterResult(
|
|
3081
|
+
status=AdapterResultStatus.ERROR,
|
|
3082
|
+
error="A model path is required (model_path or model_file)",
|
|
3083
|
+
)
|
|
3084
|
+
|
|
3085
|
+
path = os.path.abspath(str(model_path))
|
|
3086
|
+
if not os.path.exists(path):
|
|
3087
|
+
return AdapterResult(
|
|
3088
|
+
status=AdapterResultStatus.ERROR,
|
|
3089
|
+
error=f"Model file not found: {model_path}",
|
|
3090
|
+
)
|
|
3091
|
+
|
|
3092
|
+
third_angle = bool(data.get("third_angle", True))
|
|
3093
|
+
if str(data.get("projection", "")).lower() in {"first", "first_angle"}:
|
|
3094
|
+
third_angle = False
|
|
3095
|
+
|
|
3096
|
+
def _standard() -> dict[str, Any]:
|
|
3097
|
+
drawing = _sw_type_info.flagged(adapter.currentModel, "IDrawingDoc")
|
|
3098
|
+
before = _view_names(adapter, drawing)
|
|
3099
|
+
|
|
3100
|
+
created = adapter._attempt(
|
|
3101
|
+
lambda: (
|
|
3102
|
+
drawing.Create3rdAngleViews2(path)
|
|
3103
|
+
if third_angle
|
|
3104
|
+
else drawing.Create1stAngleViews2(path)
|
|
3105
|
+
),
|
|
3106
|
+
default=False,
|
|
3107
|
+
)
|
|
3108
|
+
|
|
3109
|
+
after = _view_names(adapter, drawing)
|
|
3110
|
+
added = [n for n in after if n not in before]
|
|
3111
|
+
if not added:
|
|
3112
|
+
raise Exception(
|
|
3113
|
+
f"No views were created from '{model_path}' (the call "
|
|
3114
|
+
f"returned {created!r}). Check the model has solid "
|
|
3115
|
+
"geometry and opens on its own."
|
|
3116
|
+
)
|
|
3117
|
+
|
|
3118
|
+
return {
|
|
3119
|
+
"views": added,
|
|
3120
|
+
"model_path": path,
|
|
3121
|
+
"projection": "third_angle" if third_angle else "first_angle",
|
|
3122
|
+
"views_before": len(before),
|
|
3123
|
+
"views_after": len(after),
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
return cast(
|
|
3127
|
+
AdapterResult[dict[str, Any]],
|
|
3128
|
+
adapter._handle_com_operation("create_technical_drawing", _standard),
|
|
3129
|
+
)
|
|
3130
|
+
|
|
3131
|
+
async def add_note(self, payload: Any = None) -> AdapterResult[dict[str, Any]]:
|
|
3132
|
+
"""Place a text note on the active drawing sheet.
|
|
3133
|
+
|
|
3134
|
+
Wraps ``IModelDoc2::InsertNote(Text)`` and then positions the returned
|
|
3135
|
+
annotation: ``InsertNote`` places the note wherever SolidWorks likes,
|
|
3136
|
+
so the position is applied afterwards via ``IAnnotation::SetPosition``.
|
|
3137
|
+
|
|
3138
|
+
Args:
|
|
3139
|
+
payload: Tool payload. Reads ``text``, ``position_x``/``position_y``
|
|
3140
|
+
(or a two-element ``position``) in millimetres, and
|
|
3141
|
+
``font_size`` in **points**, matching the tool schema.
|
|
3142
|
+
|
|
3143
|
+
Returns:
|
|
3144
|
+
AdapterResult[dict[str, Any]]: The note text and where it landed.
|
|
3145
|
+
|
|
3146
|
+
Example::
|
|
3147
|
+
|
|
3148
|
+
await adapter.add_note(
|
|
3149
|
+
{"text": "MATERIAL: AISI 1018", "position_x": 200.0,
|
|
3150
|
+
"position_y": 50.0}
|
|
3151
|
+
)
|
|
3152
|
+
"""
|
|
3153
|
+
adapter = self._adapter(self)
|
|
3154
|
+
guard = self._require_drawing()
|
|
3155
|
+
if guard is not None:
|
|
3156
|
+
return cast("AdapterResult[dict[str, Any]]", guard)
|
|
3157
|
+
|
|
3158
|
+
data = _payload(payload)
|
|
3159
|
+
text = _first(data, "text", "note", default="")
|
|
3160
|
+
if not text:
|
|
3161
|
+
return AdapterResult(
|
|
3162
|
+
status=AdapterResultStatus.ERROR,
|
|
3163
|
+
error="add_note requires text",
|
|
3164
|
+
)
|
|
3165
|
+
|
|
3166
|
+
position = data.get("position")
|
|
3167
|
+
if isinstance(position, (list, tuple)) and len(position) >= 2:
|
|
3168
|
+
x, y = float(position[0]), float(position[1])
|
|
3169
|
+
else:
|
|
3170
|
+
x = float(_first(data, "position_x", "x", default=100.0))
|
|
3171
|
+
y = float(_first(data, "position_y", "y", default=50.0))
|
|
3172
|
+
|
|
3173
|
+
# The schema expresses font size in points, not millimetres.
|
|
3174
|
+
font_points = float(_first(data, "font_size", default=0.0) or 0.0)
|
|
3175
|
+
font_mm = font_points * _POINTS_TO_MM
|
|
3176
|
+
|
|
3177
|
+
def _add_note() -> dict[str, Any]:
|
|
3178
|
+
model = adapter.currentModel
|
|
3179
|
+
adapter._attempt(lambda: model.ClearSelection2(True), default=None)
|
|
3180
|
+
|
|
3181
|
+
note = adapter._attempt(lambda: model.InsertNote(str(text)), default=None)
|
|
3182
|
+
if note is None:
|
|
3183
|
+
raise Exception(
|
|
3184
|
+
"InsertNote returned nothing - the note was not created."
|
|
3185
|
+
)
|
|
3186
|
+
|
|
3187
|
+
positioned = False
|
|
3188
|
+
annotation = adapter._attempt(
|
|
3189
|
+
lambda: _sw_type_info.flagged(note, "INote").GetAnnotation(),
|
|
3190
|
+
default=None,
|
|
3191
|
+
)
|
|
3192
|
+
if annotation is not None:
|
|
3193
|
+
positioned = bool(
|
|
3194
|
+
adapter._attempt(
|
|
3195
|
+
lambda: _sw_type_info.flagged(
|
|
3196
|
+
annotation, "IAnnotation"
|
|
3197
|
+
).SetPosition(x / 1000.0, y / 1000.0, 0.0),
|
|
3198
|
+
default=False,
|
|
3199
|
+
)
|
|
3200
|
+
)
|
|
3201
|
+
|
|
3202
|
+
if font_mm:
|
|
3203
|
+
adapter._attempt(
|
|
3204
|
+
lambda: _sw_type_info.flagged(note, "INote").SetTextFormat(
|
|
3205
|
+
0, False, font_mm / 1000.0
|
|
3206
|
+
),
|
|
3207
|
+
default=None,
|
|
3208
|
+
)
|
|
3209
|
+
|
|
3210
|
+
adapter._attempt(lambda: model.EditRebuild3(), default=None)
|
|
3211
|
+
return {
|
|
3212
|
+
"text": text,
|
|
3213
|
+
"position": {"x": x, "y": y},
|
|
3214
|
+
"positioned": positioned,
|
|
3215
|
+
"font_size_points": font_points or None,
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
return cast(
|
|
3219
|
+
AdapterResult[dict[str, Any]],
|
|
3220
|
+
adapter._handle_com_operation("add_note", _add_note),
|
|
3221
|
+
)
|
|
3222
|
+
|
|
3223
|
+
async def list_drawing_views(self) -> AdapterResult[list[str]]:
|
|
3224
|
+
"""List the views on the active drawing.
|
|
3225
|
+
|
|
3226
|
+
Returns:
|
|
3227
|
+
AdapterResult[list[str]]: View names, sheet formats excluded.
|
|
3228
|
+
"""
|
|
3229
|
+
adapter = self._adapter(self)
|
|
3230
|
+
guard = self._require_drawing()
|
|
3231
|
+
if guard is not None:
|
|
3232
|
+
return cast("AdapterResult[list[str]]", guard)
|
|
3233
|
+
|
|
3234
|
+
def _list_views() -> list[str]:
|
|
3235
|
+
drawing = _sw_type_info.flagged(adapter.currentModel, "IDrawingDoc")
|
|
3236
|
+
return _view_names(adapter, drawing)
|
|
3237
|
+
|
|
3238
|
+
return cast(
|
|
3239
|
+
AdapterResult[list[str]],
|
|
3240
|
+
adapter._handle_com_operation("list_drawing_views", _list_views),
|
|
3241
|
+
)
|
|
3242
|
+
|
|
3243
|
+
async def auto_center_marks(
|
|
3244
|
+
self,
|
|
3245
|
+
view_name: str,
|
|
3246
|
+
mark_holes: bool = True,
|
|
3247
|
+
mark_fillets: bool = False,
|
|
3248
|
+
mark_slots: bool = True,
|
|
3249
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3250
|
+
"""Auto-insert centre marks on circular features in a drawing view.
|
|
3251
|
+
|
|
3252
|
+
Wraps ``IView::AutoInsertCenterMarks2`` (falling back to
|
|
3253
|
+
``AutoInsertCenterMarks`` on older builds) using the document's
|
|
3254
|
+
default size/gap/font. ``IView::GetCenterMarkCount()`` returns 0
|
|
3255
|
+
regardless of marks actually present on SW 3DEXPERIENCE R2026x, so
|
|
3256
|
+
the count is derived instead from ``IView::GetAnnotations()``: the
|
|
3257
|
+
view's annotations are enumerated and those flagged as
|
|
3258
|
+
``swCenterMarkSym`` (``swAnnotationType_e``) are counted.
|
|
3259
|
+
``center_marks_before == center_marks_after == 0`` is reported when
|
|
3260
|
+
the annotation enumeration cannot be performed (COM unavailable or
|
|
3261
|
+
older builds).
|
|
3262
|
+
|
|
3263
|
+
Adding nothing is still a success - the view may simply have no
|
|
3264
|
+
un-marked circular features.
|
|
3265
|
+
|
|
3266
|
+
Args:
|
|
3267
|
+
view_name: Name of a view on the active drawing.
|
|
3268
|
+
mark_holes: Mark holes / bores. Defaults to ``True``.
|
|
3269
|
+
mark_fillets: Mark fillets. Defaults to ``False``.
|
|
3270
|
+
mark_slots: Mark slots. Defaults to ``True``.
|
|
3271
|
+
|
|
3272
|
+
Returns:
|
|
3273
|
+
AdapterResult[dict[str, Any]]: The view, the feature types acted
|
|
3274
|
+
on, and ``center_marks_before`` / ``center_marks_after`` /
|
|
3275
|
+
``center_marks_added``. ``ERROR`` when the active document is not
|
|
3276
|
+
a drawing, no feature type is selected, or the view is not found.
|
|
3277
|
+
"""
|
|
3278
|
+
adapter = self._adapter(self)
|
|
3279
|
+
guard = self._require_drawing()
|
|
3280
|
+
if guard is not None:
|
|
3281
|
+
return cast("AdapterResult[dict[str, Any]]", guard)
|
|
3282
|
+
|
|
3283
|
+
insert_type = (
|
|
3284
|
+
(_SW_CM_TYPE_HOLE if mark_holes else 0)
|
|
3285
|
+
| (_SW_CM_TYPE_FILLET if mark_fillets else 0)
|
|
3286
|
+
| (_SW_CM_TYPE_SLOT if mark_slots else 0)
|
|
3287
|
+
)
|
|
3288
|
+
if insert_type == 0:
|
|
3289
|
+
return AdapterResult(
|
|
3290
|
+
status=AdapterResultStatus.ERROR,
|
|
3291
|
+
error=(
|
|
3292
|
+
"Select at least one feature type: mark_holes, "
|
|
3293
|
+
"mark_fillets or mark_slots."
|
|
3294
|
+
),
|
|
3295
|
+
)
|
|
3296
|
+
|
|
3297
|
+
def _auto_marks() -> dict[str, Any]:
|
|
3298
|
+
drawing = _sw_type_info.flagged(adapter.currentModel, "IDrawingDoc")
|
|
3299
|
+
view = _resolve_drawing_view(adapter, drawing, view_name)
|
|
3300
|
+
if view is None:
|
|
3301
|
+
raise Exception(
|
|
3302
|
+
f"No view named {view_name!r} on the active drawing. "
|
|
3303
|
+
f"Views: {', '.join(_view_names(adapter, drawing)) or 'none'}"
|
|
3304
|
+
)
|
|
3305
|
+
|
|
3306
|
+
def _center_mark_count() -> int:
|
|
3307
|
+
"""Count annotations on the view flagged as swCenterMarkSym.
|
|
3308
|
+
|
|
3309
|
+
Enumerates ``IView::GetAnnotations()`` and inspects each
|
|
3310
|
+
annotation's type via ``IAnnotation::GetType()``. Returns 0 when
|
|
3311
|
+
the enumeration cannot be performed (COM unavailable, older
|
|
3312
|
+
builds, or the annotation type is unknown). This is the count
|
|
3313
|
+
source for both ``center_marks_before`` and ``center_marks_after``.
|
|
3314
|
+
"""
|
|
3315
|
+
raw_annos = adapter._attempt(
|
|
3316
|
+
lambda: view.GetAnnotations(), default=None
|
|
3317
|
+
)
|
|
3318
|
+
if not isinstance(raw_annos, (list, tuple)):
|
|
3319
|
+
return 0
|
|
3320
|
+
count = 0
|
|
3321
|
+
for raw in raw_annos:
|
|
3322
|
+
anno = _as_com(adapter, raw, "IAnnotation")
|
|
3323
|
+
if anno is None:
|
|
3324
|
+
continue
|
|
3325
|
+
anno_type = adapter._attempt(
|
|
3326
|
+
lambda anno=anno: anno.GetType(), default=None
|
|
3327
|
+
)
|
|
3328
|
+
if (
|
|
3329
|
+
isinstance(anno_type, (int, float))
|
|
3330
|
+
and anno_type == _SW_ANNOTATION_CENTER_MARK
|
|
3331
|
+
):
|
|
3332
|
+
count += 1
|
|
3333
|
+
return count
|
|
3334
|
+
|
|
3335
|
+
before = _center_mark_count()
|
|
3336
|
+
|
|
3337
|
+
ran = adapter._attempt(
|
|
3338
|
+
lambda: view.AutoInsertCenterMarks2(
|
|
3339
|
+
insert_type,
|
|
3340
|
+
_SW_CM_STYLE_SINGLE,
|
|
3341
|
+
True, # LinearSlotCenter
|
|
3342
|
+
True, # ArcSlotCenter
|
|
3343
|
+
True, # UseDocumentDefaults
|
|
3344
|
+
0.0, # Size (ignored when UseDocumentDefaults)
|
|
3345
|
+
0.0, # Gap
|
|
3346
|
+
True, # ExtendedLines
|
|
3347
|
+
False, # CenterLineFont
|
|
3348
|
+
0.0, # Angle
|
|
3349
|
+
),
|
|
3350
|
+
default=None,
|
|
3351
|
+
)
|
|
3352
|
+
if ran is None:
|
|
3353
|
+
ran = adapter._attempt(
|
|
3354
|
+
lambda: view.AutoInsertCenterMarks(
|
|
3355
|
+
insert_type,
|
|
3356
|
+
_SW_CM_STYLE_SINGLE,
|
|
3357
|
+
True,
|
|
3358
|
+
True,
|
|
3359
|
+
True,
|
|
3360
|
+
0.0,
|
|
3361
|
+
True,
|
|
3362
|
+
False,
|
|
3363
|
+
0.0,
|
|
3364
|
+
),
|
|
3365
|
+
default=None,
|
|
3366
|
+
)
|
|
3367
|
+
|
|
3368
|
+
adapter._attempt(lambda: adapter.currentModel.EditRebuild3(), default=None)
|
|
3369
|
+
|
|
3370
|
+
after = _center_mark_count()
|
|
3371
|
+
|
|
3372
|
+
return {
|
|
3373
|
+
"view": view_name,
|
|
3374
|
+
"feature_types": [
|
|
3375
|
+
label
|
|
3376
|
+
for label, on in (
|
|
3377
|
+
("holes", mark_holes),
|
|
3378
|
+
("fillets", mark_fillets),
|
|
3379
|
+
("slots", mark_slots),
|
|
3380
|
+
)
|
|
3381
|
+
if on
|
|
3382
|
+
],
|
|
3383
|
+
"center_marks_before": before,
|
|
3384
|
+
"center_marks_after": after,
|
|
3385
|
+
"center_marks_added": after - before,
|
|
3386
|
+
}
|
|
3387
|
+
|
|
3388
|
+
return cast(
|
|
3389
|
+
AdapterResult[dict[str, Any]],
|
|
3390
|
+
adapter._handle_com_operation("auto_center_marks", _auto_marks),
|
|
3391
|
+
)
|
|
3392
|
+
|
|
3393
|
+
async def save_body_as_part(
|
|
3394
|
+
self, body_name: str, file_path: str
|
|
3395
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3396
|
+
"""Extract one solid body from the active multibody part to a new file.
|
|
3397
|
+
|
|
3398
|
+
Wraps ``IFeatureManager::CreateSaveBodyFeature`` - the API behind
|
|
3399
|
+
Insert > Features > Save Bodies. ``body_name`` is matched against
|
|
3400
|
+
``IPartDoc::GetBodies2(swSolidBody)`` by ``IBody2::Name``; a Save
|
|
3401
|
+
Bodies feature is added and the standalone part is written to
|
|
3402
|
+
``file_path``. ``CreateSaveBodyFeature`` returns the feature (or
|
|
3403
|
+
``None``) but no write status, so success is confirmed by the file
|
|
3404
|
+
existing afterwards.
|
|
3405
|
+
|
|
3406
|
+
Args:
|
|
3407
|
+
body_name: Name of a solid body in the active part.
|
|
3408
|
+
file_path: Absolute path for the new ``.sldprt``; its parent
|
|
3409
|
+
directory must already exist.
|
|
3410
|
+
|
|
3411
|
+
Returns:
|
|
3412
|
+
AdapterResult[dict[str, Any]]: The body, the written path, the new
|
|
3413
|
+
feature's name, and every solid-body name found. ``ERROR`` when
|
|
3414
|
+
the active document is not a part, the body is absent, the parent
|
|
3415
|
+
directory is missing, or no file was written.
|
|
3416
|
+
"""
|
|
3417
|
+
adapter = self._adapter(self)
|
|
3418
|
+
self._sync_current_model_from_active()
|
|
3419
|
+
if not adapter.currentModel:
|
|
3420
|
+
return AdapterResult(
|
|
3421
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
3422
|
+
)
|
|
3423
|
+
if _doc_type(adapter) != 1:
|
|
3424
|
+
return AdapterResult(
|
|
3425
|
+
status=AdapterResultStatus.ERROR,
|
|
3426
|
+
error="save_body_as_part requires an active part document",
|
|
3427
|
+
)
|
|
3428
|
+
if not str(body_name or "").strip():
|
|
3429
|
+
return AdapterResult(
|
|
3430
|
+
status=AdapterResultStatus.ERROR, error="body_name is required"
|
|
3431
|
+
)
|
|
3432
|
+
target = str(file_path or "").strip()
|
|
3433
|
+
if not target:
|
|
3434
|
+
return AdapterResult(
|
|
3435
|
+
status=AdapterResultStatus.ERROR, error="file_path is required"
|
|
3436
|
+
)
|
|
3437
|
+
target = os.path.abspath(target)
|
|
3438
|
+
parent = os.path.dirname(target)
|
|
3439
|
+
if not os.path.isdir(parent):
|
|
3440
|
+
return AdapterResult(
|
|
3441
|
+
status=AdapterResultStatus.ERROR,
|
|
3442
|
+
error=f"Parent directory does not exist: {parent}",
|
|
3443
|
+
)
|
|
3444
|
+
|
|
3445
|
+
def _save_body() -> dict[str, Any]:
|
|
3446
|
+
model = adapter.currentModel
|
|
3447
|
+
part = _sw_type_info.flagged(model, "IPartDoc")
|
|
3448
|
+
raw_bodies = adapter._attempt(
|
|
3449
|
+
lambda: part.GetBodies2(_SW_SOLID_BODY, False), default=None
|
|
3450
|
+
)
|
|
3451
|
+
bodies = (
|
|
3452
|
+
list(raw_bodies)
|
|
3453
|
+
if isinstance(raw_bodies, (list, tuple))
|
|
3454
|
+
else ([raw_bodies] if raw_bodies else [])
|
|
3455
|
+
)
|
|
3456
|
+
if not bodies:
|
|
3457
|
+
raise Exception("The active part has no solid bodies")
|
|
3458
|
+
|
|
3459
|
+
names: list[str] = []
|
|
3460
|
+
match = None
|
|
3461
|
+
for body in bodies:
|
|
3462
|
+
flagged = _sw_type_info.flagged(body, "IBody2")
|
|
3463
|
+
name = adapter._attempt(
|
|
3464
|
+
lambda f=flagged: adapter._get_attr_or_call(f, "Name"),
|
|
3465
|
+
default=None,
|
|
3466
|
+
)
|
|
3467
|
+
if name is not None:
|
|
3468
|
+
names.append(str(name))
|
|
3469
|
+
if str(name) == body_name:
|
|
3470
|
+
match = flagged
|
|
3471
|
+
if match is None:
|
|
3472
|
+
raise Exception(
|
|
3473
|
+
f"Body {body_name!r} not found. Solid bodies: "
|
|
3474
|
+
+ (", ".join(names) or "none")
|
|
3475
|
+
)
|
|
3476
|
+
|
|
3477
|
+
manager = adapter._attempt(lambda: model.FeatureManager, default=None)
|
|
3478
|
+
if manager is None:
|
|
3479
|
+
raise Exception("Part has no FeatureManager")
|
|
3480
|
+
manager = _sw_type_info.flagged(manager, "IFeatureManager")
|
|
3481
|
+
|
|
3482
|
+
if os.path.exists(target):
|
|
3483
|
+
os.remove(target)
|
|
3484
|
+
|
|
3485
|
+
feature = adapter._attempt(
|
|
3486
|
+
lambda: manager.CreateSaveBodyFeature(
|
|
3487
|
+
_variant_array(pythoncom.VT_DISPATCH, [match]),
|
|
3488
|
+
_variant_array(pythoncom.VT_BSTR, [target]),
|
|
3489
|
+
"",
|
|
3490
|
+
False,
|
|
3491
|
+
True,
|
|
3492
|
+
),
|
|
3493
|
+
default=None,
|
|
3494
|
+
)
|
|
3495
|
+
adapter._attempt(lambda: model.EditRebuild3(), default=None)
|
|
3496
|
+
|
|
3497
|
+
if not os.path.exists(target):
|
|
3498
|
+
raise Exception(
|
|
3499
|
+
f"CreateSaveBodyFeature did not write a file to {target}; "
|
|
3500
|
+
"the Save Bodies feature was rejected."
|
|
3501
|
+
)
|
|
3502
|
+
|
|
3503
|
+
feat_name = (
|
|
3504
|
+
adapter._attempt(
|
|
3505
|
+
lambda: adapter._get_attr_or_call(feature, "Name"),
|
|
3506
|
+
default=None,
|
|
3507
|
+
)
|
|
3508
|
+
if feature is not None
|
|
3509
|
+
else None
|
|
3510
|
+
)
|
|
3511
|
+
return {
|
|
3512
|
+
"body": body_name,
|
|
3513
|
+
"file_path": target,
|
|
3514
|
+
"feature": str(feat_name) if feat_name else None,
|
|
3515
|
+
"solid_bodies": names,
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
return cast(
|
|
3519
|
+
AdapterResult[dict[str, Any]],
|
|
3520
|
+
adapter._handle_com_operation("save_body_as_part", _save_body),
|
|
3521
|
+
)
|
|
3522
|
+
|
|
3523
|
+
async def check_interference(
|
|
3524
|
+
self, params: Any = None
|
|
3525
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3526
|
+
"""Run SolidWorks' interference detection on the active assembly.
|
|
3527
|
+
|
|
3528
|
+
Uses ``IAssemblyDoc::InterferenceDetectionManager``
|
|
3529
|
+
(``IInterferenceDetectionMgr``), **not** the older
|
|
3530
|
+
``ToolsCheckInterference2``. That call is declared ``Sub`` with two
|
|
3531
|
+
``ByRef`` out-parameters, and on SW 2025 through pywin32 late binding
|
|
3532
|
+
it could not be made to report anything: ``pythoncom.Missing`` raises
|
|
3533
|
+
``PyOleMissing can not be converted to a COM VARIANT``, a plain
|
|
3534
|
+
``None`` or a typed array VARIANT raises ``Type mismatch``, passing a
|
|
3535
|
+
component array throws server-side, and a byref ``VT_VARIANT`` pair is
|
|
3536
|
+
accepted but leaves both out-parameters ``None`` for an assembly that
|
|
3537
|
+
demonstrably interferes. An inspection tool that always answers "no
|
|
3538
|
+
interference" is worse than one that refuses.
|
|
3539
|
+
|
|
3540
|
+
``GetInterferenceCount`` is an ordinary return value, so ``0`` is a
|
|
3541
|
+
real measurement rather than a swallowed failure, and each
|
|
3542
|
+
``IInterference`` carries the overlap ``Volume``.
|
|
3543
|
+
|
|
3544
|
+
Args:
|
|
3545
|
+
params (Any): Optional settings. ``coincident`` (bool, default
|
|
3546
|
+
``False``) treats touching faces as interference;
|
|
3547
|
+
``include_multibody`` (bool, default ``True``);
|
|
3548
|
+
``ignore_hidden`` (bool, default ``False``). A ``components``
|
|
3549
|
+
list filters which interferences are reported. ``tolerance``
|
|
3550
|
+
is accepted and ignored - SolidWorks' interference detection
|
|
3551
|
+
has no tolerance setting - and the payload says so.
|
|
3552
|
+
|
|
3553
|
+
Returns:
|
|
3554
|
+
AdapterResult[dict[str, Any]]: ``interference_found``,
|
|
3555
|
+
``interference_count``, and per-interference component pairs with
|
|
3556
|
+
overlap volumes in mm^3. ``ERROR`` when there is no active model
|
|
3557
|
+
or the active document is not an assembly.
|
|
3558
|
+
|
|
3559
|
+
Raises:
|
|
3560
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
3561
|
+
|
|
3562
|
+
Example::
|
|
3563
|
+
|
|
3564
|
+
await adapter.check_interference({"coincident": False})
|
|
3565
|
+
"""
|
|
3566
|
+
adapter = self._adapter(self)
|
|
3567
|
+
options = _payload_dict(params)
|
|
3568
|
+
|
|
3569
|
+
if not adapter.currentModel:
|
|
3570
|
+
return AdapterResult(
|
|
3571
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
3572
|
+
)
|
|
3573
|
+
|
|
3574
|
+
doc_type = _doc_type(adapter)
|
|
3575
|
+
if doc_type != 2:
|
|
3576
|
+
return AdapterResult(
|
|
3577
|
+
status=AdapterResultStatus.ERROR,
|
|
3578
|
+
error=(
|
|
3579
|
+
"Interference detection requires an assembly document "
|
|
3580
|
+
f"(active document type is {doc_type!r}, expected 2)"
|
|
3581
|
+
),
|
|
3582
|
+
)
|
|
3583
|
+
|
|
3584
|
+
coincident = bool(options.get("coincident", False))
|
|
3585
|
+
include_multibody = bool(options.get("include_multibody", True))
|
|
3586
|
+
ignore_hidden = bool(options.get("ignore_hidden", False))
|
|
3587
|
+
wanted = options.get("components") or []
|
|
3588
|
+
wanted_names = {str(name) for name in wanted} if wanted else set()
|
|
3589
|
+
|
|
3590
|
+
def _check() -> dict[str, Any]:
|
|
3591
|
+
assembly = _sw_type_info.flagged(adapter.currentModel, "IAssemblyDoc")
|
|
3592
|
+
manager = adapter._attempt(
|
|
3593
|
+
lambda: assembly.InterferenceDetectionManager, default=None
|
|
3594
|
+
)
|
|
3595
|
+
if manager is None:
|
|
3596
|
+
raise Exception(
|
|
3597
|
+
"InterferenceDetectionManager is unavailable on this "
|
|
3598
|
+
"assembly, so no interference check was performed."
|
|
3599
|
+
)
|
|
3600
|
+
manager = _sw_type_info.flagged(manager, "IInterferenceDetectionMgr")
|
|
3601
|
+
|
|
3602
|
+
for name, value in (
|
|
3603
|
+
("TreatCoincidenceAsInterference", coincident),
|
|
3604
|
+
("IncludeMultibodyPartInterferences", include_multibody),
|
|
3605
|
+
("IgnoreHiddenBodies", ignore_hidden),
|
|
3606
|
+
("MakeInterferingPartsTransparent", False),
|
|
3607
|
+
):
|
|
3608
|
+
adapter._attempt(
|
|
3609
|
+
lambda n=name, v=value: setattr(manager, n, v), default=None
|
|
3610
|
+
)
|
|
3611
|
+
|
|
3612
|
+
try:
|
|
3613
|
+
# A real return value: 0 means SolidWorks found nothing, and a
|
|
3614
|
+
# COM failure raises instead of flattening into a false clean
|
|
3615
|
+
# result.
|
|
3616
|
+
count = int(manager.GetInterferenceCount() or 0)
|
|
3617
|
+
interferences = (
|
|
3618
|
+
adapter._attempt(lambda: manager.GetInterferences(), default=None)
|
|
3619
|
+
if count
|
|
3620
|
+
else None
|
|
3621
|
+
)
|
|
3622
|
+
details = _interference_details(adapter, interferences)
|
|
3623
|
+
finally:
|
|
3624
|
+
# Leaves the assembly out of interference-display mode even
|
|
3625
|
+
# when the read above fails.
|
|
3626
|
+
adapter._attempt(lambda: manager.Done(), default=None)
|
|
3627
|
+
|
|
3628
|
+
if wanted_names:
|
|
3629
|
+
details = [
|
|
3630
|
+
item
|
|
3631
|
+
for item in details
|
|
3632
|
+
if wanted_names & set(item.get("components", []))
|
|
3633
|
+
]
|
|
3634
|
+
|
|
3635
|
+
return {
|
|
3636
|
+
"interference_found": bool(details) if wanted_names else count > 0,
|
|
3637
|
+
"interference_count": len(details) if wanted_names else count,
|
|
3638
|
+
"interferences": details,
|
|
3639
|
+
"coincident_treated_as_interference": coincident,
|
|
3640
|
+
"scope": sorted(wanted_names) if wanted_names else "whole assembly",
|
|
3641
|
+
# Said plainly rather than silently dropped: a caller passing a
|
|
3642
|
+
# tolerance would otherwise assume it was applied.
|
|
3643
|
+
"tolerance_applied": None,
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
return cast(
|
|
3647
|
+
AdapterResult[dict[str, Any]],
|
|
3648
|
+
adapter._handle_com_operation("check_interference", _check),
|
|
3649
|
+
)
|