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,1476 @@
1
+ """Model-data mixin for PyWin32 SolidWorks operations.
2
+
3
+ Covers the engineering data a drawing or BOM is built from: model dimensions,
4
+ measurements, material, custom properties and sheet-metal flat patterns.
5
+
6
+ Every method name, argument order and enum value below was read from the
7
+ SOLIDWORKS 2025 type libraries (``sldworks.tlb`` via gen_py, ``swconst.tlb``
8
+ via ``pythoncom.LoadTypeLib``), not from memory. Live behaviour is covered by
9
+ ``tests/live/test_live_sw_documentation_tools.py``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ from typing import TYPE_CHECKING, Any, cast
16
+
17
+ import defusedxml.ElementTree as ET
18
+ from defusedxml import DefusedXmlException
19
+
20
+ from ..base import AdapterResult, AdapterResultStatus
21
+ from . import dimension_layout as _layout
22
+ from .features import _null_callout, _parse_edge_spec, _select_edge_by_coord
23
+ from .io import (
24
+ _as_com,
25
+ _byref_bstr,
26
+ _doc_type,
27
+ _resolve_drawing_view,
28
+ _sw_type_info,
29
+ _view_names,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ # swUserPreferenceStringValue_e
36
+ _SW_PREF_MATERIAL_DATABASES = 28
37
+ # swCustomInfoType_e / swCustomPropertyAddOption_e
38
+ _SW_CUSTOM_INFO_TEXT = 30
39
+ _SW_CUSTOM_PROPERTY_REPLACE_VALUE = 2
40
+ # swExportToDWG_e
41
+ _SW_EXPORT_SHEET_METAL = 1
42
+ # swBodyType_e
43
+ _SW_SOLID_BODY = 0
44
+ # swInsertAnnotation_e
45
+ _SW_INSERT_DIMENSIONS = 8
46
+ _SW_INSERT_DIMENSIONS_MARKED_FOR_DRAWING = 32768
47
+ _SW_INSERT_HOLE_WIZARD_LOCATION_DIMS = 131072
48
+ # swImportModelItemsSource_e
49
+ _SW_IMPORT_FROM_ENTIRE_MODEL = 0
50
+ # swAutodimEntities_e / swAutodimScheme_e / placements
51
+ _SW_AUTODIM_ENTITIES_ALL = 1
52
+ _SW_AUTODIM_SCHEME_BASELINE = 1
53
+ _SW_AUTODIM_HORIZONTAL_BELOW = -1
54
+ _SW_AUTODIM_VERTICAL_LEFT = -1
55
+ _SW_AUTODIM_STATUS_SUCCESS = 0
56
+ # swAnnotationType_e
57
+ _SW_ANNOTATION_DISPLAY_DIMENSION = 4
58
+
59
+
60
+ def _overlap_summary(result: dict[str, Any]) -> str:
61
+ """Describe the overlaps ``arrange_dimensions`` could not clear.
62
+
63
+ Args:
64
+ result: The layout result, carrying ``remaining_overlaps``.
65
+
66
+ Returns:
67
+ str: How many overlaps remain, their kinds, the first five, and what
68
+ to try next.
69
+ """
70
+ overlaps = result.get("remaining_overlaps", [])
71
+ kinds = sorted({o["kind"] for o in overlaps})
72
+ sample = "; ".join(
73
+ f"{o['dimension']} vs {o['conflicts_with']} ({o['kind']})" for o in overlaps[:5]
74
+ )
75
+ return (
76
+ f"{result['overlaps_after']} dimension overlap(s) remain after arranging "
77
+ f"{result['dimensions']} dimension(s) ({', '.join(kinds)}): {sample}. "
78
+ "Try a smaller sheet scale or a larger sheet so the views have room."
79
+ )
80
+
81
+
82
+ _DEFAULT_MATERIAL_DATABASE = "SOLIDWORKS Materials"
83
+
84
+ # SelectByID2 entity types accepted by measure_entities, keyed by spec prefix.
85
+ _MEASURE_PREFIXES = {"face": "FACE", "edge": "EDGE", "vertex": "VERTEX"}
86
+ #: Layout passes: one full plan, then re-plans of what is still in conflict.
87
+ _LAYOUT_PASSES = 3
88
+
89
+
90
+ def _parse_measure_spec(spec: str) -> tuple[str, float, float, float, str]:
91
+ """Parse a ``measure_entities`` entity spec into SelectByID2 arguments.
92
+
93
+ Accepts the same ``"face:x,y,z"`` / ``"x,y,z"`` / ``"Edge<1>"`` forms as
94
+ ``add_fillet`` (via :func:`_parse_edge_spec`), plus ``"edge:x,y,z"`` and
95
+ ``"vertex:x,y,z"``. Coordinates are in metres.
96
+
97
+ Args:
98
+ spec: The entity spec.
99
+
100
+ Returns:
101
+ tuple[str, float, float, float, str]: name, x, y, z and entity type.
102
+ """
103
+ prefix, sep, rest = spec.partition(":")
104
+ kind = _MEASURE_PREFIXES.get(prefix.strip().lower()) if sep else None
105
+ if kind in {"EDGE", "VERTEX"}:
106
+ parts = rest.split(",")
107
+ if len(parts) == 3:
108
+ try:
109
+ return "", float(parts[0]), float(parts[1]), float(parts[2]), kind
110
+ except ValueError:
111
+ pass
112
+ return rest, 0.0, 0.0, 0.0, kind
113
+ return _parse_edge_spec(spec)
114
+
115
+
116
+ def _parse_material_library(path: str) -> list[dict[str, str]]:
117
+ """Read material names and classifications from one ``.sldmat`` file.
118
+
119
+ ``.sldmat`` is XML: ``<classification name=...>`` elements hold
120
+ ``<material name=...>`` children. Tags are matched by local name because
121
+ the root element carries a namespace. Parsed with ``defusedxml``: library
122
+ folders can be user-configured, so entity-expansion and external-entity
123
+ payloads are refused rather than expanded.
124
+
125
+ Args:
126
+ path: Path to the ``.sldmat`` file.
127
+
128
+ Returns:
129
+ list[dict[str, str]]: ``name``, ``classification`` and ``database``
130
+ for each material; empty when the file cannot be parsed or is refused.
131
+ """
132
+ try:
133
+ root = ET.parse(path).getroot()
134
+ except (ET.ParseError, OSError, DefusedXmlException):
135
+ return []
136
+ database = os.path.splitext(os.path.basename(path))[0]
137
+ materials: list[dict[str, str]] = []
138
+ for classification in root.iter():
139
+ if classification.tag.rsplit("}", 1)[-1] != "classification":
140
+ continue
141
+ class_name = classification.get("name", "")
142
+ for child in classification:
143
+ if child.tag.rsplit("}", 1)[-1] == "material" and child.get("name"):
144
+ materials.append(
145
+ {
146
+ "name": child.get("name", ""),
147
+ "classification": class_name,
148
+ "database": database,
149
+ }
150
+ )
151
+ return materials
152
+
153
+
154
+ def _unpack_custom_property(raw: Any) -> tuple[str, str]:
155
+ """Pull (value, resolved value) out of an ``ICustomPropertyManager::Get6`` result.
156
+
157
+ Under late binding the out-parameters come back as a tuple that may or may
158
+ not lead with the integer return code, depending on the pywin32 build.
159
+
160
+ Args:
161
+ raw: Whatever ``Get6`` returned.
162
+
163
+ Returns:
164
+ tuple[str, str]: The raw (possibly ``$PRP:`` linked) value and the
165
+ resolved value; empty strings when the shape is unrecognised.
166
+ """
167
+ if not isinstance(raw, (list, tuple)):
168
+ return ("", "")
169
+ items = list(raw)
170
+ if items and isinstance(items[0], int) and not isinstance(items[0], bool):
171
+ items = items[1:]
172
+ strings = [str(item) for item in items if isinstance(item, str)]
173
+ if not strings:
174
+ return ("", "")
175
+ value = strings[0]
176
+ resolved = strings[1] if len(strings) > 1 else value
177
+ return (value, resolved)
178
+
179
+
180
+ def _read_custom_property(adapter: Any, manager: Any, name: str) -> tuple[str, str]:
181
+ """Read one custom property's (value, resolved value).
182
+
183
+ Tries ``Get6`` (value and resolved value) first; under late binding on
184
+ SW 2025 its out-parameters raise ``Parameter not optional``, so it falls
185
+ back to ``Get(name)``, which returns the stored text only.
186
+
187
+ Args:
188
+ adapter: The adapter, for its ``_attempt`` helper.
189
+ manager: The document's ``ICustomPropertyManager``.
190
+ name: Property name.
191
+
192
+ Returns:
193
+ tuple[str, str]: The stored value and the resolved value; empty
194
+ strings when the property cannot be read.
195
+ """
196
+ value, resolved = _unpack_custom_property(
197
+ adapter._attempt(lambda: manager.Get6(name, False), default=None)
198
+ )
199
+ if value:
200
+ return value, resolved
201
+ plain = adapter._attempt(lambda: manager.Get(name), default=None)
202
+ text = str(plain) if isinstance(plain, str) else ""
203
+ return text, text
204
+
205
+
206
+ def _read_material(part: Any, configuration: str | None) -> tuple[str, str]:
207
+ """Read ``(material, database)`` from ``IPartDoc::GetMaterialPropertyName2``.
208
+
209
+ ``Database`` is a ``String`` out-parameter: under late binding on SW 2026
210
+ omitting it raises "Parameter not optional", so it is passed as a byref
211
+ BSTR and read back from ``.value``. Older pywin32 builds may instead return
212
+ ``(name, database)`` as a tuple; both shapes are accepted.
213
+
214
+ Args:
215
+ part: The active ``IPartDoc``.
216
+ configuration: Configuration name; the active one when ``None``.
217
+
218
+ Returns:
219
+ tuple[str, str]: Material name and library; empty strings when unset.
220
+ """
221
+ database = _byref_bstr()
222
+ raw = part.GetMaterialPropertyName2(configuration or "", database)
223
+ if isinstance(raw, (list, tuple)):
224
+ strings = [str(s) for s in raw if isinstance(s, str)]
225
+ name = strings[0] if strings else ""
226
+ library = strings[1] if len(strings) > 1 else ""
227
+ else:
228
+ name = raw if isinstance(raw, str) else ""
229
+ library = ""
230
+ stored = getattr(database, "value", "")
231
+ return name, library or (stored if isinstance(stored, str) else "")
232
+
233
+
234
+ def _strip_document_suffix(full_name: Any) -> str | None:
235
+ """Turn ``"D1@Sketch1@Part10.Part"`` into ``"D1@Sketch1"``.
236
+
237
+ Live SW 2025 appends ``@<document title>`` to ``IDimension::FullName``;
238
+ ``IModelDoc2::Parameter`` (behind ``set_dimension``) takes the short form.
239
+
240
+ Args:
241
+ full_name: ``IDimension::FullName`` as read.
242
+
243
+ Returns:
244
+ str | None: The name without the document suffix; ``None`` when
245
+ ``full_name`` is not text.
246
+ """
247
+ if not isinstance(full_name, str):
248
+ return None
249
+ parts = full_name.split("@")
250
+ if len(parts) >= 3 and "." in parts[-1]:
251
+ return "@".join(parts[:-1])
252
+ return full_name
253
+
254
+
255
+ def _sequence(raw: Any) -> list[Any]:
256
+ """Normalise a COM array return (``None``, scalar or tuple) into a list.
257
+
258
+ Args:
259
+ raw: What the COM call returned.
260
+
261
+ Returns:
262
+ list[Any]: The non-``None`` items.
263
+ """
264
+ if raw is None:
265
+ return []
266
+ if isinstance(raw, (list, tuple)):
267
+ return [item for item in raw if item is not None]
268
+ return [raw]
269
+
270
+
271
+ class SolidWorksDocumentationMixin:
272
+ """Expose model-data and drawing-documentation methods through a mixin."""
273
+
274
+ if TYPE_CHECKING:
275
+ currentModel: Any
276
+ swApp: Any
277
+
278
+ def _handle_com_operation(
279
+ self, operation_name: str, operation: Callable[..., Any], *args: Any
280
+ ) -> AdapterResult[Any]: ...
281
+
282
+ def _attempt(
283
+ self, operation: Callable[[], Any], default: Any = None
284
+ ) -> Any: ...
285
+
286
+ def _get_attr_or_call(self, obj: Any, name: str) -> Any: ...
287
+
288
+ def _require_drawing(self) -> AdapterResult[Any] | None: ...
289
+
290
+ def _require_doc_type(
291
+ self, allowed: set[int], operation: str
292
+ ) -> AdapterResult[Any] | None:
293
+ """Return an error result unless the active document type is allowed.
294
+
295
+ Args:
296
+ allowed: Accepted ``swDocumentTypes_e`` values (1 part, 2 assembly,
297
+ 3 drawing).
298
+ operation: Operation name for the error message.
299
+
300
+ Returns:
301
+ AdapterResult[Any] | None: ``None`` when allowed, else the error.
302
+ """
303
+ if not self.currentModel:
304
+ return AdapterResult(
305
+ status=AdapterResultStatus.ERROR, error="No active model"
306
+ )
307
+ names = {1: "part", 2: "assembly", 3: "drawing"}
308
+ doc_type = _doc_type(self)
309
+ if doc_type not in allowed:
310
+ wanted = " or ".join(names[t] for t in sorted(allowed))
311
+ return AdapterResult(
312
+ status=AdapterResultStatus.ERROR,
313
+ error=(
314
+ f"{operation} needs an active {wanted} document "
315
+ f"(active document is {names.get(doc_type or 0, 'unknown')})."
316
+ ),
317
+ )
318
+ return None
319
+
320
+ # ------------------------------------------------------------------
321
+ # Model data
322
+ # ------------------------------------------------------------------
323
+
324
+ async def list_model_dimensions(
325
+ self, feature_name: str | None = None
326
+ ) -> AdapterResult[list[dict[str, Any]]]:
327
+ """List every dimension in the active part or assembly.
328
+
329
+ Walks ``IModelDoc2::FirstFeature`` / ``IFeature::GetNextFeature`` and
330
+ each feature's ``GetFirstDisplayDimension`` chain. ``full_name`` (e.g.
331
+ ``"D1@Sketch1"``) is the name ``set_dimension`` accepts, so the pair
332
+ gives edit-by-name access to every driving parameter in the model.
333
+
334
+ Args:
335
+ feature_name: Only list dimensions of this feature (case-insensitive).
336
+
337
+ Returns:
338
+ AdapterResult[list[dict[str, Any]]]: ``full_name``, ``feature``,
339
+ ``value`` (document units), ``system_value`` (metres / radians)
340
+ and ``read_only`` per dimension.
341
+ """
342
+ guard = self._require_doc_type({1, 2}, "list_model_dimensions")
343
+ if guard is not None:
344
+ return cast("AdapterResult[list[dict[str, Any]]]", guard)
345
+ target = (feature_name or "").strip().lower()
346
+
347
+ def _list() -> list[dict[str, Any]]:
348
+ model = self.currentModel
349
+ dims: list[dict[str, Any]] = []
350
+ feature = self._attempt(lambda: model.FirstFeature(), default=None)
351
+ guard_count = 0
352
+ while feature is not None and guard_count < 20000:
353
+ guard_count += 1
354
+ feat = _sw_type_info.flagged(feature, "IFeature")
355
+ name = str(self._attempt(lambda f=feat: f.Name, default="") or "")
356
+ if not target or name.lower() == target:
357
+ display = self._attempt(
358
+ lambda f=feat: f.GetFirstDisplayDimension(), default=None
359
+ )
360
+ while display is not None:
361
+ disp = _sw_type_info.flagged(display, "IDisplayDimension")
362
+ dim = self._attempt(
363
+ lambda d=disp: d.GetDimension2(0), default=None
364
+ )
365
+ if dim is not None:
366
+ dim = _sw_type_info.flagged(dim, "IDimension")
367
+ dims.append(
368
+ {
369
+ "full_name": _strip_document_suffix(
370
+ self._attempt(
371
+ lambda d=dim: d.FullName, default=None
372
+ )
373
+ ),
374
+ "feature": name,
375
+ "value": self._attempt(
376
+ lambda d=dim: d.Value, default=None
377
+ ),
378
+ "system_value": self._attempt(
379
+ lambda d=dim: d.SystemValue, default=None
380
+ ),
381
+ "read_only": bool(
382
+ self._attempt(
383
+ lambda d=dim: d.ReadOnly, default=False
384
+ )
385
+ ),
386
+ }
387
+ )
388
+ display = self._attempt(
389
+ lambda f=feat, d=display: f.GetNextDisplayDimension(d),
390
+ default=None,
391
+ )
392
+ feature = self._attempt(lambda f=feat: f.GetNextFeature(), default=None)
393
+ if target and not dims:
394
+ raise LookupError(
395
+ f"No dimensions found on feature {feature_name!r}; list "
396
+ "the feature tree with list_features for exact names"
397
+ )
398
+ return dims
399
+
400
+ return cast(
401
+ "AdapterResult[list[dict[str, Any]]]",
402
+ self._handle_com_operation("list_model_dimensions", _list),
403
+ )
404
+
405
+ async def measure_entities(
406
+ self, entities: list[str]
407
+ ) -> AdapterResult[dict[str, Any]]:
408
+ """Measure one or more entities with SolidWorks' Measure tool.
409
+
410
+ Selects each entity by spec (see :func:`_parse_measure_spec`), then runs
411
+ ``IModelDocExtension::CreateMeasure`` + ``IMeasure::Calculate(None)``,
412
+ which measures the current selection. Values SolidWorks reports as
413
+ ``-1`` (not applicable to the selection) are dropped.
414
+
415
+ Args:
416
+ entities: One or two entity specs, e.g. ``["face:0,0.01,0"]`` for a
417
+ face area, or two edge/face specs for distance and angle.
418
+
419
+ Returns:
420
+ AdapterResult[dict[str, Any]]: Measured values in metres, square
421
+ metres and radians, plus ``selected``; ``is_parallel`` only for
422
+ two entities.
423
+ """
424
+ guard = self._require_doc_type({1, 2}, "measure_entities")
425
+ if guard is not None:
426
+ return cast("AdapterResult[dict[str, Any]]", guard)
427
+ if not entities or len(entities) > 2:
428
+ return AdapterResult(
429
+ status=AdapterResultStatus.ERROR,
430
+ error="measure_entities takes one or two entity specs",
431
+ )
432
+
433
+ def _measure() -> dict[str, Any]:
434
+ model = self.currentModel
435
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
436
+ for index, spec in enumerate(entities):
437
+ name, x, y, z, kind = _parse_measure_spec(spec)
438
+ append = index > 0
439
+ if name:
440
+ ok = bool(
441
+ self._attempt(
442
+ lambda n=name, k=kind, a=append: (
443
+ model.Extension.SelectByID2(
444
+ n, k, 0.0, 0.0, 0.0, a, 0, _null_callout(), 0
445
+ )
446
+ ),
447
+ default=False,
448
+ )
449
+ )
450
+ else:
451
+ ok = _select_edge_by_coord(self, x, y, z, append, 0, kind)
452
+ if not ok:
453
+ raise LookupError(
454
+ f"No entity found for {spec!r}; check the name or the "
455
+ "coordinates (metres)"
456
+ )
457
+
458
+ # CreateMeasure is a zero-arg method; unflagged, late binding asks
459
+ # for it as a property and SW 2026 answers "Member not found".
460
+ # Not wrapped in _attempt: a COM error surfaces with its message.
461
+ extension = _sw_type_info.flagged(model.Extension, "IModelDocExtension")
462
+ measure = extension.CreateMeasure()
463
+ if measure is None:
464
+ raise Exception("CreateMeasure returned nothing")
465
+ measure = _sw_type_info.flagged(measure, "IMeasure")
466
+ if not measure.Calculate(None):
467
+ raise Exception("IMeasure.Calculate failed for this selection")
468
+
469
+ result: dict[str, Any] = {"selected": list(entities)}
470
+ for prop, key in (
471
+ ("Distance", "distance_m"),
472
+ ("CenterDistance", "center_distance_m"),
473
+ ("Angle", "angle_rad"),
474
+ ("Radius", "radius_m"),
475
+ ("Diameter", "diameter_m"),
476
+ ("Length", "length_m"),
477
+ ("ArcLength", "arc_length_m"),
478
+ ("Area", "area_m2"),
479
+ ("Perimeter", "perimeter_m"),
480
+ ("DeltaX", "delta_x_m"),
481
+ ("DeltaY", "delta_y_m"),
482
+ ("DeltaZ", "delta_z_m"),
483
+ ):
484
+ value = self._attempt(lambda p=prop: getattr(measure, p), default=None)
485
+ if isinstance(value, (int, float)) and value != -1:
486
+ result[key] = float(value)
487
+ if len(entities) == 2:
488
+ # Parallelism only means something between two entities.
489
+ parallel = self._attempt(lambda: measure.IsParallel, default=None)
490
+ if isinstance(parallel, bool):
491
+ result["is_parallel"] = parallel
492
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
493
+ return result
494
+
495
+ return cast(
496
+ "AdapterResult[dict[str, Any]]",
497
+ self._handle_com_operation("measure_entities", _measure),
498
+ )
499
+
500
+ async def get_material(
501
+ self, configuration: str | None = None
502
+ ) -> AdapterResult[dict[str, Any]]:
503
+ """Read the material assigned to the active part.
504
+
505
+ Wraps ``IPartDoc::GetMaterialPropertyName2``; see
506
+ :func:`_read_material` for its ``Database`` out-parameter.
507
+
508
+ Args:
509
+ configuration: Configuration name; the active one when omitted.
510
+
511
+ Returns:
512
+ AdapterResult[dict[str, Any]]: ``material`` (``None`` when unset)
513
+ and ``database``.
514
+ """
515
+ guard = self._require_doc_type({1}, "get_material")
516
+ if guard is not None:
517
+ return cast("AdapterResult[dict[str, Any]]", guard)
518
+
519
+ def _get() -> dict[str, Any]:
520
+ part = _sw_type_info.flagged(self.currentModel, "IPartDoc")
521
+ name, database = _read_material(part, configuration)
522
+ return {
523
+ "material": name or None,
524
+ "database": database or None,
525
+ "configuration": configuration,
526
+ }
527
+
528
+ return cast(
529
+ "AdapterResult[dict[str, Any]]",
530
+ self._handle_com_operation("get_material", _get),
531
+ )
532
+
533
+ async def set_material(
534
+ self,
535
+ material_name: str,
536
+ database: str | None = None,
537
+ configuration: str | None = None,
538
+ ) -> AdapterResult[dict[str, Any]]:
539
+ """Assign a material to the active part.
540
+
541
+ Wraps ``IPartDoc::SetMaterialPropertyName2`` (a ``void`` call), then
542
+ reads the material back - the read-back is the only proof it applied.
543
+ A name the library does not hold leaves the part unchanged and is
544
+ reported as not found.
545
+
546
+ Args:
547
+ material_name: Material name exactly as in the library, e.g.
548
+ ``"AISI 1020"`` or ``"6061-T6 (SS)"``.
549
+ database: Library name; ``"SOLIDWORKS Materials"`` when omitted.
550
+ configuration: Configuration name; ``""`` (all) when omitted.
551
+
552
+ Returns:
553
+ AdapterResult[dict[str, Any]]: Requested and applied material.
554
+ """
555
+ guard = self._require_doc_type({1}, "set_material")
556
+ if guard is not None:
557
+ return cast("AdapterResult[dict[str, Any]]", guard)
558
+ if not material_name.strip():
559
+ return AdapterResult(
560
+ status=AdapterResultStatus.ERROR, error="material_name is required"
561
+ )
562
+ library = database or _DEFAULT_MATERIAL_DATABASE
563
+
564
+ def _set() -> dict[str, Any]:
565
+ part = _sw_type_info.flagged(self.currentModel, "IPartDoc")
566
+ part.SetMaterialPropertyName2(configuration or "", library, material_name)
567
+ self._attempt(lambda: self.currentModel.EditRebuild3(), default=None)
568
+ applied, _library = _read_material(part, configuration)
569
+ if applied.lower() != material_name.lower():
570
+ # SolidWorks ignores a name its library does not hold.
571
+ raise LookupError(
572
+ f"Material {material_name!r} not found in {library!r}; the "
573
+ f"part still reports {applied or 'no material'}. Use "
574
+ "list_available_materials for the exact name and library."
575
+ )
576
+ return {
577
+ "material": applied,
578
+ "database": library,
579
+ "configuration": configuration,
580
+ }
581
+
582
+ return cast(
583
+ "AdapterResult[dict[str, Any]]",
584
+ self._handle_com_operation("set_material", _set),
585
+ )
586
+
587
+ async def list_available_materials(
588
+ self, search: str | None = None
589
+ ) -> AdapterResult[list[dict[str, str]]]:
590
+ """List materials from the configured SolidWorks material libraries.
591
+
592
+ Reads the custom library folders from
593
+ ``swFileLocationsMaterialDatabases`` (a ``;``-separated list) plus the
594
+ install's built-in ``sldmaterials`` folder, and parses every
595
+ ``.sldmat`` file in them.
596
+
597
+ Args:
598
+ search: Case-insensitive substring filter on name or classification.
599
+
600
+ Returns:
601
+ AdapterResult[list[dict[str, str]]]: ``name``, ``classification``
602
+ and ``database`` per material. ``metadata`` gives the ``folders``
603
+ read and the ``total`` before filtering, so an empty search can be
604
+ told apart from an unexpected library location.
605
+ """
606
+ app = getattr(self, "swApp", None)
607
+ if app is None:
608
+ return AdapterResult(
609
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
610
+ )
611
+ needle = (search or "").strip().lower()
612
+ searched: dict[str, Any] = {}
613
+
614
+ def _list() -> list[dict[str, str]]:
615
+ configured = str(
616
+ self._attempt(
617
+ lambda: app.GetUserPreferenceStringValue(
618
+ _SW_PREF_MATERIAL_DATABASES
619
+ ),
620
+ default="",
621
+ )
622
+ or ""
623
+ )
624
+ # swFileLocationsMaterialDatabases lists only the folders the user
625
+ # added: "" on a fresh SW 2025 install, just "Custom Materials" on
626
+ # SW 2026 (both measured live). SolidWorks always shows its
627
+ # built-in <install>\lang\<language>\sldmaterials library as
628
+ # well, so read that too.
629
+ exe_dir = str(
630
+ self._attempt(lambda: app.GetExecutablePath(), default="") or ""
631
+ )
632
+ language = str(
633
+ self._attempt(lambda: app.GetCurrentLanguage(), default="") or "english"
634
+ )
635
+ if exe_dir.lower().endswith(".exe"):
636
+ exe_dir = os.path.dirname(exe_dir)
637
+ built_in = os.path.join(exe_dir, "lang", language, "sldmaterials")
638
+ folder_list = [f.strip() for f in configured.split(";") if f.strip()]
639
+ if os.path.normcase(built_in) not in map(os.path.normcase, folder_list):
640
+ folder_list.append(built_in)
641
+ folders = ";".join(folder_list)
642
+ materials: list[dict[str, str]] = []
643
+ for folder in folder_list:
644
+ if not os.path.isdir(folder):
645
+ continue
646
+ for entry in sorted(os.listdir(folder)):
647
+ if entry.lower().endswith(".sldmat"):
648
+ materials.extend(
649
+ _parse_material_library(os.path.join(folder, entry))
650
+ )
651
+ searched["folders"] = folder_list
652
+ searched["total"] = len(materials)
653
+ if not materials:
654
+ raise LookupError(f"No .sldmat libraries found in: {folders}")
655
+ if needle:
656
+ materials = [
657
+ m
658
+ for m in materials
659
+ if needle in m["name"].lower()
660
+ or needle in m["classification"].lower()
661
+ ]
662
+ return materials
663
+
664
+ result = cast(
665
+ "AdapterResult[list[dict[str, str]]]",
666
+ self._handle_com_operation("list_available_materials", _list),
667
+ )
668
+ if result.is_success:
669
+ result.metadata = dict(searched)
670
+ return result
671
+
672
+ async def get_custom_properties(
673
+ self, configuration: str | None = None
674
+ ) -> AdapterResult[dict[str, Any]]:
675
+ """Read the custom properties of the active document.
676
+
677
+ Wraps ``IModelDocExtension::CustomPropertyManager(config)`` with
678
+ ``GetNames`` + ``Get6``. ``""`` reads the file-level properties a
679
+ drawing title block usually links to.
680
+
681
+ Args:
682
+ configuration: Configuration name; file-level when omitted.
683
+
684
+ Returns:
685
+ AdapterResult[dict[str, Any]]: ``properties`` mapping name to
686
+ ``{"value", "resolved"}``.
687
+ """
688
+ if not self.currentModel:
689
+ return AdapterResult(
690
+ status=AdapterResultStatus.ERROR, error="No active model"
691
+ )
692
+
693
+ def _get() -> dict[str, Any]:
694
+ manager = _sw_type_info.flagged(
695
+ self.currentModel.Extension.CustomPropertyManager(configuration or ""),
696
+ "ICustomPropertyManager",
697
+ )
698
+ props: dict[str, dict[str, str]] = {}
699
+ names = self._attempt(
700
+ lambda: self._get_attr_or_call(manager, "GetNames"), default=None
701
+ )
702
+ for name in _sequence(names):
703
+ value, resolved = _read_custom_property(self, manager, str(name))
704
+ props[str(name)] = {"value": value, "resolved": resolved}
705
+ return {"configuration": configuration or "", "properties": props}
706
+
707
+ return cast(
708
+ "AdapterResult[dict[str, Any]]",
709
+ self._handle_com_operation("get_custom_properties", _get),
710
+ )
711
+
712
+ async def set_custom_property(
713
+ self, name: str, value: str, configuration: str | None = None
714
+ ) -> AdapterResult[dict[str, Any]]:
715
+ """Create or overwrite one text custom property on the active document.
716
+
717
+ Wraps ``ICustomPropertyManager::Add3(name, swCustomInfoText, value,
718
+ swCustomPropertyReplaceValue)`` and reads the value back.
719
+
720
+ Args:
721
+ name: Property name, e.g. ``"PartNo"`` or ``"DrawnBy"``.
722
+ value: Text value; may be a ``$PRP:"..."`` link.
723
+ configuration: Configuration name; file-level when omitted.
724
+
725
+ Returns:
726
+ AdapterResult[dict[str, Any]]: The stored and resolved value.
727
+ """
728
+ if not self.currentModel:
729
+ return AdapterResult(
730
+ status=AdapterResultStatus.ERROR, error="No active model"
731
+ )
732
+ if not name.strip():
733
+ return AdapterResult(
734
+ status=AdapterResultStatus.ERROR, error="name is required"
735
+ )
736
+
737
+ def _set() -> dict[str, Any]:
738
+ manager = _sw_type_info.flagged(
739
+ self.currentModel.Extension.CustomPropertyManager(configuration or ""),
740
+ "ICustomPropertyManager",
741
+ )
742
+ code = manager.Add3(
743
+ name,
744
+ _SW_CUSTOM_INFO_TEXT,
745
+ str(value),
746
+ _SW_CUSTOM_PROPERTY_REPLACE_VALUE,
747
+ )
748
+ stored, resolved = _read_custom_property(self, manager, name)
749
+ if stored != str(value):
750
+ raise Exception(
751
+ f"Custom property {name!r} was not stored (Add3 returned {code!r}, "
752
+ f"read back {stored!r})."
753
+ )
754
+ return {
755
+ "name": name,
756
+ "value": stored,
757
+ "resolved": resolved,
758
+ "configuration": configuration or "",
759
+ }
760
+
761
+ return cast(
762
+ "AdapterResult[dict[str, Any]]",
763
+ self._handle_com_operation("set_custom_property", _set),
764
+ )
765
+
766
+ async def export_flat_pattern(
767
+ self, file_path: str
768
+ ) -> AdapterResult[dict[str, Any]]:
769
+ """Export the active sheet-metal part's flat pattern to DXF or DWG.
770
+
771
+ Wraps ``IPartDoc::ExportToDWG2`` with ``swExportToDWG_ExportSheetMetal``.
772
+ SheetMetalOptions ``1`` exports the flat-pattern geometry only (no bend
773
+ lines or sketches).
774
+
775
+ Args:
776
+ file_path: Output ``.dxf`` or ``.dwg`` path.
777
+
778
+ Returns:
779
+ AdapterResult[dict[str, Any]]: The written path and its size.
780
+ """
781
+ guard = self._require_doc_type({1}, "export_flat_pattern")
782
+ if guard is not None:
783
+ return cast("AdapterResult[dict[str, Any]]", guard)
784
+ out = os.path.abspath(file_path)
785
+ if os.path.splitext(out)[1].lower() not in {".dxf", ".dwg"}:
786
+ return AdapterResult(
787
+ status=AdapterResultStatus.ERROR,
788
+ error="file_path must end in .dxf or .dwg",
789
+ )
790
+
791
+ def _export() -> dict[str, Any]:
792
+ model = self.currentModel
793
+ part = _sw_type_info.flagged(model, "IPartDoc")
794
+ bodies = _sequence(
795
+ self._attempt(
796
+ lambda: part.GetBodies2(_SW_SOLID_BODY, True), default=None
797
+ )
798
+ )
799
+ if not any(
800
+ self._attempt(
801
+ lambda b=b: _as_com(self, b, "IBody2").IsSheetMetal(), default=False
802
+ )
803
+ for b in bodies
804
+ ):
805
+ raise LookupError(
806
+ "The active part has no sheet-metal body, so it has no flat "
807
+ "pattern to export"
808
+ )
809
+ os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
810
+ model_path = str(
811
+ self._attempt(lambda: model.GetPathName(), default="") or ""
812
+ )
813
+ ok = part.ExportToDWG2(
814
+ out,
815
+ model_path,
816
+ _SW_EXPORT_SHEET_METAL,
817
+ True,
818
+ None,
819
+ False,
820
+ False,
821
+ 1,
822
+ None,
823
+ )
824
+ if not ok or not os.path.exists(out):
825
+ raise Exception(f"ExportToDWG2 did not write {out} (returned {ok!r})")
826
+ return {"file_path": out, "size_bytes": os.path.getsize(out)}
827
+
828
+ return cast(
829
+ "AdapterResult[dict[str, Any]]",
830
+ self._handle_com_operation("export_flat_pattern", _export),
831
+ )
832
+
833
+ # ------------------------------------------------------------------
834
+ # Drawing documentation
835
+ # ------------------------------------------------------------------
836
+
837
+ def _view_dimension_count(self, view: Any) -> int:
838
+ """Count the display dimensions on a drawing view.
839
+
840
+ Args:
841
+ view: The ``IView``.
842
+
843
+ Returns:
844
+ int: ``IView::GetDisplayDimensionCount``; 0 when unreadable.
845
+ """
846
+ count = self._attempt(lambda: view.GetDisplayDimensionCount(), default=0)
847
+ return int(count) if isinstance(count, (int, float)) else 0
848
+
849
+ def _pictorial(self, view: Any) -> bool:
850
+ """True for isometric/dimetric/trimetric views, which are not dimensioned.
851
+
852
+ Args:
853
+ view: The ``IView``.
854
+
855
+ Returns:
856
+ bool: Whether the view's orientation name is pictorial.
857
+ """
858
+ name = str(self._attempt(lambda: view.GetOrientationName(), default="") or "")
859
+ return any(k in name.lower() for k in ("isometric", "dimetric", "trimetric"))
860
+
861
+ def _delete_display_dimension(self, model: Any, an: Any) -> bool:
862
+ """Delete one drawing dimension.
863
+
864
+ ``IAnnotation::Select3`` selects nothing under late binding (False with
865
+ a null dispatch, type mismatch with ``None``); selecting by
866
+ ``IDisplayDimension::GetNameForSelection`` with type ``DIMENSION`` and
867
+ ``DeleteSelection2`` works - measured live on SW 2025 (pitfall #30).
868
+
869
+ Args:
870
+ model: The drawing document.
871
+ an: The dimension's ``IAnnotation``.
872
+
873
+ Returns:
874
+ bool: Whether it was selected and deleted.
875
+ """
876
+ selected = self._select_display_dimension(model, an)
877
+ deleted = selected and bool(
878
+ self._attempt(lambda: model.Extension.DeleteSelection2(0), default=False)
879
+ )
880
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
881
+ return deleted
882
+
883
+ def _select_display_dimension(self, model: Any, an: Any) -> bool:
884
+ """Select one drawing dimension, alone (pitfall #30: by its selection name).
885
+
886
+ Args:
887
+ model: The drawing document.
888
+ an: The dimension's ``IAnnotation``.
889
+
890
+ Returns:
891
+ bool: Whether it is now the selection.
892
+ """
893
+ specific = self._attempt(lambda: an.GetSpecificAnnotation(), default=None)
894
+ disp = (
895
+ _as_com(self, specific, "IDisplayDimension")
896
+ if specific is not None
897
+ else None
898
+ )
899
+ sel_name = (
900
+ self._attempt(lambda: disp.GetNameForSelection(), default=None)
901
+ if disp
902
+ else None
903
+ )
904
+ if not sel_name:
905
+ return False
906
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
907
+ return bool(
908
+ self._attempt(
909
+ lambda: model.Extension.SelectByID2(
910
+ sel_name, "DIMENSION", 0.0, 0.0, 0.0, False, 0, _null_callout(), 0
911
+ ),
912
+ default=False,
913
+ )
914
+ )
915
+
916
+ def _dimension_annotations(self, view: Any) -> list[tuple[Any, str | None]]:
917
+ """List the display dimensions in a view with their model names.
918
+
919
+ Args:
920
+ view: The ``IView``.
921
+
922
+ Returns:
923
+ list[tuple[Any, str | None]]: ``(IAnnotation, "D1@Sketch1")`` per
924
+ dimension; the name is ``None`` when it cannot be read.
925
+ """
926
+ found: list[tuple[Any, str | None]] = []
927
+ ann = self._attempt(lambda: view.GetFirstAnnotation3(), default=None)
928
+ guard = 0
929
+ while ann is not None and guard < 2000:
930
+ guard += 1
931
+ an = _sw_type_info.flagged(ann, "IAnnotation")
932
+ if self._attempt(lambda a=an: a.GetType(), default=None) == (
933
+ _SW_ANNOTATION_DISPLAY_DIMENSION
934
+ ):
935
+ specific = self._attempt(
936
+ lambda a=an: a.GetSpecificAnnotation(), default=None
937
+ )
938
+ name = None
939
+ if specific is not None:
940
+ disp = _sw_type_info.flagged(specific, "IDisplayDimension")
941
+ dim = self._attempt(lambda d=disp: d.GetDimension2(0), default=None)
942
+ if dim is not None:
943
+ name = _strip_document_suffix(
944
+ self._attempt(
945
+ lambda d=dim: (
946
+ _sw_type_info.flagged(d, "IDimension").FullName
947
+ ),
948
+ default=None,
949
+ )
950
+ )
951
+ found.append((an, name))
952
+ ann = self._attempt(lambda a=an: a.GetNext3(), default=None)
953
+ return found
954
+
955
+ def _import_dimensions(
956
+ self,
957
+ view_name: str | None = None,
958
+ include_hole_wizard: bool = True,
959
+ only_views: list[str] | None = None,
960
+ ) -> dict[str, Any]:
961
+ """Import model dimensions into views; the body of insert_model_dimensions.
962
+
963
+ Args:
964
+ view_name: One view; every orthographic view when ``None``.
965
+ include_hole_wizard: Also import Hole Wizard location dimensions.
966
+ only_views: Exactly these views (the standard drawing's layout).
967
+
968
+ Returns:
969
+ dict[str, Any]: Per-view counts and what was done; see
970
+ :meth:`insert_model_dimensions`.
971
+
972
+ Raises:
973
+ LookupError: ``view_name`` is not on the drawing, or there is no
974
+ view to dimension.
975
+ Exception: Nothing could be imported or auto-dimensioned.
976
+ """
977
+ model = self.currentModel
978
+ drawing = _sw_type_info.flagged(model, "IDrawingDoc")
979
+ all_names = _view_names(self, drawing)
980
+ if view_name and view_name not in all_names:
981
+ raise LookupError(
982
+ f"No view named {view_name!r}. Views: {', '.join(all_names) or 'none'}"
983
+ )
984
+ views = {n: _resolve_drawing_view(self, drawing, n) for n in all_names}
985
+ skipped = (
986
+ [] if view_name else [n for n, v in views.items() if self._pictorial(v)]
987
+ )
988
+ names = [view_name] if view_name else [n for n in all_names if n not in skipped]
989
+ if only_views:
990
+ names = [n for n in only_views if n in all_names]
991
+ if not names:
992
+ raise LookupError(
993
+ "No drawing views found to dimension (pictorial views are skipped "
994
+ "unless named); add views with create_technical_drawing or "
995
+ "create_drawing_view"
996
+ )
997
+ before = {n: self._view_dimension_count(views[n]) for n in names}
998
+
999
+ types = _SW_INSERT_DIMENSIONS_MARKED_FOR_DRAWING
1000
+ if include_hole_wizard:
1001
+ types |= _SW_INSERT_HOLE_WIZARD_LOCATION_DIMS
1002
+ # Dimensions already on the sheet (any view) count as shown.
1003
+ shown: set[str] = set()
1004
+ for name in all_names:
1005
+ if name in names:
1006
+ continue
1007
+ shown.update(n for _, n in self._dimension_annotations(views[name]) if n)
1008
+ removed: dict[str, int] = {}
1009
+ method = "insert_model_annotations"
1010
+ imported: dict[str, list[tuple[Any, str | None]]] = {}
1011
+ for name in names:
1012
+ self._attempt(lambda n=name: drawing.ActivateView(n), default=None)
1013
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1014
+ self._attempt(
1015
+ lambda n=name: model.Extension.SelectByID2(
1016
+ n, "DRAWINGVIEW", 0.0, 0.0, 0.0, False, 0, _null_callout(), 0
1017
+ ),
1018
+ default=None,
1019
+ )
1020
+ self._attempt(
1021
+ lambda: drawing.InsertModelAnnotations3(
1022
+ _SW_IMPORT_FROM_ENTIRE_MODEL, types, False, False, False, False
1023
+ ),
1024
+ default=None,
1025
+ )
1026
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1027
+ imported[name] = self._dimension_annotations(views[name])
1028
+ # Each dimension stays in the view that took the most dimensions: the
1029
+ # one that shows the sketch's true shape (a sketch on the Top plane is
1030
+ # dimensioned in the plan, not projected edge-on into the front view,
1031
+ # where holes stack up). Ties keep the view order.
1032
+ for name in sorted(names, key=lambda n: -len(imported[n])):
1033
+ removed[name] = 0
1034
+ for an, dim_name in imported[name]:
1035
+ if dim_name and dim_name in shown:
1036
+ if self._delete_display_dimension(model, an):
1037
+ removed[name] += 1
1038
+ elif dim_name:
1039
+ shown.add(dim_name)
1040
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1041
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1042
+
1043
+ autodim_status: Any = None
1044
+ if not shown and all(self._view_dimension_count(views[n]) == 0 for n in names):
1045
+ method = "auto_dimension"
1046
+ for name in names:
1047
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1048
+ self._attempt(
1049
+ lambda n=name: model.Extension.SelectByID2(
1050
+ n,
1051
+ "DRAWINGVIEW",
1052
+ 0.0,
1053
+ 0.0,
1054
+ 0.0,
1055
+ False,
1056
+ 0,
1057
+ _null_callout(),
1058
+ 0,
1059
+ ),
1060
+ default=None,
1061
+ )
1062
+ autodim_status = self._attempt(
1063
+ lambda: drawing.AutoDimension(
1064
+ _SW_AUTODIM_ENTITIES_ALL,
1065
+ _SW_AUTODIM_SCHEME_BASELINE,
1066
+ _SW_AUTODIM_HORIZONTAL_BELOW,
1067
+ _SW_AUTODIM_SCHEME_BASELINE,
1068
+ _SW_AUTODIM_VERTICAL_LEFT,
1069
+ ),
1070
+ default=None,
1071
+ )
1072
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1073
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1074
+
1075
+ per_view = []
1076
+ for name in names:
1077
+ after = self._view_dimension_count(views[name])
1078
+ per_view.append(
1079
+ {
1080
+ "view": name,
1081
+ "dimensions_before": before[name],
1082
+ "dimensions_after": after,
1083
+ "dimensions_added": after - before[name],
1084
+ "duplicates_removed": removed.get(name, 0),
1085
+ }
1086
+ )
1087
+ total = sum(v["dimensions_added"] for v in per_view)
1088
+ on_sheet = sum(v["dimensions_after"] for v in per_view)
1089
+ if on_sheet == 0:
1090
+ raise Exception(
1091
+ "No dimensions were added to any view: InsertModelAnnotations3 "
1092
+ f"imported nothing and AutoDimension returned {autodim_status!r} "
1093
+ "(0 = success). See com-api-pitfalls #18."
1094
+ )
1095
+ return {
1096
+ "views": per_view,
1097
+ "dimensions_added": total,
1098
+ "unique_dimensions": len(shown),
1099
+ "skipped_pictorial_views": skipped,
1100
+ "method": method,
1101
+ "autodimension_status": autodim_status,
1102
+ }
1103
+
1104
+ async def insert_model_dimensions(
1105
+ self, view_name: str | None = None, include_hole_wizard: bool = True
1106
+ ) -> AdapterResult[dict[str, Any]]:
1107
+ """Bring model dimensions into drawing views, each dimension shown once.
1108
+
1109
+ Imports the model's "marked for drawing" dimensions view by view with
1110
+ ``IDrawingDoc::InsertModelAnnotations3`` (``UsePlacementInSketch`` off,
1111
+ so SolidWorks places them rather than copying sketch positions).
1112
+ Drafting rules applied:
1113
+
1114
+ * pictorial views (isometric, dimetric, trimetric) are not dimensioned
1115
+ unless named explicitly;
1116
+ * a model dimension already shown in an earlier view is deleted from
1117
+ later views, so each dimension appears exactly once on the sheet.
1118
+
1119
+ Falls back to ``IDrawingDoc::AutoDimension`` only when nothing at all
1120
+ could be imported. Placement is then made overlap-free by
1121
+ :meth:`arrange_dimensions`.
1122
+
1123
+ Args:
1124
+ view_name: One view to dimension; every orthographic view when omitted.
1125
+ include_hole_wizard: Also import Hole Wizard location dimensions.
1126
+
1127
+ Returns:
1128
+ AdapterResult[dict[str, Any]]: Per-view counts, duplicates removed,
1129
+ skipped pictorial views and the ``method`` used.
1130
+ """
1131
+ guard = self._require_drawing()
1132
+ if guard is not None:
1133
+ return cast("AdapterResult[dict[str, Any]]", guard)
1134
+
1135
+ def _insert() -> dict[str, Any]:
1136
+ return self._import_dimensions(view_name, include_hole_wizard)
1137
+
1138
+ return cast(
1139
+ "AdapterResult[dict[str, Any]]",
1140
+ self._handle_com_operation("insert_model_dimensions", _insert),
1141
+ )
1142
+
1143
+ # ------------------------------------------------------------------
1144
+ # Dimension layout: no dimension may overlap another
1145
+ # ------------------------------------------------------------------
1146
+
1147
+ def _read_view_dimensions(self, view: Any, view_name: str) -> list[tuple[Any, Any]]:
1148
+ """Return ``(annotation, DimensionGeometry)`` for every dimension in a view.
1149
+
1150
+ Geometry comes from ``IAnnotation::GetDisplayData``: text boxes from
1151
+ ``GetTextPositionAtIndex`` (lower-left) with the larger of
1152
+ ``GetTextInBoxWidthAtIndex`` and a width estimated from the text and
1153
+ ``GetTextHeightAtIndex``, turned by ``GetTextAngleAtIndex`` (a vertical
1154
+ dimension's text runs up the sheet); lines from ``GetLineAtIndex3``, whose
1155
+ endpoints are elements 4-5 and 7-8 of the returned tuple (measured
1156
+ live on SW 2025).
1157
+
1158
+ Args:
1159
+ view: The ``IView``.
1160
+ view_name: Its name, used in each dimension's key.
1161
+
1162
+ Returns:
1163
+ list[tuple[Any, Any]]: ``(IAnnotation, DimensionGeometry)`` pairs.
1164
+ """
1165
+ found: list[tuple[Any, Any]] = []
1166
+ ann = self._attempt(lambda: view.GetFirstAnnotation3(), default=None)
1167
+ index = 0
1168
+ while ann is not None and index < 2000:
1169
+ an = _sw_type_info.flagged(ann, "IAnnotation")
1170
+ kind = self._attempt(lambda a=an: a.GetType(), default=None)
1171
+ if kind == _SW_ANNOTATION_DISPLAY_DIMENSION:
1172
+ geometry = self._dimension_geometry(
1173
+ an, f"{view_name}#{index}", view_name
1174
+ )
1175
+ if geometry is not None:
1176
+ found.append((an, geometry))
1177
+ index += 1
1178
+ ann = self._attempt(lambda a=an: a.GetNext3(), default=None)
1179
+ return found
1180
+
1181
+ def _dimension_geometry(self, an: Any, key: str, view_name: str) -> Any:
1182
+ """Read one dimension's text box and lines from its display data.
1183
+
1184
+ Args:
1185
+ an: The dimension's ``IAnnotation``.
1186
+ key: Stable key for the layout (view name + index).
1187
+ view_name: The owning view.
1188
+
1189
+ Returns:
1190
+ Any: A ``DimensionGeometry``, or ``None`` when the position or any
1191
+ text cannot be read.
1192
+ """
1193
+ position = self._attempt(lambda: an.GetPosition(), default=None)
1194
+ data = self._attempt(lambda: an.GetDisplayData(), default=None)
1195
+ if not isinstance(position, (list, tuple)) or data is None:
1196
+ return None
1197
+ dd = _sw_type_info.flagged(data, "IDisplayData")
1198
+ boxes: list[tuple[float, float, float, float]] = []
1199
+ for i in range(int(self._attempt(lambda: dd.GetTextCount(), default=0) or 0)):
1200
+ pos = self._attempt(lambda i=i: dd.GetTextPositionAtIndex(i), default=None)
1201
+ if not isinstance(pos, (list, tuple)) or len(pos) < 2:
1202
+ continue
1203
+ text = str(
1204
+ self._attempt(lambda i=i: dd.GetTextAtIndex(i), default="") or ""
1205
+ )
1206
+ height = float(
1207
+ self._attempt(lambda i=i: dd.GetTextHeightAtIndex(i), default=0.0)
1208
+ or 0.0
1209
+ )
1210
+ box_w = float(
1211
+ self._attempt(lambda i=i: dd.GetTextInBoxWidthAtIndex(i), default=0.0)
1212
+ or 0.0
1213
+ )
1214
+ angle = float(
1215
+ self._attempt(lambda i=i: dd.GetTextAngleAtIndex(i), default=0.0) or 0.0
1216
+ )
1217
+ width = max(box_w, _layout.shown_length(text) * height * 0.62)
1218
+ boxes.append(
1219
+ _layout.text_box(pos[0], pos[1], width, max(height, 0.001), angle)
1220
+ )
1221
+ if not boxes:
1222
+ return None
1223
+ text_box = (
1224
+ min(b[0] for b in boxes),
1225
+ min(b[1] for b in boxes),
1226
+ max(b[2] for b in boxes),
1227
+ max(b[3] for b in boxes),
1228
+ )
1229
+ lines = []
1230
+ for i in range(int(self._attempt(lambda: dd.GetLineCount(), default=0) or 0)):
1231
+ raw = self._attempt(lambda i=i: dd.GetLineAtIndex3(i), default=None)
1232
+ if isinstance(raw, (list, tuple)) and len(raw) >= 9:
1233
+ lines.append(
1234
+ (float(raw[4]), float(raw[5]), float(raw[7]), float(raw[8]))
1235
+ )
1236
+ return _layout.DimensionGeometry(
1237
+ key=key,
1238
+ view=view_name,
1239
+ text_box=text_box,
1240
+ position=(float(position[0]), float(position[1])),
1241
+ lines=lines,
1242
+ )
1243
+
1244
+ def _sheet_bounds(self, drawing: Any) -> Any:
1245
+ """Sheet rectangle from ``ISheet::GetProperties2`` (width/height at 5, 6).
1246
+
1247
+ Args:
1248
+ drawing: The ``IDrawingDoc``.
1249
+
1250
+ Returns:
1251
+ Any: ``(0, 0, width, height)`` in metres, or ``None`` when unreadable.
1252
+ """
1253
+ sheet = self._attempt(lambda: drawing.GetCurrentSheet(), default=None)
1254
+ if sheet is None:
1255
+ return None
1256
+ props = self._attempt(
1257
+ lambda: _sw_type_info.flagged(sheet, "ISheet").GetProperties2(),
1258
+ default=None,
1259
+ )
1260
+ if isinstance(props, (list, tuple)) and len(props) >= 7:
1261
+ width, height = props[5], props[6]
1262
+ if (
1263
+ isinstance(width, (int, float))
1264
+ and isinstance(height, (int, float))
1265
+ and width > 0
1266
+ ):
1267
+ return (0.0, 0.0, float(width), float(height))
1268
+ return None
1269
+
1270
+ def _collect_layout(
1271
+ self, drawing: Any, names: list[str]
1272
+ ) -> tuple[dict, list, dict]:
1273
+ """Read every named view's outline and dimension geometry.
1274
+
1275
+ Args:
1276
+ drawing: The ``IDrawingDoc``.
1277
+ names: View names to read.
1278
+
1279
+ Returns:
1280
+ tuple[dict, list, dict]: View outlines by name, every
1281
+ ``DimensionGeometry``, and each geometry key's ``IAnnotation``.
1282
+ """
1283
+ outlines: dict[str, tuple[float, float, float, float]] = {}
1284
+ annotations: dict[str, Any] = {}
1285
+ geometries: list[Any] = []
1286
+ for name in names:
1287
+ view = _resolve_drawing_view(self, drawing, name)
1288
+ if view is None:
1289
+ continue
1290
+ outline = self._attempt(lambda v=view: v.GetOutline(), default=None)
1291
+ if isinstance(outline, (list, tuple)) and len(outline) >= 4:
1292
+ outlines[name] = tuple(float(c) for c in outline[:4])
1293
+ for an, geometry in self._read_view_dimensions(view, name):
1294
+ annotations[geometry.key] = an
1295
+ geometries.append(geometry)
1296
+ return outlines, geometries, annotations
1297
+
1298
+ def _arrange_layout(
1299
+ self,
1300
+ view_name: str | None = None,
1301
+ obstacles: dict[str, Any] | None = None,
1302
+ work_area: Any = None,
1303
+ move: bool = True,
1304
+ attached: dict[str, Any] | None = None,
1305
+ ) -> dict[str, Any]:
1306
+ """Plan, apply and verify the dimension layout; arrange_dimensions' body.
1307
+
1308
+ Args:
1309
+ view_name: Only move this view's dimensions; all when ``None``.
1310
+ obstacles: Named boxes nothing may touch (title block, captions).
1311
+ work_area: The frame's working area; the sheet when ``None``.
1312
+ move: ``False`` only checks the current layout.
1313
+ attached: Dimension key -> box hanging off it (a GD&T frame),
1314
+ relative to its position; counted as part of its text.
1315
+
1316
+ Returns:
1317
+ dict[str, Any]: ``dimensions``, ``moved``, ``unplaced``,
1318
+ ``passes``, ``trace`` (each pass's plan, moves and overlaps),
1319
+ ``overlaps_before``/``overlaps_after``, ``remaining_overlaps`` and
1320
+ ``sheet_size_m``. Remaining overlaps are reported, not raised.
1321
+
1322
+ Raises:
1323
+ LookupError: ``view_name`` is not on the drawing.
1324
+ """
1325
+ model = self.currentModel
1326
+ drawing = _sw_type_info.flagged(model, "IDrawingDoc")
1327
+ all_views = _view_names(self, drawing)
1328
+ if view_name and view_name not in all_views:
1329
+ raise LookupError(
1330
+ f"No view named {view_name!r}. Views: {', '.join(all_views) or 'none'}"
1331
+ )
1332
+ sheet = work_area or self._sheet_bounds(drawing)
1333
+ outlines, geometries, annotations = self._collect_layout(drawing, all_views)
1334
+ _layout.attach_boxes(geometries, attached or {})
1335
+ before = _layout.find_overlaps(geometries, outlines, sheet, obstacles)
1336
+ after = before
1337
+ moved, unplaced, passes, trace = 0, [], 0, []
1338
+ # The first pass plans every movable dimension from where it was
1339
+ # imported. SolidWorks does not always draw a dimension where the
1340
+ # plan put it, so later passes re-plan only the dimensions still in
1341
+ # conflict, from the real read-back geometry, with the rest fixed.
1342
+ retry: set[str] | None = None
1343
+ while move and passes < _LAYOUT_PASSES and (retry is None or retry):
1344
+ passes += 1
1345
+ allowed = {
1346
+ g.key for g in geometries if not view_name or g.view == view_name
1347
+ }
1348
+ movable = allowed if retry is None else allowed & retry
1349
+ fixed = {g.key for g in geometries} - movable
1350
+ moves, unplaced = _layout.plan_layout(
1351
+ geometries,
1352
+ outlines,
1353
+ sheet,
1354
+ obstacles,
1355
+ fixed=fixed,
1356
+ inside_first=set(attached or {}),
1357
+ )
1358
+ applied = self._apply_moves(geometries, annotations, moves, movable)
1359
+ moved += applied
1360
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1361
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1362
+ self._attempt(lambda: model.GraphicsRedraw2(), default=None)
1363
+ outlines, geometries, annotations = self._collect_layout(drawing, all_views)
1364
+ _layout.attach_boxes(geometries, attached or {})
1365
+ after = _layout.find_overlaps(geometries, outlines, sheet, obstacles)
1366
+ # Both sides of a conflict are re-planned: a leader that grazes a
1367
+ # dimension's text is as likely the one to move as the text.
1368
+ keys = {g.key for g in geometries}
1369
+ retry = {
1370
+ k
1371
+ for p in after
1372
+ for k in (p["dimension"], p["conflicts_with"])
1373
+ if k in keys
1374
+ }
1375
+ trace.append(
1376
+ {
1377
+ "pass": passes,
1378
+ "movable": sorted(movable),
1379
+ "planned": {k: [round(d, 5) for d in v] for k, v in moves.items()},
1380
+ "moved": applied,
1381
+ "unplaced": unplaced,
1382
+ "overlaps": after[:50],
1383
+ }
1384
+ )
1385
+ result = {
1386
+ "dimensions": len(geometries),
1387
+ "moved": moved,
1388
+ "unplaced": unplaced,
1389
+ "passes": passes,
1390
+ "trace": trace,
1391
+ "overlaps_before": len(before),
1392
+ "overlaps_after": len(after),
1393
+ "remaining_overlaps": after[:50],
1394
+ "sheet_size_m": [sheet[2], sheet[3]] if sheet else None,
1395
+ }
1396
+ return result
1397
+
1398
+ def _apply_moves(
1399
+ self,
1400
+ geometries: list[Any],
1401
+ annotations: dict[str, Any],
1402
+ moves: dict[str, tuple[float, float]],
1403
+ movable: set[str],
1404
+ ) -> int:
1405
+ """Move each movable dimension by its planned offset; return how many moved.
1406
+
1407
+ Args:
1408
+ geometries: Every dimension's geometry, as read.
1409
+ annotations: Geometry key -> ``IAnnotation``.
1410
+ moves: Planned ``(dx, dy)`` per key.
1411
+ movable: Keys this pass may move.
1412
+
1413
+ Returns:
1414
+ int: Dimensions ``SetPosition`` accepted.
1415
+ """
1416
+ moved = 0
1417
+ for geometry in geometries:
1418
+ delta = moves.get(geometry.key)
1419
+ if geometry.key not in movable or not delta:
1420
+ continue
1421
+ if abs(delta[0]) < 1e-9 and abs(delta[1]) < 1e-9:
1422
+ continue
1423
+ an = annotations[geometry.key]
1424
+ x, y = geometry.position[0] + delta[0], geometry.position[1] + delta[1]
1425
+ current = self._attempt(lambda a=an: a.GetPosition(), default=None)
1426
+ z = (
1427
+ float(current[2])
1428
+ if isinstance(current, (list, tuple)) and len(current) > 2
1429
+ else 0.0
1430
+ )
1431
+ if self._attempt(
1432
+ lambda a=an, x=x, y=y, z=z: a.SetPosition(x, y, z), default=False
1433
+ ):
1434
+ moved += 1
1435
+ return moved
1436
+
1437
+ async def arrange_dimensions(
1438
+ self, view_name: str | None = None
1439
+ ) -> AdapterResult[dict[str, Any]]:
1440
+ """Move drawing dimensions so none overlaps another, then verify.
1441
+
1442
+ Reads every dimension's text box and lines, plans positions outside
1443
+ the views with :func:`dimension_layout.plan_layout`, applies them with
1444
+ ``IAnnotation::SetPosition``, rebuilds, then **re-reads the real
1445
+ geometry** and checks the rule with :func:`dimension_layout.find_overlaps`.
1446
+ The check covers every view on the sheet, even when only one view is
1447
+ rearranged, because neighbouring views' dimensions can collide.
1448
+
1449
+ Args:
1450
+ view_name: Only move this view's dimensions; all views when omitted.
1451
+
1452
+ Returns:
1453
+ AdapterResult[dict[str, Any]]: ``overlaps_before``, ``overlaps_after``
1454
+ (each violation listed), ``moved`` and ``unplaced``. ERROR when any
1455
+ overlap remains - the rule is not met - with the same data, so the
1456
+ caller can see which dimensions still collide.
1457
+ """
1458
+ guard = self._require_drawing()
1459
+ if guard is not None:
1460
+ return cast("AdapterResult[dict[str, Any]]", guard)
1461
+
1462
+ def _arrange() -> dict[str, Any]:
1463
+ return self._arrange_layout(view_name)
1464
+
1465
+ result = cast(
1466
+ "AdapterResult[dict[str, Any]]",
1467
+ self._handle_com_operation("arrange_dimensions", _arrange),
1468
+ )
1469
+ if result.is_success and result.data and result.data["overlaps_after"]:
1470
+ return AdapterResult(
1471
+ status=AdapterResultStatus.ERROR,
1472
+ error=_overlap_summary(result.data),
1473
+ data=result.data,
1474
+ execution_time=result.execution_time,
1475
+ )
1476
+ return result