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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. solidworks_mcp/__init__.py +57 -0
  2. solidworks_mcp/adapters/__init__.py +40 -0
  3. solidworks_mcp/adapters/base.py +1882 -0
  4. solidworks_mcp/adapters/circuit_breaker.py +2052 -0
  5. solidworks_mcp/adapters/com_executor.py +252 -0
  6. solidworks_mcp/adapters/complexity_analyzer.py +272 -0
  7. solidworks_mcp/adapters/connection_pool.py +1884 -0
  8. solidworks_mcp/adapters/factory.py +366 -0
  9. solidworks_mcp/adapters/intelligent_router.py +218 -0
  10. solidworks_mcp/adapters/mock_adapter.py +4016 -0
  11. solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
  12. solidworks_mcp/adapters/solidworks/__init__.py +17 -0
  13. solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
  14. solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
  15. solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
  16. solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
  17. solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
  18. solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
  19. solidworks_mcp/adapters/solidworks/features.py +3362 -0
  20. solidworks_mcp/adapters/solidworks/io.py +3649 -0
  21. solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
  22. solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
  23. solidworks_mcp/adapters/solidworks/selection.py +308 -0
  24. solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
  25. solidworks_mcp/adapters/sw_feature_errors.py +66 -0
  26. solidworks_mcp/adapters/sw_type_info.py +317 -0
  27. solidworks_mcp/adapters/vba_adapter.py +318 -0
  28. solidworks_mcp/adapters/vba_macro_executor.py +215 -0
  29. solidworks_mcp/agents/__init__.py +68 -0
  30. solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
  31. solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
  32. solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
  33. solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
  34. solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
  35. solidworks_mcp/agents/harness.py +216 -0
  36. solidworks_mcp/agents/history_db.py +1508 -0
  37. solidworks_mcp/agents/retrieval_index.py +164 -0
  38. solidworks_mcp/agents/schemas.py +165 -0
  39. solidworks_mcp/agents/smoke_test.py +285 -0
  40. solidworks_mcp/agents/soc_exporter.py +650 -0
  41. solidworks_mcp/agents/soc_pickup.py +332 -0
  42. solidworks_mcp/agents/soc_rewind.py +221 -0
  43. solidworks_mcp/agents/vector_rag.py +665 -0
  44. solidworks_mcp/cache/__init__.py +5 -0
  45. solidworks_mcp/cache/response_cache.py +164 -0
  46. solidworks_mcp/config.py +729 -0
  47. solidworks_mcp/exceptions.py +37 -0
  48. solidworks_mcp/security/__init__.py +68 -0
  49. solidworks_mcp/security/auth.py +128 -0
  50. solidworks_mcp/security/cors.py +27 -0
  51. solidworks_mcp/security/rate_limiting.py +90 -0
  52. solidworks_mcp/security/runtime.py +155 -0
  53. solidworks_mcp/server.py +777 -0
  54. solidworks_mcp/server_cli_fixed.py +120 -0
  55. solidworks_mcp/setup/__init__.py +1 -0
  56. solidworks_mcp/setup/cli.py +399 -0
  57. solidworks_mcp/setup/clients.py +157 -0
  58. solidworks_mcp/setup/config_file.py +230 -0
  59. solidworks_mcp/setup/handshake.py +202 -0
  60. solidworks_mcp/setup/launcher.py +102 -0
  61. solidworks_mcp/setup/legacy.py +100 -0
  62. solidworks_mcp/setup/registration.py +193 -0
  63. solidworks_mcp/setup/solidworks.py +49 -0
  64. solidworks_mcp/tools/__init__.py +64 -0
  65. solidworks_mcp/tools/analysis.py +723 -0
  66. solidworks_mcp/tools/automation.py +578 -0
  67. solidworks_mcp/tools/docs_discovery.py +1476 -0
  68. solidworks_mcp/tools/drawing.py +1015 -0
  69. solidworks_mcp/tools/drawing_analysis.py +456 -0
  70. solidworks_mcp/tools/drawing_automation.py +627 -0
  71. solidworks_mcp/tools/engineering_data.py +466 -0
  72. solidworks_mcp/tools/error_classifier.py +232 -0
  73. solidworks_mcp/tools/export.py +833 -0
  74. solidworks_mcp/tools/file_facts.py +40 -0
  75. solidworks_mcp/tools/file_management.py +1473 -0
  76. solidworks_mcp/tools/input_compat.py +17 -0
  77. solidworks_mcp/tools/macro_recording.py +464 -0
  78. solidworks_mcp/tools/modeling.py +2360 -0
  79. solidworks_mcp/tools/sketching.py +1422 -0
  80. solidworks_mcp/tools/template_management.py +367 -0
  81. solidworks_mcp/tools/tool_boundary.py +280 -0
  82. solidworks_mcp/tools/vba_generation.py +1145 -0
  83. solidworks_mcp/ui/__init__.py +9 -0
  84. solidworks_mcp/ui/local_llm.py +725 -0
  85. solidworks_mcp/ui/routers/__init__.py +10 -0
  86. solidworks_mcp/ui/routers/checkpoint.py +24 -0
  87. solidworks_mcp/ui/routers/docs.py +47 -0
  88. solidworks_mcp/ui/routers/llm.py +83 -0
  89. solidworks_mcp/ui/routers/local_model.py +69 -0
  90. solidworks_mcp/ui/routers/model.py +75 -0
  91. solidworks_mcp/ui/routers/preview.py +38 -0
  92. solidworks_mcp/ui/routers/session.py +161 -0
  93. solidworks_mcp/ui/routers/viewer.py +154 -0
  94. solidworks_mcp/ui/schemas.py +502 -0
  95. solidworks_mcp/ui/server.py +298 -0
  96. solidworks_mcp/ui/service.py +481 -0
  97. solidworks_mcp/ui/services/__init__.py +136 -0
  98. solidworks_mcp/ui/services/_utils.py +958 -0
  99. solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
  100. solidworks_mcp/ui/services/docs_service.py +282 -0
  101. solidworks_mcp/ui/services/llm_service.py +956 -0
  102. solidworks_mcp/ui/services/model_service.py +459 -0
  103. solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
  104. solidworks_mcp/ui/services/preview_service.py +489 -0
  105. solidworks_mcp/ui/services/session_service.py +1389 -0
  106. solidworks_mcp/utils/__init__.py +9 -0
  107. solidworks_mcp/utils/feature_tree_classifier.py +342 -0
  108. solidworks_mcp/utils/logging.py +76 -0
  109. solidworks_mcp/utils/validation.py +77 -0
  110. solidworks_mcp/version.py +8 -0
  111. solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
  112. solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
  113. solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
  114. solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
  115. solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ )