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