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,3362 @@
|
|
|
1
|
+
"""Feature-domain mixin for PyWin32 SolidWorks operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
from .. import sw_type_info
|
|
11
|
+
from ..base import (
|
|
12
|
+
AdapterResult,
|
|
13
|
+
AdapterResultStatus,
|
|
14
|
+
ExtrusionParameters,
|
|
15
|
+
LoftParameters,
|
|
16
|
+
RevolveParameters,
|
|
17
|
+
SolidWorksFeature,
|
|
18
|
+
SweepParameters,
|
|
19
|
+
)
|
|
20
|
+
from .com_utils import as_list
|
|
21
|
+
from .com_utils import attempt as _attempt
|
|
22
|
+
from .com_utils import flag as _flag
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SolidWorksFeaturesMixin:
|
|
26
|
+
"""Expose SolidWorks feature methods via mixin-local implementation helpers."""
|
|
27
|
+
|
|
28
|
+
async def create_extrusion(
|
|
29
|
+
self, params: ExtrusionParameters
|
|
30
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
31
|
+
return _create_extrusion_impl(self, params)
|
|
32
|
+
|
|
33
|
+
async def create_revolve(
|
|
34
|
+
self, params: RevolveParameters
|
|
35
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
36
|
+
return _create_revolve_impl(self, params)
|
|
37
|
+
|
|
38
|
+
async def create_sweep(
|
|
39
|
+
self, params: SweepParameters
|
|
40
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
41
|
+
return _create_sweep_impl(self, params)
|
|
42
|
+
|
|
43
|
+
async def create_loft(
|
|
44
|
+
self, params: LoftParameters
|
|
45
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
46
|
+
return _create_loft_impl(self, params)
|
|
47
|
+
|
|
48
|
+
async def create_cut_extrude(
|
|
49
|
+
self, params: ExtrusionParameters
|
|
50
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
51
|
+
return _create_cut_extrude_impl(self, params)
|
|
52
|
+
|
|
53
|
+
async def add_fillet(
|
|
54
|
+
self, radius: float, edge_names: list[str]
|
|
55
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
56
|
+
"""Fillet edges, then confirm the model's volume actually changed.
|
|
57
|
+
|
|
58
|
+
``IModelDoc2.FeatureFillet3`` can report success without building
|
|
59
|
+
anything, so besides the feature-count check inside
|
|
60
|
+
:func:`_add_fillet_impl` the volume is compared before and after,
|
|
61
|
+
here in the async layer for the same reason as ``mirror_feature``.
|
|
62
|
+
An unreadable volume is not treated as failure.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
radius: Fillet radius in millimetres.
|
|
66
|
+
edge_names: Edge names, ``"x,y,z"`` points or ``"face:x,y,z"``
|
|
67
|
+
specs; see :func:`_add_fillet_impl`.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
AdapterResult[SolidWorksFeature]: The fillet, with
|
|
71
|
+
``volume_before`` / ``volume_after`` in its properties when they
|
|
72
|
+
could be read. ``ERROR`` when the volume did not change.
|
|
73
|
+
"""
|
|
74
|
+
before = await self.get_mass_properties()
|
|
75
|
+
volume_before = (
|
|
76
|
+
before.data.volume if before.is_success and before.data else None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
result = _add_fillet_impl(self, radius, edge_names)
|
|
80
|
+
if not result.is_success or result.data is None or volume_before is None:
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
after = await self.get_mass_properties()
|
|
84
|
+
volume_after = after.data.volume if after.is_success and after.data else None
|
|
85
|
+
if volume_after is None:
|
|
86
|
+
return result
|
|
87
|
+
if abs(volume_after - volume_before) <= 1e-7 * max(volume_before, 1.0):
|
|
88
|
+
return AdapterResult(
|
|
89
|
+
status=AdapterResultStatus.ERROR,
|
|
90
|
+
error=(
|
|
91
|
+
f"The fillet changed nothing (volume stayed at "
|
|
92
|
+
f"{volume_before:.3f} mm3). SolidWorks accepted the call "
|
|
93
|
+
"but filleted no edge; check that the specs name edges or "
|
|
94
|
+
"faces of the body."
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
result.data.properties["volume_before"] = volume_before
|
|
98
|
+
result.data.properties["volume_after"] = volume_after
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
async def add_chamfer(
|
|
102
|
+
self, distance: float, edge_names: list[str]
|
|
103
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
104
|
+
return _add_chamfer_impl(self, distance, edge_names)
|
|
105
|
+
|
|
106
|
+
async def delete_feature(self, name: str) -> AdapterResult[dict[str, Any]]:
|
|
107
|
+
return _delete_feature_impl(self, name)
|
|
108
|
+
|
|
109
|
+
async def suppress_feature(
|
|
110
|
+
self, name: str, suppress: bool = True
|
|
111
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
112
|
+
return _suppress_feature_impl(self, name, suppress)
|
|
113
|
+
|
|
114
|
+
async def rename_feature(
|
|
115
|
+
self, old_name: str, new_name: str
|
|
116
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
117
|
+
"""Rename a feature on the active model's tree.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
old_name: The feature's current name.
|
|
121
|
+
new_name: The name to give it.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
AdapterResult[dict[str, Any]]: Delegates to
|
|
125
|
+
:func:`_rename_feature_impl`; see it for the payload shape.
|
|
126
|
+
"""
|
|
127
|
+
return _rename_feature_impl(self, old_name, new_name)
|
|
128
|
+
|
|
129
|
+
async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]:
|
|
130
|
+
return _undo_impl(self, count)
|
|
131
|
+
|
|
132
|
+
async def create_reference_plane(
|
|
133
|
+
self,
|
|
134
|
+
reference: str,
|
|
135
|
+
offset: float = 0.0,
|
|
136
|
+
angle: float = 0.0,
|
|
137
|
+
flip: bool = False,
|
|
138
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
139
|
+
return _create_reference_plane_impl(self, reference, offset, angle, flip)
|
|
140
|
+
|
|
141
|
+
async def create_axis(self, reference: str) -> AdapterResult[dict[str, Any]]:
|
|
142
|
+
return _create_axis_impl(self, reference)
|
|
143
|
+
|
|
144
|
+
async def create_reference_point(
|
|
145
|
+
self,
|
|
146
|
+
mode: str,
|
|
147
|
+
x: float,
|
|
148
|
+
y: float,
|
|
149
|
+
z: float,
|
|
150
|
+
distance: float | None = None,
|
|
151
|
+
percent: float | None = None,
|
|
152
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
153
|
+
"""Create a reference point on the active part.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
mode: ``"along_curve"`` or ``"face_center"``.
|
|
157
|
+
x: X of a point on the target edge/face, in millimetres.
|
|
158
|
+
y: Y of a point on the target edge/face, in millimetres.
|
|
159
|
+
z: Z of a point on the target edge/face, in millimetres.
|
|
160
|
+
distance: For ``along_curve``, offset from the edge start in mm.
|
|
161
|
+
percent: For ``along_curve``, position as 0-100 of edge length.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
AdapterResult[dict[str, Any]]: Delegates to
|
|
165
|
+
:func:`_create_reference_point_impl`; see it for the payload shape.
|
|
166
|
+
"""
|
|
167
|
+
return _create_reference_point_impl(self, mode, x, y, z, distance, percent)
|
|
168
|
+
|
|
169
|
+
async def pattern_circular(
|
|
170
|
+
self,
|
|
171
|
+
features: list[str],
|
|
172
|
+
axis: str,
|
|
173
|
+
count: int,
|
|
174
|
+
angle: float = 360.0,
|
|
175
|
+
equal_spacing: bool = True,
|
|
176
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
177
|
+
"""Pattern features around an axis.
|
|
178
|
+
|
|
179
|
+
``FeatureCircularPattern5`` returns a Feature object whether or not it
|
|
180
|
+
produced anything, so the model volume is measured before and after
|
|
181
|
+
here - in the async layer, using ``get_mass_properties``, which is the
|
|
182
|
+
read path that works. Reading volume inline inside the COM closure
|
|
183
|
+
returns ``None`` while a selection is active.
|
|
184
|
+
|
|
185
|
+
Note that instances landing off the parent body are silently dropped
|
|
186
|
+
by SolidWorks, so the volume rises by less than ``count - 1`` times the
|
|
187
|
+
seed. That is correct behaviour, not a failure - only *no* growth at
|
|
188
|
+
all means the pattern did nothing.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
features: Names of the features to pattern.
|
|
192
|
+
axis: Name of the axis to rotate about, e.g. ``"Axis1"`` from
|
|
193
|
+
``create_axis``.
|
|
194
|
+
count: Total number of instances including the original.
|
|
195
|
+
angle: Total angle in degrees to spread them over. Defaults to a
|
|
196
|
+
full 360.
|
|
197
|
+
equal_spacing: Space instances evenly across ``angle``.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
AdapterResult[dict[str, Any]]: What was patterned and the volume
|
|
201
|
+
before and after. ``ERROR`` when nothing could be selected or the
|
|
202
|
+
volume did not grow.
|
|
203
|
+
"""
|
|
204
|
+
adapter = self._adapter(self)
|
|
205
|
+
if not adapter.currentModel:
|
|
206
|
+
return AdapterResult(
|
|
207
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
before = await self.get_mass_properties()
|
|
211
|
+
volume_before = (
|
|
212
|
+
before.data.volume if before.is_success and before.data else None
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
result = _pattern_circular_impl(
|
|
216
|
+
self, features, axis, count, angle, equal_spacing
|
|
217
|
+
)
|
|
218
|
+
if not result.is_success:
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
after = await self.get_mass_properties()
|
|
222
|
+
volume_after = after.data.volume if after.is_success and after.data else None
|
|
223
|
+
|
|
224
|
+
if volume_before is None or volume_after is None:
|
|
225
|
+
return AdapterResult(
|
|
226
|
+
status=AdapterResultStatus.ERROR,
|
|
227
|
+
error=(
|
|
228
|
+
"The pattern call returned a feature but the volume could "
|
|
229
|
+
"not be read before and after, so whether any instances "
|
|
230
|
+
"were created is unknown."
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
if volume_after <= volume_before * 1.0001:
|
|
234
|
+
return AdapterResult(
|
|
235
|
+
status=AdapterResultStatus.ERROR,
|
|
236
|
+
error=(
|
|
237
|
+
f"The circular pattern produced no geometry (volume "
|
|
238
|
+
f"{volume_before:.1f} -> {volume_after:.1f} mm3). "
|
|
239
|
+
f"SolidWorks returned a feature but patterned nothing. "
|
|
240
|
+
f"Check that '{axis}' is a real axis in the tree and that "
|
|
241
|
+
f"the instances land on the parent body."
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
data = dict(result.data or {})
|
|
246
|
+
data["volume_before"] = volume_before
|
|
247
|
+
data["volume_after"] = volume_after
|
|
248
|
+
data["volume_ratio"] = round(volume_after / volume_before, 6)
|
|
249
|
+
return AdapterResult(
|
|
250
|
+
status=AdapterResultStatus.SUCCESS,
|
|
251
|
+
data=data,
|
|
252
|
+
execution_time=result.execution_time,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
async def mirror_feature(
|
|
256
|
+
self,
|
|
257
|
+
features: list[str],
|
|
258
|
+
mirror_plane: str,
|
|
259
|
+
merge: bool = True,
|
|
260
|
+
mirror_bodies: bool = True,
|
|
261
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
262
|
+
"""Mirror solid bodies or features about a plane.
|
|
263
|
+
|
|
264
|
+
``InsertMirrorFeature`` returns a Feature object even when it mirrored
|
|
265
|
+
nothing, so the model's volume is measured before and after and the
|
|
266
|
+
call is reported as failed if the volume did not grow. That check runs
|
|
267
|
+
here rather than inside the COM closure because ``get_mass_properties``
|
|
268
|
+
is the read path known to work - reading ``CreateMassProperty().Volume``
|
|
269
|
+
inline returns ``None`` when a plane is still in the selection set,
|
|
270
|
+
which silently disables the check.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
features: Names of the bodies (or features) to mirror. For a body
|
|
274
|
+
mirror these are the names as they appear under Solid Bodies,
|
|
275
|
+
which for a lofted wing is the loft's own name.
|
|
276
|
+
mirror_plane: Plane to mirror about, e.g. ``"Right Plane"``.
|
|
277
|
+
merge: Merge the mirrored result with the original.
|
|
278
|
+
mirror_bodies: Mirror whole solid bodies rather than features.
|
|
279
|
+
Defaults to ``True``, which is what works for anything built
|
|
280
|
+
from a loft or sweep: SolidWorks only resolves a *feature*
|
|
281
|
+
mirror when the feature's own sketch sits on the mirror plane,
|
|
282
|
+
so a wing lofted between stations offset from the centreline
|
|
283
|
+
cannot be feature-mirrored. Measured on SW 2025: body mirroring
|
|
284
|
+
a 1819569 mm^3 wing about the Right Plane gave 3636460 mm^3.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
AdapterResult[dict[str, Any]]: What was mirrored, and the volume
|
|
288
|
+
before and after. ``ERROR`` when nothing was selected, the call
|
|
289
|
+
failed, or the volume did not grow.
|
|
290
|
+
"""
|
|
291
|
+
adapter = self._adapter(self)
|
|
292
|
+
if not adapter.currentModel:
|
|
293
|
+
return AdapterResult(
|
|
294
|
+
status=AdapterResultStatus.ERROR, error="No active model"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
before = await self.get_mass_properties()
|
|
298
|
+
volume_before = (
|
|
299
|
+
before.data.volume if before.is_success and before.data else None
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
result = _mirror_feature_impl(
|
|
303
|
+
self, features, mirror_plane, merge, mirror_bodies
|
|
304
|
+
)
|
|
305
|
+
if not result.is_success:
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
after = await self.get_mass_properties()
|
|
309
|
+
volume_after = after.data.volume if after.is_success and after.data else None
|
|
310
|
+
|
|
311
|
+
if volume_before is None or volume_after is None:
|
|
312
|
+
return AdapterResult(
|
|
313
|
+
status=AdapterResultStatus.ERROR,
|
|
314
|
+
error=(
|
|
315
|
+
"The mirror call returned a feature but the volume could "
|
|
316
|
+
"not be read before and after, so whether any geometry was "
|
|
317
|
+
"produced is unknown."
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
if volume_after <= volume_before * 1.001:
|
|
321
|
+
return AdapterResult(
|
|
322
|
+
status=AdapterResultStatus.ERROR,
|
|
323
|
+
error=(
|
|
324
|
+
f"The mirror produced no new geometry (volume "
|
|
325
|
+
f"{volume_before:.1f} -> {volume_after:.1f} mm3). "
|
|
326
|
+
"SolidWorks returned a feature but mirrored nothing. With "
|
|
327
|
+
"mirror_bodies=False this happens whenever the feature's "
|
|
328
|
+
"own sketch does not sit on the mirror plane; try "
|
|
329
|
+
"mirror_bodies=True to mirror the solid body instead."
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
data = dict(result.data or {})
|
|
334
|
+
data["volume_before"] = volume_before
|
|
335
|
+
data["volume_after"] = volume_after
|
|
336
|
+
data["volume_ratio"] = round(volume_after / volume_before, 6)
|
|
337
|
+
return AdapterResult(
|
|
338
|
+
status=AdapterResultStatus.SUCCESS,
|
|
339
|
+
data=data,
|
|
340
|
+
execution_time=result.execution_time,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
#: ``end_condition`` spellings accepted by ``create_extrusion``, keyed by the
|
|
345
|
+
#: name with case, spaces, ``_`` and ``-`` removed.
|
|
346
|
+
_EXTRUSION_END_CONDITIONS = {
|
|
347
|
+
"blind": ("Blind", "swEndCondBlind"),
|
|
348
|
+
"midplane": ("MidPlane", "swEndCondMidPlane"),
|
|
349
|
+
"throughall": ("ThroughAll", "swEndCondThroughAll"),
|
|
350
|
+
"uptonext": ("UpToNext", "swEndCondUpToNext"),
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _extrusion_end_condition(
|
|
355
|
+
adapter: Any, end_condition: str, both_directions: bool
|
|
356
|
+
) -> tuple[str, int]:
|
|
357
|
+
"""Resolve a boss extrusion's first-direction end condition.
|
|
358
|
+
|
|
359
|
+
``both_directions=True`` means mid-plane whatever ``end_condition`` says,
|
|
360
|
+
which is what the thin-wall branch always did. Before this was shared,
|
|
361
|
+
the solid branch passed ``swEndCondBlind`` unconditionally, so a
|
|
362
|
+
"both directions" extrusion silently came out one-sided.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
adapter: Adapter whose ``constants`` hold the ``swEndCond*`` values.
|
|
366
|
+
end_condition: Requested condition, e.g. ``"Blind"`` or ``"Mid Plane"``.
|
|
367
|
+
both_directions: Extrude symmetrically about the sketch plane.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
tuple[str, int]: The canonical name and its ``swEndCondition_e`` value.
|
|
371
|
+
|
|
372
|
+
Raises:
|
|
373
|
+
ValueError: When ``end_condition`` is not one this call supports.
|
|
374
|
+
"""
|
|
375
|
+
if both_directions:
|
|
376
|
+
key = "midplane"
|
|
377
|
+
else:
|
|
378
|
+
key = "".join(c for c in end_condition.lower() if c not in " _-") or "blind"
|
|
379
|
+
if key not in _EXTRUSION_END_CONDITIONS:
|
|
380
|
+
supported = ", ".join(name for name, _ in _EXTRUSION_END_CONDITIONS.values())
|
|
381
|
+
raise ValueError(
|
|
382
|
+
f"Unsupported extrusion end_condition {end_condition!r}; "
|
|
383
|
+
f"use one of: {supported}"
|
|
384
|
+
)
|
|
385
|
+
name, constant = _EXTRUSION_END_CONDITIONS[key]
|
|
386
|
+
return name, adapter.constants[constant]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _create_extrusion_impl(
|
|
390
|
+
adapter: Any, params: ExtrusionParameters
|
|
391
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
392
|
+
"""Create a boss-extrude feature from the active sketch profile.
|
|
393
|
+
|
|
394
|
+
Attempts the modern ``FeatureExtrusion3`` COM call first; falls back to the
|
|
395
|
+
legacy ``FeatureExtrusion2`` signature when the newer overload is absent.
|
|
396
|
+
When ``params.thin_feature`` is truthy, the thin-wall variants
|
|
397
|
+
(``FeatureExtrusionThin2`` / ``FeatureExtruThin2``) are used instead.
|
|
398
|
+
|
|
399
|
+
All depth and thickness values are provided in millimetres and converted
|
|
400
|
+
to metres internally.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
adapter: A fully connected ``PyWin32Adapter`` instance. Must have a
|
|
404
|
+
non-``None`` ``currentModel`` and a valid ``FeatureManager``.
|
|
405
|
+
params: Extrusion parameter bag. Relevant fields:
|
|
406
|
+
- ``depth`` (float): Extrude depth in mm.
|
|
407
|
+
- ``draft_angle`` (float): Draft angle in degrees. Default 0.
|
|
408
|
+
- ``reverse_direction`` (bool): Flip the extrusion direction.
|
|
409
|
+
- ``thin_feature`` (bool): Produce a thin-wall body.
|
|
410
|
+
- ``thin_thickness`` (float | None): Wall thickness in mm when
|
|
411
|
+
``thin_feature`` is ``True``.
|
|
412
|
+
- ``merge_result`` (bool): Merge with existing bodies. Default
|
|
413
|
+
``True``.
|
|
414
|
+
- ``both_directions`` (bool): Extrude symmetrically in both
|
|
415
|
+
directions from the sketch plane (mid-plane): ``depth`` is the
|
|
416
|
+
total thickness, half on each side.
|
|
417
|
+
- ``end_condition`` (str): ``"Blind"`` (default), ``"MidPlane"``,
|
|
418
|
+
``"ThroughAll"`` or ``"UpToNext"`` - see
|
|
419
|
+
:func:`_extrusion_end_condition`.
|
|
420
|
+
- ``auto_fillet_corners`` (bool): Round sharp thin-wall corners.
|
|
421
|
+
- ``fillet_corners_radius`` (float): Corner fillet radius in mm.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
425
|
+
``SolidWorksFeature`` whose ``type`` is ``"Extrusion"``. On failure,
|
|
426
|
+
``status`` is ``ERROR`` and ``error`` contains a descriptive message.
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
Exception: Propagated through ``_handle_com_operation`` when the COM
|
|
430
|
+
call returns ``None`` for the created feature object.
|
|
431
|
+
|
|
432
|
+
Example::
|
|
433
|
+
|
|
434
|
+
from solidworks_mcp.adapters.base import ExtrusionParameters
|
|
435
|
+
from solidworks_mcp.adapters import pywin32_feature_ops
|
|
436
|
+
|
|
437
|
+
params = ExtrusionParameters(depth=25.0, draft_angle=2.0)
|
|
438
|
+
result = pywin32_feature_ops.create_extrusion(adapter, params)
|
|
439
|
+
print(result.data.name) # e.g. "Boss-Extrude1"
|
|
440
|
+
"""
|
|
441
|
+
if not adapter.currentModel:
|
|
442
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
443
|
+
|
|
444
|
+
def _extrusion_operation() -> SolidWorksFeature:
|
|
445
|
+
"""Inner COM closure that builds and returns the extrusion feature.
|
|
446
|
+
|
|
447
|
+
Normalises ``params`` into a ``SimpleNamespace`` so every attribute
|
|
448
|
+
access is guaranteed safe regardless of the dataclass version. Picks
|
|
449
|
+
the thin-wall or solid branch, then tries the modern API first before
|
|
450
|
+
falling back to the legacy one.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
SolidWorksFeature: Populated feature descriptor on success.
|
|
454
|
+
|
|
455
|
+
Raises:
|
|
456
|
+
Exception: If both API variants return ``None``.
|
|
457
|
+
"""
|
|
458
|
+
normalized = SimpleNamespace(
|
|
459
|
+
depth=float(getattr(params, "depth", 0.0)),
|
|
460
|
+
draft_angle=float(getattr(params, "draft_angle", 0.0)),
|
|
461
|
+
reverse_direction=bool(getattr(params, "reverse_direction", False)),
|
|
462
|
+
thin_feature=bool(getattr(params, "thin_feature", False)),
|
|
463
|
+
thin_thickness=getattr(params, "thin_thickness", None),
|
|
464
|
+
merge_result=bool(getattr(params, "merge_result", True)),
|
|
465
|
+
both_directions=bool(getattr(params, "both_directions", False)),
|
|
466
|
+
auto_fillet_corners=bool(getattr(params, "auto_fillet_corners", False)),
|
|
467
|
+
fillet_corners_radius=float(getattr(params, "fillet_corners_radius", 0.0)),
|
|
468
|
+
)
|
|
469
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
470
|
+
t0 = adapter.constants.get("swStartSketchPlane", 0)
|
|
471
|
+
end_name, t1 = _extrusion_end_condition(
|
|
472
|
+
adapter,
|
|
473
|
+
str(getattr(params, "end_condition", "Blind") or "Blind"),
|
|
474
|
+
normalized.both_directions,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if normalized.thin_feature and normalized.thin_thickness:
|
|
478
|
+
try:
|
|
479
|
+
feature = feature_manager.FeatureExtrusionThin2(
|
|
480
|
+
True,
|
|
481
|
+
False,
|
|
482
|
+
normalized.reverse_direction,
|
|
483
|
+
t1,
|
|
484
|
+
adapter.constants["swEndCondBlind"],
|
|
485
|
+
normalized.depth / 1000.0,
|
|
486
|
+
0.0,
|
|
487
|
+
False,
|
|
488
|
+
False,
|
|
489
|
+
False,
|
|
490
|
+
False,
|
|
491
|
+
normalized.draft_angle * 3.14159 / 180.0,
|
|
492
|
+
0.0,
|
|
493
|
+
False,
|
|
494
|
+
False,
|
|
495
|
+
False,
|
|
496
|
+
False,
|
|
497
|
+
normalized.merge_result,
|
|
498
|
+
normalized.thin_thickness / 1000.0,
|
|
499
|
+
normalized.thin_thickness / 1000.0,
|
|
500
|
+
0.0,
|
|
501
|
+
0,
|
|
502
|
+
0,
|
|
503
|
+
normalized.auto_fillet_corners,
|
|
504
|
+
normalized.fillet_corners_radius / 1000.0,
|
|
505
|
+
False,
|
|
506
|
+
True,
|
|
507
|
+
t0,
|
|
508
|
+
0.0,
|
|
509
|
+
False,
|
|
510
|
+
)
|
|
511
|
+
except Exception:
|
|
512
|
+
feature = feature_manager.FeatureExtruThin2(
|
|
513
|
+
normalized.depth / 1000.0,
|
|
514
|
+
0.0,
|
|
515
|
+
False,
|
|
516
|
+
normalized.draft_angle * 3.14159 / 180.0,
|
|
517
|
+
0.0,
|
|
518
|
+
False,
|
|
519
|
+
False,
|
|
520
|
+
normalized.merge_result,
|
|
521
|
+
False,
|
|
522
|
+
True,
|
|
523
|
+
normalized.thin_thickness / 1000.0,
|
|
524
|
+
normalized.thin_thickness / 1000.0,
|
|
525
|
+
False,
|
|
526
|
+
False,
|
|
527
|
+
False,
|
|
528
|
+
adapter.constants["swEndCondBlind"],
|
|
529
|
+
adapter.constants["swEndCondBlind"],
|
|
530
|
+
)
|
|
531
|
+
else:
|
|
532
|
+
try:
|
|
533
|
+
feature = feature_manager.FeatureExtrusion3(
|
|
534
|
+
True,
|
|
535
|
+
False,
|
|
536
|
+
normalized.reverse_direction,
|
|
537
|
+
t1,
|
|
538
|
+
adapter.constants["swEndCondBlind"],
|
|
539
|
+
normalized.depth / 1000.0,
|
|
540
|
+
0.0,
|
|
541
|
+
False,
|
|
542
|
+
False,
|
|
543
|
+
False,
|
|
544
|
+
False,
|
|
545
|
+
normalized.draft_angle * 3.14159 / 180.0,
|
|
546
|
+
0.0,
|
|
547
|
+
False,
|
|
548
|
+
False,
|
|
549
|
+
False,
|
|
550
|
+
False,
|
|
551
|
+
normalized.merge_result,
|
|
552
|
+
False,
|
|
553
|
+
True,
|
|
554
|
+
t0,
|
|
555
|
+
0.0,
|
|
556
|
+
False,
|
|
557
|
+
)
|
|
558
|
+
except Exception:
|
|
559
|
+
feature = feature_manager.FeatureExtrusion2(
|
|
560
|
+
True,
|
|
561
|
+
False,
|
|
562
|
+
normalized.reverse_direction,
|
|
563
|
+
t1,
|
|
564
|
+
adapter.constants["swEndCondBlind"],
|
|
565
|
+
normalized.depth / 1000.0,
|
|
566
|
+
0.0,
|
|
567
|
+
False,
|
|
568
|
+
False,
|
|
569
|
+
False,
|
|
570
|
+
False,
|
|
571
|
+
normalized.draft_angle * 3.14159 / 180.0,
|
|
572
|
+
0.0,
|
|
573
|
+
False,
|
|
574
|
+
False,
|
|
575
|
+
False,
|
|
576
|
+
False,
|
|
577
|
+
normalized.merge_result,
|
|
578
|
+
False,
|
|
579
|
+
True,
|
|
580
|
+
t0,
|
|
581
|
+
0.0,
|
|
582
|
+
False,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if not feature:
|
|
586
|
+
raise Exception("Failed to create extrusion feature")
|
|
587
|
+
|
|
588
|
+
return SolidWorksFeature(
|
|
589
|
+
name=feature.Name,
|
|
590
|
+
type="Extrusion",
|
|
591
|
+
id=adapter._get_feature_id(feature),
|
|
592
|
+
parameters={
|
|
593
|
+
"depth": normalized.depth,
|
|
594
|
+
"draft_angle": normalized.draft_angle,
|
|
595
|
+
"reverse_direction": normalized.reverse_direction,
|
|
596
|
+
"thin_feature": normalized.thin_feature,
|
|
597
|
+
"thin_thickness": normalized.thin_thickness,
|
|
598
|
+
"end_condition": end_name,
|
|
599
|
+
},
|
|
600
|
+
properties={"created": datetime.now().isoformat()},
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
return cast(
|
|
604
|
+
AdapterResult[SolidWorksFeature],
|
|
605
|
+
adapter._handle_com_operation("create_extrusion", _extrusion_operation),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _create_revolve_impl(
|
|
610
|
+
adapter: Any, params: RevolveParameters
|
|
611
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
612
|
+
"""Create a revolve feature from the active sketch profile around a centre axis.
|
|
613
|
+
|
|
614
|
+
Uses ``FeatureRevolve2`` from the SolidWorks COM API. The sketch must
|
|
615
|
+
already contain a centre-line that SolidWorks will use as the rotation axis.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
|
|
619
|
+
``currentModel``.
|
|
620
|
+
params: Revolve parameter bag. Relevant fields:
|
|
621
|
+
- ``angle`` (float): Revolve angle in degrees. Use 360 for a
|
|
622
|
+
full revolution.
|
|
623
|
+
- ``reverse_direction`` (bool): Flip the revolve direction.
|
|
624
|
+
- ``both_directions`` (bool): Revolve symmetrically in both
|
|
625
|
+
directions.
|
|
626
|
+
- ``thin_feature`` (bool): Produce a thin-wall body.
|
|
627
|
+
- ``thin_thickness`` (float | None): Wall thickness in mm.
|
|
628
|
+
- ``merge_result`` (bool): Merge with existing bodies.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
632
|
+
``SolidWorksFeature`` whose ``type`` is ``"Revolve"``. On failure,
|
|
633
|
+
``status`` is ``ERROR``.
|
|
634
|
+
|
|
635
|
+
Raises:
|
|
636
|
+
Exception: Propagated through ``_handle_com_operation`` when
|
|
637
|
+
``FeatureRevolve2`` returns ``None``.
|
|
638
|
+
|
|
639
|
+
Example::
|
|
640
|
+
|
|
641
|
+
from solidworks_mcp.adapters.base import RevolveParameters
|
|
642
|
+
from solidworks_mcp.adapters import pywin32_feature_ops
|
|
643
|
+
|
|
644
|
+
params = RevolveParameters(angle=360.0, merge_result=True)
|
|
645
|
+
result = pywin32_feature_ops.create_revolve(adapter, params)
|
|
646
|
+
print(result.data.name) # e.g. "Revolve1"
|
|
647
|
+
"""
|
|
648
|
+
if not adapter.currentModel:
|
|
649
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
650
|
+
|
|
651
|
+
def _revolve_operation() -> SolidWorksFeature:
|
|
652
|
+
"""Inner COM closure that builds and returns the revolve feature.
|
|
653
|
+
|
|
654
|
+
Converts the degree angle to radians and invokes ``FeatureRevolve2``.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
SolidWorksFeature: Populated feature descriptor on success.
|
|
658
|
+
|
|
659
|
+
Raises:
|
|
660
|
+
Exception: If ``FeatureRevolve2`` returns ``None``.
|
|
661
|
+
"""
|
|
662
|
+
# Detect SW major version for FeatureRevolve2 API choice
|
|
663
|
+
revolve_sw_major = 0
|
|
664
|
+
if getattr(adapter, "swApp", None):
|
|
665
|
+
rev = adapter._attempt(
|
|
666
|
+
lambda: adapter._get_attr_or_call(adapter.swApp, "RevisionNumber"),
|
|
667
|
+
default="0",
|
|
668
|
+
)
|
|
669
|
+
try:
|
|
670
|
+
revolve_sw_major = int(str(rev).split(".")[0])
|
|
671
|
+
except (ValueError, IndexError):
|
|
672
|
+
revolve_sw_major = 0
|
|
673
|
+
|
|
674
|
+
import math
|
|
675
|
+
|
|
676
|
+
if revolve_sw_major == 33:
|
|
677
|
+
# IFeatureManager.FeatureRevolve2 - exact 20-parameter signature
|
|
678
|
+
# read from the live gen_py type library for SW 2025:
|
|
679
|
+
# SingleDir, IsSolid, IsThin, IsCut, ReverseDir,
|
|
680
|
+
# BothDirectionUpToSameEntity, Dir1Type, Dir2Type,
|
|
681
|
+
# Dir1Angle(rad), Dir2Angle(rad), OffsetReverse1, OffsetReverse2,
|
|
682
|
+
# OffsetDistance1, OffsetDistance2, ThinType,
|
|
683
|
+
# ThinThickness1(m), ThinThickness2(m), Merge,
|
|
684
|
+
# UseFeatScope, UseAutoSelect
|
|
685
|
+
# This call passed 19 arguments and put Merge where ThinType
|
|
686
|
+
# belongs, so SolidWorks rejected every revolve with
|
|
687
|
+
# "Parameter not optional".
|
|
688
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
689
|
+
is_thin = bool(params.thin_feature and params.thin_thickness)
|
|
690
|
+
feature = feature_manager.FeatureRevolve2(
|
|
691
|
+
not params.both_directions, # SingleDir
|
|
692
|
+
True, # IsSolid
|
|
693
|
+
is_thin, # IsThin
|
|
694
|
+
False, # IsCut
|
|
695
|
+
params.reverse_direction, # ReverseDir
|
|
696
|
+
False, # BothDirectionUpToSameEntity
|
|
697
|
+
0, # Dir1Type (swEndCondBlind)
|
|
698
|
+
0, # Dir2Type
|
|
699
|
+
params.angle * math.pi / 180.0, # Dir1Angle (rad)
|
|
700
|
+
(params.angle * math.pi / 180.0)
|
|
701
|
+
if params.both_directions
|
|
702
|
+
else 0.0, # Dir2Angle
|
|
703
|
+
False, # OffsetReverse1
|
|
704
|
+
False, # OffsetReverse2
|
|
705
|
+
0.0, # OffsetDistance1
|
|
706
|
+
0.0, # OffsetDistance2
|
|
707
|
+
0, # ThinType
|
|
708
|
+
(params.thin_thickness or 0.0) / 1000.0, # ThinThickness1
|
|
709
|
+
0.0, # ThinThickness2
|
|
710
|
+
params.merge_result, # Merge
|
|
711
|
+
False, # UseFeatScope
|
|
712
|
+
True, # UseAutoSelect
|
|
713
|
+
)
|
|
714
|
+
else:
|
|
715
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
716
|
+
feature = feature_manager.FeatureRevolve2(
|
|
717
|
+
not params.both_directions,
|
|
718
|
+
True,
|
|
719
|
+
params.thin_feature,
|
|
720
|
+
False,
|
|
721
|
+
params.reverse_direction,
|
|
722
|
+
False,
|
|
723
|
+
adapter.constants["swEndCondBlind"],
|
|
724
|
+
adapter.constants["swEndCondBlind"],
|
|
725
|
+
math.radians(params.angle),
|
|
726
|
+
math.radians(params.angle) if params.both_directions else 0.0,
|
|
727
|
+
False,
|
|
728
|
+
False,
|
|
729
|
+
0.0,
|
|
730
|
+
0.0,
|
|
731
|
+
0,
|
|
732
|
+
(params.thin_thickness or 0.0) / 1000.0,
|
|
733
|
+
0.0,
|
|
734
|
+
params.merge_result,
|
|
735
|
+
False,
|
|
736
|
+
True,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# IModelDoc2.FeatureRevolve2 returns None (void) on SW 2025
|
|
740
|
+
if not feature and revolve_sw_major != 33:
|
|
741
|
+
raise Exception("Failed to create revolve feature")
|
|
742
|
+
|
|
743
|
+
return SolidWorksFeature(
|
|
744
|
+
name=feature.Name if feature else "Revolve-Auto",
|
|
745
|
+
type="Revolve",
|
|
746
|
+
id=adapter._get_feature_id(feature) if feature else "revolve_auto",
|
|
747
|
+
parameters={
|
|
748
|
+
"angle": params.angle,
|
|
749
|
+
"reverse_direction": params.reverse_direction,
|
|
750
|
+
"both_directions": params.both_directions,
|
|
751
|
+
"thin_feature": params.thin_feature,
|
|
752
|
+
"thin_thickness": params.thin_thickness,
|
|
753
|
+
},
|
|
754
|
+
properties={"created": datetime.now().isoformat()},
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
return cast(
|
|
758
|
+
AdapterResult[SolidWorksFeature],
|
|
759
|
+
adapter._handle_com_operation("create_revolve", _revolve_operation),
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _select_named_feature(
|
|
764
|
+
adapter: Any,
|
|
765
|
+
name: str,
|
|
766
|
+
mark: int,
|
|
767
|
+
append: bool,
|
|
768
|
+
) -> bool:
|
|
769
|
+
"""Select a named feature under a specific selection mark via ``Select2``.
|
|
770
|
+
|
|
771
|
+
Sweep and loft rely on selection marks to tell SolidWorks which selection
|
|
772
|
+
is the profile (1), guide curve (2), or sweep path (4). We resolve the
|
|
773
|
+
feature with ``IModelDoc2::FeatureByName`` and select it with
|
|
774
|
+
``IFeature::Select2(append, mark)`` — the same proven path the rest of the
|
|
775
|
+
adapter uses for plane/sketch selection. ``IModelDocExtension::SelectByID2``
|
|
776
|
+
is avoided deliberately: late-bound ``SelectByID2`` raises
|
|
777
|
+
``Type mismatch`` on some SolidWorks builds, whereas ``FeatureByName`` +
|
|
778
|
+
``Select2`` is reliable, works for sketches *and* reference curves such as
|
|
779
|
+
a helix, and needs no entity-type string.
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
783
|
+
name: Feature name (e.g. ``"Sketch1"`` or ``"Helix/Spiral1"``). Any
|
|
784
|
+
``@document`` qualifier is stripped before lookup.
|
|
785
|
+
mark: Selection mark — 1=profile, 2=guide curve, 4=sweep path.
|
|
786
|
+
append: ``True`` to add to the current selection set, ``False`` to
|
|
787
|
+
replace it.
|
|
788
|
+
|
|
789
|
+
Returns:
|
|
790
|
+
bool: ``True`` when the feature was found and selected.
|
|
791
|
+
"""
|
|
792
|
+
bare = name.split("@", 1)[0]
|
|
793
|
+
feature = adapter._attempt(
|
|
794
|
+
lambda: adapter.currentModel.FeatureByName(bare), default=None
|
|
795
|
+
)
|
|
796
|
+
if not feature:
|
|
797
|
+
return False
|
|
798
|
+
return bool(adapter._attempt(lambda: feature.Select2(append, mark), default=False))
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _flag_feature_methods(obj: Any, interface: str) -> None:
|
|
802
|
+
"""Best-effort method flagging for a COM object via ``sw_type_info``.
|
|
803
|
+
|
|
804
|
+
Flagging tells pywin32 late binding to resolve names like ``GetTypeName2``
|
|
805
|
+
/ ``GetNextFeature`` / ``FirstFeature`` as methods. No-ops on plain test
|
|
806
|
+
doubles (and any environment without the gen_py wrapper).
|
|
807
|
+
|
|
808
|
+
Args:
|
|
809
|
+
obj: The COM object (or test double) to flag.
|
|
810
|
+
interface: SolidWorks interface name (e.g. ``"IFeature"``).
|
|
811
|
+
"""
|
|
812
|
+
sw_type_info.flag_methods(obj, interface)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _flag_feature_members(obj: Any, *names: str) -> None:
|
|
816
|
+
"""Flag only the named members on ``obj``.
|
|
817
|
+
|
|
818
|
+
Cheaper than :func:`_flag_feature_methods` inside a loop: flagging a
|
|
819
|
+
whole interface (``IFeature`` is ~100 names, ~27 ms) costs a fixed price
|
|
820
|
+
per object, and ``sw_type_info``'s flag cache is keyed by ``id(obj)``, so
|
|
821
|
+
a walk over fresh dispatches — one per feature — never hits it. Flagging
|
|
822
|
+
just the handful of members about to be read avoids that cost.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
obj: The COM object (or test double) to flag.
|
|
826
|
+
*names: Member names about to be read.
|
|
827
|
+
"""
|
|
828
|
+
sw_type_info.flag_members(obj, *names)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
#: Members read while walking the feature tree in ``_profile_feature_names``.
|
|
832
|
+
_TREE_WALK_MEMBERS = ("GetTypeName2", "GetNextFeature", "Name")
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _read_member(obj: Any, name: str) -> Any:
|
|
836
|
+
"""Read a COM member that pywin32 may expose as a property *or* a method.
|
|
837
|
+
|
|
838
|
+
Late-bound pywin32 dispatches are inconsistent: an unflagged zero-arg
|
|
839
|
+
accessor may come back as a bound method (needing a call) *or* as the
|
|
840
|
+
already-resolved value — and when that value is itself a COM object it is
|
|
841
|
+
also callable, so a naive "call if callable" check wrongly invokes its
|
|
842
|
+
default dispatch (``Member not found``). This helper calls the member and
|
|
843
|
+
falls back to the raw member if the call raises, so it yields the value in
|
|
844
|
+
every case (flagged method, unflagged method, property-returning-object,
|
|
845
|
+
or plain test double).
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
obj: The COM object (or test double) to read from.
|
|
849
|
+
name: Member name.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
Any: The member's value, or ``None`` when the attribute is absent.
|
|
853
|
+
"""
|
|
854
|
+
member = getattr(obj, name, None)
|
|
855
|
+
if not callable(member):
|
|
856
|
+
return member
|
|
857
|
+
try:
|
|
858
|
+
return member()
|
|
859
|
+
except Exception:
|
|
860
|
+
return member
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _profile_feature_names(adapter: Any) -> list[str]:
|
|
864
|
+
"""Return sketch (``ProfileFeature``) names in feature-tree order.
|
|
865
|
+
|
|
866
|
+
Walks ``FirstFeature`` -> ``GetNextFeature`` reading ``GetTypeName2`` and
|
|
867
|
+
collecting features whose type is ``"ProfileFeature"`` (a 2D/3D sketch).
|
|
868
|
+
Mirrors the tree walk used by :func:`_create_cut_extrude_impl`, but flags
|
|
869
|
+
each feature for ``IFeature`` and reads members through
|
|
870
|
+
:func:`_read_member` so it is robust to pywin32's method-vs-property
|
|
871
|
+
late-binding ambiguity.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
875
|
+
|
|
876
|
+
Returns:
|
|
877
|
+
list[str]: Bare sketch names, earliest first. Empty when the walk
|
|
878
|
+
finds no sketches or the tree is inaccessible.
|
|
879
|
+
"""
|
|
880
|
+
names: list[str] = []
|
|
881
|
+
try:
|
|
882
|
+
_flag_feature_methods(adapter.currentModel, "IModelDoc2")
|
|
883
|
+
feat = _read_member(adapter.currentModel, "FirstFeature")
|
|
884
|
+
# Bound the walk so a misbehaving GetNextFeature can't spin forever.
|
|
885
|
+
for _ in range(5000):
|
|
886
|
+
if not feat:
|
|
887
|
+
break
|
|
888
|
+
_flag_feature_members(feat, *_TREE_WALK_MEMBERS)
|
|
889
|
+
try:
|
|
890
|
+
if _read_member(feat, "GetTypeName2") == "ProfileFeature":
|
|
891
|
+
names.append(str(_read_member(feat, "Name")))
|
|
892
|
+
except Exception:
|
|
893
|
+
pass
|
|
894
|
+
try:
|
|
895
|
+
feat = _read_member(feat, "GetNextFeature")
|
|
896
|
+
except Exception:
|
|
897
|
+
break
|
|
898
|
+
except Exception:
|
|
899
|
+
pass
|
|
900
|
+
return names
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _create_sweep_impl(
|
|
904
|
+
adapter: Any, params: SweepParameters
|
|
905
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
906
|
+
"""Create a swept boss/protrusion from a profile sketch along a path sketch.
|
|
907
|
+
|
|
908
|
+
Uses ``IFeatureManager::InsertProtrusionSwept4``. Two sketches are
|
|
909
|
+
required in the active part: a closed **profile** sketch and an open
|
|
910
|
+
**path** sketch named by ``params.path``. The path is selected under
|
|
911
|
+
mark 4 and the profile under mark 1, per the SolidWorks selection-mark
|
|
912
|
+
contract for sweeps.
|
|
913
|
+
|
|
914
|
+
Because :class:`SweepParameters` only names the path, the profile is
|
|
915
|
+
inferred as the first ``ProfileFeature`` sketch in the feature tree whose
|
|
916
|
+
name is **not** the path. In the common "draw profile, draw path, sweep"
|
|
917
|
+
workflow this is unambiguous (exactly two sketches exist).
|
|
918
|
+
|
|
919
|
+
Args:
|
|
920
|
+
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
|
|
921
|
+
``currentModel``.
|
|
922
|
+
params: Sweep parameter bag. Relevant fields:
|
|
923
|
+
- ``path`` (str): Name of the path sketch (e.g. ``"Sketch2"``).
|
|
924
|
+
- ``twist_along_path`` (bool): Apply a constant twist along the
|
|
925
|
+
path.
|
|
926
|
+
- ``twist_angle`` (float): Twist angle in **degrees** (used only
|
|
927
|
+
when ``twist_along_path`` is true).
|
|
928
|
+
- ``merge_result`` (bool): Merge with existing bodies.
|
|
929
|
+
|
|
930
|
+
Returns:
|
|
931
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
932
|
+
``SolidWorksFeature`` whose ``type`` is ``"Sweep"``. On failure,
|
|
933
|
+
``status`` is ``ERROR`` with a descriptive message.
|
|
934
|
+
|
|
935
|
+
Raises:
|
|
936
|
+
Exception: Propagated through ``_handle_com_operation`` when the
|
|
937
|
+
profile/path cannot be selected or the COM call returns ``None``.
|
|
938
|
+
|
|
939
|
+
Example::
|
|
940
|
+
|
|
941
|
+
from solidworks_mcp.adapters.base import SweepParameters
|
|
942
|
+
|
|
943
|
+
params = SweepParameters(path="Sketch2", merge_result=True)
|
|
944
|
+
result = await adapter.create_sweep(params)
|
|
945
|
+
print(result.data.name) # e.g. "Sweep1"
|
|
946
|
+
"""
|
|
947
|
+
if not adapter.currentModel:
|
|
948
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
949
|
+
|
|
950
|
+
if not getattr(params, "path", None):
|
|
951
|
+
return AdapterResult(
|
|
952
|
+
status=AdapterResultStatus.ERROR,
|
|
953
|
+
error="Sweep requires a 'path' sketch name",
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
def _sweep_operation() -> SolidWorksFeature:
|
|
957
|
+
"""Inner COM closure that selects profile + path and runs the sweep.
|
|
958
|
+
|
|
959
|
+
Returns:
|
|
960
|
+
SolidWorksFeature: Populated feature descriptor on success.
|
|
961
|
+
|
|
962
|
+
Raises:
|
|
963
|
+
Exception: When selections fail or ``InsertProtrusionSwept4``
|
|
964
|
+
returns ``None``.
|
|
965
|
+
"""
|
|
966
|
+
import math
|
|
967
|
+
|
|
968
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
969
|
+
|
|
970
|
+
# Resolve the path name against the actual tree sketches so the
|
|
971
|
+
# profile/path comparison is on bare names, then pick the first
|
|
972
|
+
# non-path sketch as the profile.
|
|
973
|
+
sketch_names = _profile_feature_names(adapter)
|
|
974
|
+
path_name = params.path
|
|
975
|
+
for name in sketch_names:
|
|
976
|
+
if name == params.path or name.lower() == params.path.lower():
|
|
977
|
+
path_name = name
|
|
978
|
+
break
|
|
979
|
+
|
|
980
|
+
# Profile = the most recently created sketch that isn't the path.
|
|
981
|
+
# Preferring the latest sketch handles both a sketch path (profile is
|
|
982
|
+
# drawn first, so it's the only non-path sketch) and a helix/curve
|
|
983
|
+
# path (the helix's base-circle sketch precedes the profile in the
|
|
984
|
+
# tree, so "first non-path" would wrongly pick the base circle).
|
|
985
|
+
profile_name = None
|
|
986
|
+
last = getattr(adapter, "_last_sketch_name", None)
|
|
987
|
+
if last and last != path_name and last in sketch_names:
|
|
988
|
+
profile_name = last
|
|
989
|
+
if profile_name is None:
|
|
990
|
+
profile_name = next(
|
|
991
|
+
(name for name in reversed(sketch_names) if name != path_name), None
|
|
992
|
+
)
|
|
993
|
+
if profile_name is None:
|
|
994
|
+
raise Exception(
|
|
995
|
+
"Sweep needs a profile sketch distinct from the path "
|
|
996
|
+
f"'{params.path}'. Sketches found: {sketch_names or 'none'}"
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
adapter._attempt(
|
|
1000
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
if not _select_named_feature(adapter, profile_name, 1, False):
|
|
1004
|
+
raise Exception(f"Failed to select sweep profile sketch: {profile_name}")
|
|
1005
|
+
if not _select_named_feature(adapter, path_name, 4, True):
|
|
1006
|
+
raise Exception(f"Failed to select sweep path: {path_name}")
|
|
1007
|
+
|
|
1008
|
+
twist = bool(getattr(params, "twist_along_path", False))
|
|
1009
|
+
twist_angle_deg = float(getattr(params, "twist_angle", 0.0))
|
|
1010
|
+
# swTwistControlType_e: 0 = follow path, 8 = constant twist along path.
|
|
1011
|
+
twist_ctrl = 8 if twist else 0
|
|
1012
|
+
twist_angle_rad = math.radians(twist_angle_deg) if twist else 0.0
|
|
1013
|
+
|
|
1014
|
+
feature = feature_manager.InsertProtrusionSwept4(
|
|
1015
|
+
False, # Propagate to next tangent edge
|
|
1016
|
+
False, # Alignment (go through end faces)
|
|
1017
|
+
twist_ctrl, # TwistCtrlOption (swTwistControlType_e)
|
|
1018
|
+
False, # KeepTangency
|
|
1019
|
+
False, # BAdvancedSmoothing
|
|
1020
|
+
0, # StartMatchingType (swTangencyType_e)
|
|
1021
|
+
0, # EndMatchingType
|
|
1022
|
+
False, # IsThinBody
|
|
1023
|
+
0.0, # Thickness1
|
|
1024
|
+
0.0, # Thickness2
|
|
1025
|
+
0, # ThinType (swThinWallType_e)
|
|
1026
|
+
0, # PathAlign
|
|
1027
|
+
bool(getattr(params, "merge_result", True)), # Merge
|
|
1028
|
+
True, # UseFeatScope
|
|
1029
|
+
True, # UseAutoSelect
|
|
1030
|
+
twist_angle_rad, # TwistAngle (radians)
|
|
1031
|
+
True, # BMergeSmoothFaces
|
|
1032
|
+
False, # CircularProfile
|
|
1033
|
+
0.0, # CircularProfileDiameter
|
|
1034
|
+
0, # Direction
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
if not feature:
|
|
1038
|
+
raise Exception("Failed to create sweep feature")
|
|
1039
|
+
|
|
1040
|
+
return SolidWorksFeature(
|
|
1041
|
+
name=feature.Name,
|
|
1042
|
+
type="Sweep",
|
|
1043
|
+
id=adapter._get_feature_id(feature),
|
|
1044
|
+
parameters={
|
|
1045
|
+
"profile": profile_name,
|
|
1046
|
+
"path": path_name,
|
|
1047
|
+
"twist_along_path": twist,
|
|
1048
|
+
"twist_angle": twist_angle_deg,
|
|
1049
|
+
"merge_result": bool(getattr(params, "merge_result", True)),
|
|
1050
|
+
},
|
|
1051
|
+
properties={"created": datetime.now().isoformat()},
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
return cast(
|
|
1055
|
+
AdapterResult[SolidWorksFeature],
|
|
1056
|
+
adapter._handle_com_operation("create_sweep", _sweep_operation),
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _create_loft_impl(
|
|
1061
|
+
adapter: Any, params: LoftParameters
|
|
1062
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
1063
|
+
"""Create a lofted boss/protrusion between two or more profile sketches.
|
|
1064
|
+
|
|
1065
|
+
Uses ``IFeatureManager::InsertProtrusionBlend2``. Each profile named in
|
|
1066
|
+
``params.profiles`` is selected under mark 1 (in order — the selection
|
|
1067
|
+
order determines the loft direction), and any ``params.guide_curves`` are
|
|
1068
|
+
selected under mark 2. Because a solid is produced, every profile must be
|
|
1069
|
+
a closed contour.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
|
|
1073
|
+
``currentModel``.
|
|
1074
|
+
params: Loft parameter bag. Relevant fields:
|
|
1075
|
+
- ``profiles`` (list[str]): Ordered profile sketch names; at least
|
|
1076
|
+
two are required.
|
|
1077
|
+
- ``guide_curves`` (list[str] | None): Optional guide curve names.
|
|
1078
|
+
- ``start_tangent`` / ``end_tangent`` (str | None): ``"normal"``
|
|
1079
|
+
tangency at the start/end profile, anything else / ``None`` ->
|
|
1080
|
+
no tangency.
|
|
1081
|
+
- ``merge_result`` (bool): Merge with existing bodies.
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
1085
|
+
``SolidWorksFeature`` whose ``type`` is ``"Loft"``. On failure,
|
|
1086
|
+
``status`` is ``ERROR`` with a descriptive message.
|
|
1087
|
+
|
|
1088
|
+
Raises:
|
|
1089
|
+
Exception: Propagated through ``_handle_com_operation`` when a profile
|
|
1090
|
+
cannot be selected or the COM call returns ``None``.
|
|
1091
|
+
|
|
1092
|
+
Example::
|
|
1093
|
+
|
|
1094
|
+
from solidworks_mcp.adapters.base import LoftParameters
|
|
1095
|
+
|
|
1096
|
+
params = LoftParameters(profiles=["Sketch1", "Sketch2"])
|
|
1097
|
+
result = await adapter.create_loft(params)
|
|
1098
|
+
print(result.data.name) # e.g. "Loft1"
|
|
1099
|
+
"""
|
|
1100
|
+
if not adapter.currentModel:
|
|
1101
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
1102
|
+
|
|
1103
|
+
profiles = list(getattr(params, "profiles", None) or [])
|
|
1104
|
+
if len(profiles) < 2:
|
|
1105
|
+
return AdapterResult(
|
|
1106
|
+
status=AdapterResultStatus.ERROR,
|
|
1107
|
+
error="Loft requires at least 2 profile sketches",
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
def _loft_operation() -> SolidWorksFeature:
|
|
1111
|
+
"""Inner COM closure that selects profiles/guides and runs the loft.
|
|
1112
|
+
|
|
1113
|
+
Returns:
|
|
1114
|
+
SolidWorksFeature: Populated feature descriptor on success.
|
|
1115
|
+
|
|
1116
|
+
Raises:
|
|
1117
|
+
Exception: When a profile selection fails or
|
|
1118
|
+
``InsertProtrusionBlend2`` returns ``None``.
|
|
1119
|
+
"""
|
|
1120
|
+
guide_curves = list(getattr(params, "guide_curves", None) or [])
|
|
1121
|
+
|
|
1122
|
+
adapter._attempt(
|
|
1123
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
1124
|
+
)
|
|
1125
|
+
|
|
1126
|
+
# Profiles under mark 1, in order. First replaces the selection set,
|
|
1127
|
+
# the rest append so SW sees them as an ordered profile group.
|
|
1128
|
+
for index, profile in enumerate(profiles):
|
|
1129
|
+
if not _select_named_feature(adapter, profile, 1, append=index > 0):
|
|
1130
|
+
raise Exception(f"Failed to select loft profile sketch: {profile}")
|
|
1131
|
+
|
|
1132
|
+
# Optional guide curves under mark 2 (a sketch or a reference curve).
|
|
1133
|
+
for guide in guide_curves:
|
|
1134
|
+
if not _select_named_feature(adapter, guide, 2, append=True):
|
|
1135
|
+
raise Exception(f"Failed to select loft guide curve: {guide}")
|
|
1136
|
+
|
|
1137
|
+
# swTangencyType_e: 0 = none, 1 = tangent to profile normal.
|
|
1138
|
+
def _tangency(value: str | None) -> int:
|
|
1139
|
+
return 1 if str(value or "").strip().lower() == "normal" else 0
|
|
1140
|
+
|
|
1141
|
+
start_match = _tangency(getattr(params, "start_tangent", None))
|
|
1142
|
+
end_match = _tangency(getattr(params, "end_tangent", None))
|
|
1143
|
+
|
|
1144
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
1145
|
+
feature = feature_manager.InsertProtrusionBlend2(
|
|
1146
|
+
False, # Closed loft
|
|
1147
|
+
True, # KeepTangency
|
|
1148
|
+
False, # ForceNonRational
|
|
1149
|
+
1.0, # TessToleranceFactor
|
|
1150
|
+
start_match, # StartMatchingType (swTangencyType_e)
|
|
1151
|
+
end_match, # EndMatchingType
|
|
1152
|
+
1.0, # StartTangentLength
|
|
1153
|
+
1.0, # EndTangentLength
|
|
1154
|
+
True, # StartTangentDir
|
|
1155
|
+
True, # EndTangentDir
|
|
1156
|
+
False, # IsThinBody
|
|
1157
|
+
0.0, # Thickness1
|
|
1158
|
+
0.0, # Thickness2
|
|
1159
|
+
0, # ThinType
|
|
1160
|
+
bool(getattr(params, "merge_result", True)), # Merge
|
|
1161
|
+
True, # UseFeatScope
|
|
1162
|
+
True, # UseAutoSelect
|
|
1163
|
+
2, # GuideCurveInfluence (swGuideCurveInfluenceNextEdge)
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
if not feature:
|
|
1167
|
+
raise Exception("Failed to create loft feature")
|
|
1168
|
+
|
|
1169
|
+
return SolidWorksFeature(
|
|
1170
|
+
name=feature.Name,
|
|
1171
|
+
type="Loft",
|
|
1172
|
+
id=adapter._get_feature_id(feature),
|
|
1173
|
+
parameters={
|
|
1174
|
+
"profiles": profiles,
|
|
1175
|
+
"guide_curves": guide_curves or None,
|
|
1176
|
+
"start_tangent": getattr(params, "start_tangent", None),
|
|
1177
|
+
"end_tangent": getattr(params, "end_tangent", None),
|
|
1178
|
+
"merge_result": bool(getattr(params, "merge_result", True)),
|
|
1179
|
+
},
|
|
1180
|
+
properties={"created": datetime.now().isoformat()},
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
return cast(
|
|
1184
|
+
AdapterResult[SolidWorksFeature],
|
|
1185
|
+
adapter._handle_com_operation("create_loft", _loft_operation),
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _create_cut_extrude_impl(
|
|
1190
|
+
adapter: Any, params: ExtrusionParameters
|
|
1191
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
1192
|
+
"""Create a cut-extrude feature from an explicit or unambiguously-tracked sketch.
|
|
1193
|
+
|
|
1194
|
+
Sketch resolution never guesses:
|
|
1195
|
+
|
|
1196
|
+
1. ``params.sketch_name``, if given, is matched (case-insensitively)
|
|
1197
|
+
against the live feature tree via :func:`_profile_feature_names`. No
|
|
1198
|
+
match -> immediate error listing the sketches that do exist. No COM
|
|
1199
|
+
cut is attempted.
|
|
1200
|
+
2. Otherwise, ``adapter._last_sketch_name`` (set by the most recent
|
|
1201
|
+
``create_sketch``/``exit_sketch`` call in this session) is used only
|
|
1202
|
+
if it also matches a real name in the current tree — this catches the
|
|
1203
|
+
case where ``create_sketch`` had to fall back to a synthetic
|
|
1204
|
+
``Sketch_N`` guess (SolidWorks didn't hand back a usable sketch object
|
|
1205
|
+
synchronously) and that guess doesn't correspond to anything real.
|
|
1206
|
+
3. Otherwise: immediate error asking the caller to pass ``sketch_name``
|
|
1207
|
+
explicitly, listing available sketches. There is no third guess (no
|
|
1208
|
+
"most recent ``ProfileFeature`` in the tree", no blind ``Sketch<N>``
|
|
1209
|
+
enumeration) — a wrong silent guess would cut the wrong profile.
|
|
1210
|
+
|
|
1211
|
+
Once resolved, the sketch is explicitly selected via ``SelectByID2``
|
|
1212
|
+
*before* any ``FeatureCut`` call — SolidWorks' own "implicit" active-sketch
|
|
1213
|
+
state (whatever was last open before this call) is never relied upon,
|
|
1214
|
+
since that is exactly the racy behavior that made this feature
|
|
1215
|
+
intermittently cut nothing.
|
|
1216
|
+
|
|
1217
|
+
Two COM API variants are attempted in order of preference:
|
|
1218
|
+
|
|
1219
|
+
1. ``FeatureCut4`` — most modern (SolidWorks 2015+).
|
|
1220
|
+
2. ``FeatureCut3`` — SolidWorks 2010–2014, and any install lacking
|
|
1221
|
+
``FeatureCut4``. Same 26-parameter order as ``FeatureCut4`` minus the
|
|
1222
|
+
trailing ``OptimizeGeometry`` argument (confirmed against SolidWorks
|
|
1223
|
+
API help and live macro examples — there is no separate "legacy"
|
|
1224
|
+
argument order; an earlier version of this fallback invented one and
|
|
1225
|
+
it always raised ``DISP_E_PARAMNOTOPTIONAL`` because it was missing
|
|
1226
|
+
the ``NormalCut`` argument and had ``D2`` missing entirely).
|
|
1227
|
+
|
|
1228
|
+
All depth values are in millimetres and converted to metres internally.
|
|
1229
|
+
|
|
1230
|
+
Args:
|
|
1231
|
+
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
|
|
1232
|
+
``currentModel``.
|
|
1233
|
+
params: Extrusion parameter bag reused for cut parameters:
|
|
1234
|
+
- ``depth`` (float): Cut depth in mm.
|
|
1235
|
+
- ``draft_angle`` (float): Draft angle in degrees.
|
|
1236
|
+
- ``reverse_direction`` (bool): Flip the cut direction. A
|
|
1237
|
+
one-direction cut whose requested direction finds no material
|
|
1238
|
+
is retried the other way; ``parameters.direction_flipped``
|
|
1239
|
+
says so.
|
|
1240
|
+
- ``end_condition`` (str): ``"Blind"`` (default),
|
|
1241
|
+
``"ThroughAll"`` / ``"through_all"``, or ``"ThroughAllBoth"`` /
|
|
1242
|
+
``"through_all_both"`` (through all in both directions, cut as a
|
|
1243
|
+
double-ended Through All feature).
|
|
1244
|
+
- ``both_directions`` (bool): With ``"ThroughAll"``, cut through
|
|
1245
|
+
all in both directions (same as ``"ThroughAllBoth"``).
|
|
1246
|
+
- ``feature_scope`` (bool): Limit cut to selected bodies.
|
|
1247
|
+
- ``auto_select`` (bool): Auto-select bodies in scope.
|
|
1248
|
+
- ``sketch_name`` (str | None): Sketch to cut from. See sketch
|
|
1249
|
+
resolution above.
|
|
1250
|
+
|
|
1251
|
+
Returns:
|
|
1252
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
1253
|
+
``SolidWorksFeature`` whose ``type`` is ``"Cut-Extrude"``. On
|
|
1254
|
+
failure, ``status`` is ``ERROR`` and ``error`` explains exactly
|
|
1255
|
+
which stage failed: sketch-not-found (with the list of sketches that
|
|
1256
|
+
do exist), sketch-selection-failed (the resolved name plus why
|
|
1257
|
+
``SelectByID2`` was rejected), or every ``FeatureCut`` COM variant's
|
|
1258
|
+
own error text — and, when every COM call returned falsy without
|
|
1259
|
+
raising at all, an explicit note that the profile likely isn't a
|
|
1260
|
+
valid closed loop intersecting solid geometry, rather than a bare
|
|
1261
|
+
"failed" with no diagnostic content.
|
|
1262
|
+
|
|
1263
|
+
Raises:
|
|
1264
|
+
Exception: Propagated through ``_handle_com_operation`` for every
|
|
1265
|
+
failure stage described above.
|
|
1266
|
+
|
|
1267
|
+
Example::
|
|
1268
|
+
|
|
1269
|
+
from solidworks_mcp.adapters.base import ExtrusionParameters
|
|
1270
|
+
from solidworks_mcp.adapters import pywin32_feature_ops
|
|
1271
|
+
|
|
1272
|
+
params = ExtrusionParameters(depth=10.0, end_condition="ThroughAll")
|
|
1273
|
+
result = pywin32_feature_ops.create_cut_extrude(adapter, params)
|
|
1274
|
+
print(result.data.name) # e.g. "Cut-Extrude1"
|
|
1275
|
+
"""
|
|
1276
|
+
if not adapter.currentModel:
|
|
1277
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
1278
|
+
|
|
1279
|
+
def _cut_operation() -> SolidWorksFeature:
|
|
1280
|
+
"""Inner COM closure that locates the active sketch and performs the cut.
|
|
1281
|
+
|
|
1282
|
+
Normalises ``params``, selects the sketch profile, then tries each
|
|
1283
|
+
end-condition form against ``FeatureCut4`` and then ``FeatureCut3``.
|
|
1284
|
+
|
|
1285
|
+
Returns:
|
|
1286
|
+
SolidWorksFeature: Populated feature descriptor on success.
|
|
1287
|
+
|
|
1288
|
+
Raises:
|
|
1289
|
+
Exception: When all COM cut variants return ``None``.
|
|
1290
|
+
"""
|
|
1291
|
+
normalized = SimpleNamespace(
|
|
1292
|
+
depth=float(getattr(params, "depth", 0.0)),
|
|
1293
|
+
draft_angle=float(getattr(params, "draft_angle", 0.0)),
|
|
1294
|
+
reverse_direction=bool(getattr(params, "reverse_direction", False)),
|
|
1295
|
+
end_condition=str(getattr(params, "end_condition", "Blind")),
|
|
1296
|
+
both_directions=bool(getattr(params, "both_directions", False)),
|
|
1297
|
+
feature_scope=bool(getattr(params, "feature_scope", False)),
|
|
1298
|
+
auto_select=bool(getattr(params, "auto_select", True)),
|
|
1299
|
+
sketch_name=getattr(params, "sketch_name", None) or None,
|
|
1300
|
+
)
|
|
1301
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
1302
|
+
|
|
1303
|
+
# --- Sketch resolution: explicit > tracked-and-verified > clear
|
|
1304
|
+
# error. Never a silent guess — see the docstring above. Resolving
|
|
1305
|
+
# this up front (before any FeatureCut attempt) also lets every
|
|
1306
|
+
# failure below name the sketch it was actually targeting.
|
|
1307
|
+
tree_names = _profile_feature_names(adapter)
|
|
1308
|
+
|
|
1309
|
+
def _match_tree_name(name: str) -> str | None:
|
|
1310
|
+
for real in tree_names:
|
|
1311
|
+
if real.lower() == name.lower():
|
|
1312
|
+
return real
|
|
1313
|
+
return None
|
|
1314
|
+
|
|
1315
|
+
available = ", ".join(tree_names) if tree_names else "(none found)"
|
|
1316
|
+
if normalized.sketch_name:
|
|
1317
|
+
target_sketch = _match_tree_name(normalized.sketch_name)
|
|
1318
|
+
if target_sketch is None:
|
|
1319
|
+
raise Exception(
|
|
1320
|
+
f"Sketch '{normalized.sketch_name}' not found in the "
|
|
1321
|
+
f"feature tree. Sketches available to cut from: {available}"
|
|
1322
|
+
)
|
|
1323
|
+
else:
|
|
1324
|
+
last_tracked = getattr(adapter, "_last_sketch_name", None)
|
|
1325
|
+
target_sketch = _match_tree_name(last_tracked) if last_tracked else None
|
|
1326
|
+
if target_sketch is None:
|
|
1327
|
+
hint = (
|
|
1328
|
+
f" (the last create_sketch call returned '{last_tracked}', "
|
|
1329
|
+
"which does not match any real sketch in the model — "
|
|
1330
|
+
"SolidWorks likely didn't hand back a usable sketch "
|
|
1331
|
+
"object synchronously)"
|
|
1332
|
+
if last_tracked
|
|
1333
|
+
else " (no create_sketch/exit_sketch call has run this session)"
|
|
1334
|
+
)
|
|
1335
|
+
raise Exception(
|
|
1336
|
+
"No sketch_name given, and no reliably-tracked sketch to "
|
|
1337
|
+
f"cut from{hint}. Pass sketch_name explicitly. Sketches "
|
|
1338
|
+
f"available to cut from: {available}"
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
# Explicit selection before any FeatureCut attempt. SolidWorks' own
|
|
1342
|
+
# "implicit" active-sketch state (whatever was last open) is never
|
|
1343
|
+
# relied upon — that is exactly the race that made this feature
|
|
1344
|
+
# intermittently cut nothing while reporting no error at all.
|
|
1345
|
+
adapter._attempt(
|
|
1346
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
1347
|
+
)
|
|
1348
|
+
# Callout must be an explicit VT_DISPATCH null, not plain None — a
|
|
1349
|
+
# bare None marshals as VT_NULL and SolidWorks rejects the whole call
|
|
1350
|
+
# with DISP_E_TYPEMISMATCH (runbook item 11). Using
|
|
1351
|
+
# _attempt_with_error (not _attempt) here means that error surfaces
|
|
1352
|
+
# in the failure message below instead of being swallowed into a
|
|
1353
|
+
# generic "selection failed" guess.
|
|
1354
|
+
select_result, select_error = adapter._attempt_with_error(
|
|
1355
|
+
lambda: adapter.currentModel.Extension.SelectByID2(
|
|
1356
|
+
target_sketch,
|
|
1357
|
+
"SKETCH",
|
|
1358
|
+
0.0,
|
|
1359
|
+
0.0,
|
|
1360
|
+
0.0,
|
|
1361
|
+
False,
|
|
1362
|
+
0,
|
|
1363
|
+
_null_callout(),
|
|
1364
|
+
0,
|
|
1365
|
+
)
|
|
1366
|
+
)
|
|
1367
|
+
sketch_selected = bool(select_result)
|
|
1368
|
+
if not sketch_selected:
|
|
1369
|
+
reason = (
|
|
1370
|
+
f" COM error: {select_error}"
|
|
1371
|
+
if select_error is not None
|
|
1372
|
+
else " SelectByID2 returned False with no COM error — the "
|
|
1373
|
+
"sketch may already be consumed by another feature, be "
|
|
1374
|
+
"suppressed, or not be a valid selectable profile."
|
|
1375
|
+
)
|
|
1376
|
+
raise Exception(
|
|
1377
|
+
f"Failed to select sketch '{target_sketch}' via SelectByID2." + reason
|
|
1378
|
+
)
|
|
1379
|
+
adapter._last_sketch_name = target_sketch
|
|
1380
|
+
|
|
1381
|
+
end_condition = (normalized.end_condition or "Blind").strip().lower()
|
|
1382
|
+
blind = adapter.constants["swEndCondBlind"]
|
|
1383
|
+
through_all = adapter.constants["swEndCondThroughAll"]
|
|
1384
|
+
depth_m = normalized.depth / 1000.0
|
|
1385
|
+
through_one = end_condition in {"throughall", "through all", "through_all"}
|
|
1386
|
+
through_both = end_condition in {
|
|
1387
|
+
"throughallboth",
|
|
1388
|
+
"through all both",
|
|
1389
|
+
"through_all_both",
|
|
1390
|
+
} or (through_one and normalized.both_directions)
|
|
1391
|
+
|
|
1392
|
+
t0 = adapter.constants.get("swStartSketchPlane", 0)
|
|
1393
|
+
feature = None
|
|
1394
|
+
fallback_errors: list[str] = []
|
|
1395
|
+
|
|
1396
|
+
# Detect the SW major version: it picks OptimizeGeometry below.
|
|
1397
|
+
sw_major = 0
|
|
1398
|
+
if getattr(adapter, "swApp", None):
|
|
1399
|
+
rev = adapter._attempt(
|
|
1400
|
+
lambda: adapter._get_attr_or_call(adapter.swApp, "RevisionNumber"),
|
|
1401
|
+
default="0",
|
|
1402
|
+
)
|
|
1403
|
+
try:
|
|
1404
|
+
sw_major = int(str(rev).split(".")[0])
|
|
1405
|
+
except (ValueError, IndexError):
|
|
1406
|
+
sw_major = 0
|
|
1407
|
+
|
|
1408
|
+
# (label, Sd, T1, T2, Dir) forms to try, in order. Sd is True for a
|
|
1409
|
+
# single-ended cut, False to also cut Direction 2 with T2. Dir
|
|
1410
|
+
# reverses Direction 1.
|
|
1411
|
+
reverse = normalized.reverse_direction
|
|
1412
|
+
if through_both:
|
|
1413
|
+
# Through All in both directions is a double-ended cut with
|
|
1414
|
+
# Through All on each side. A single-ended cut with
|
|
1415
|
+
# T1=swEndCondThroughAllBoth (9) is accepted by the COM call but
|
|
1416
|
+
# SolidWorks 2026 returns no feature for it (seen live on a closed
|
|
1417
|
+
# Front Plane sketch), so that form is only a fallback.
|
|
1418
|
+
forms = [
|
|
1419
|
+
(
|
|
1420
|
+
" [Sd=False, T1=T2=ThroughAll]",
|
|
1421
|
+
False,
|
|
1422
|
+
through_all,
|
|
1423
|
+
through_all,
|
|
1424
|
+
reverse,
|
|
1425
|
+
),
|
|
1426
|
+
(
|
|
1427
|
+
" [Sd=True, T1=ThroughAllBoth]",
|
|
1428
|
+
True,
|
|
1429
|
+
adapter.constants["swEndCondThroughAllBoth"],
|
|
1430
|
+
blind,
|
|
1431
|
+
reverse,
|
|
1432
|
+
),
|
|
1433
|
+
]
|
|
1434
|
+
else:
|
|
1435
|
+
# One direction (Blind or Through All) is single-ended: Sd means
|
|
1436
|
+
# "single direction" (#193). Direction 1 follows the sketch
|
|
1437
|
+
# normal, so from a sketch on a face, or on the plane of one, the
|
|
1438
|
+
# requested direction can point out of the part and the cut finds
|
|
1439
|
+
# no material. Only then is the opposite direction tried, which is
|
|
1440
|
+
# what the SolidWorks UI does (seen live on SW 2026: a Top Plane
|
|
1441
|
+
# sketch under a block cut nothing until Dir was flipped).
|
|
1442
|
+
t1 = through_all if through_one else blind
|
|
1443
|
+
forms = [
|
|
1444
|
+
("", True, t1, blind, reverse),
|
|
1445
|
+
(" [direction flipped]", True, t1, blind, not reverse),
|
|
1446
|
+
]
|
|
1447
|
+
|
|
1448
|
+
def _cut_args(sd: bool, t1: int, t2: int, dir_: bool) -> list[Any]:
|
|
1449
|
+
"""Build the 26 arguments ``FeatureCut3`` and ``FeatureCut4`` share.
|
|
1450
|
+
|
|
1451
|
+
Signature: Sd, Flip, Dir, T1, T2, D1, D2, Dchk1, Dchk2, Ddir1, Ddir2,
|
|
1452
|
+
Dang1, Dang2, OffsetReverse1, OffsetReverse2, TranslateSurface1,
|
|
1453
|
+
TranslateSurface2, NormalCut, UseFeatScope, UseAutoSelect,
|
|
1454
|
+
AssemblyFeatureScope, AutoSelectComponents, PropagateFeatureToParts,
|
|
1455
|
+
T0, StartOffset, FlipStartOffset. ``FeatureCut4`` appends
|
|
1456
|
+
OptimizeGeometry.
|
|
1457
|
+
|
|
1458
|
+
Args:
|
|
1459
|
+
sd: ``True`` for a single-ended cut.
|
|
1460
|
+
t1: Direction 1 end condition.
|
|
1461
|
+
t2: Direction 2 end condition (used only when ``sd`` is False).
|
|
1462
|
+
dir_: Reverse Direction 1.
|
|
1463
|
+
|
|
1464
|
+
Returns:
|
|
1465
|
+
list[Any]: Positional arguments in COM order.
|
|
1466
|
+
"""
|
|
1467
|
+
return [
|
|
1468
|
+
sd, # Sd
|
|
1469
|
+
False, # Flip (True would cut outside the profile)
|
|
1470
|
+
dir_, # Dir
|
|
1471
|
+
t1, # T1
|
|
1472
|
+
t2, # T2
|
|
1473
|
+
depth_m, # D1
|
|
1474
|
+
0.0, # D2
|
|
1475
|
+
False,
|
|
1476
|
+
False,
|
|
1477
|
+
False,
|
|
1478
|
+
False, # Dchk1/2, Ddir1/2
|
|
1479
|
+
normalized.draft_angle * 3.14159 / 180.0, # Dang1
|
|
1480
|
+
0.0, # Dang2
|
|
1481
|
+
False,
|
|
1482
|
+
False,
|
|
1483
|
+
False,
|
|
1484
|
+
False, # OffsetRev1/2, TranslateSurf1/2
|
|
1485
|
+
False, # NormalCut
|
|
1486
|
+
normalized.feature_scope, # UseFeatScope
|
|
1487
|
+
normalized.auto_select, # UseAutoSelect
|
|
1488
|
+
False, # AssemblyFeatureScope
|
|
1489
|
+
False, # AutoSelectComponents
|
|
1490
|
+
False, # PropagateFeatureToParts
|
|
1491
|
+
t0, # T0
|
|
1492
|
+
0.0, # StartOffset
|
|
1493
|
+
False, # FlipStartOffset
|
|
1494
|
+
]
|
|
1495
|
+
|
|
1496
|
+
# 1. FeatureCut4 (SW 2015+): 27 params, the 27th being
|
|
1497
|
+
# OptimizeGeometry (verified True on SW 2025, False on SW 2026). PFeat
|
|
1498
|
+
# is an OUT param (PARAMFLAG_FOUT|FRETVAL), never passed by the caller.
|
|
1499
|
+
# 2. FeatureCut3 (SW 2010+, and any install lacking FeatureCut4).
|
|
1500
|
+
optimize_geometry = sw_major == 33
|
|
1501
|
+
first_call = True
|
|
1502
|
+
direction_flipped = False
|
|
1503
|
+
for label, sd, t1, t2, dir_ in forms:
|
|
1504
|
+
direction_flipped = dir_ != reverse
|
|
1505
|
+
args = _cut_args(sd, t1, t2, dir_)
|
|
1506
|
+
calls = (
|
|
1507
|
+
(
|
|
1508
|
+
"FeatureCut4",
|
|
1509
|
+
lambda a=args: feature_manager.FeatureCut4(*a, optimize_geometry),
|
|
1510
|
+
),
|
|
1511
|
+
("FeatureCut3", lambda a=args: feature_manager.FeatureCut3(*a)),
|
|
1512
|
+
)
|
|
1513
|
+
for api_name, call in calls:
|
|
1514
|
+
if not first_call:
|
|
1515
|
+
# The sketch is already explicitly selected (above) — no
|
|
1516
|
+
# guessing here. A failed FeatureCut can drop it, so
|
|
1517
|
+
# reselect the same sketch before the next variant.
|
|
1518
|
+
adapter._attempt(
|
|
1519
|
+
lambda: adapter.currentModel.Extension.SelectByID2(
|
|
1520
|
+
target_sketch,
|
|
1521
|
+
"SKETCH",
|
|
1522
|
+
0.0,
|
|
1523
|
+
0.0,
|
|
1524
|
+
0.0,
|
|
1525
|
+
False,
|
|
1526
|
+
0,
|
|
1527
|
+
_null_callout(),
|
|
1528
|
+
0,
|
|
1529
|
+
),
|
|
1530
|
+
default=False,
|
|
1531
|
+
)
|
|
1532
|
+
first_call = False
|
|
1533
|
+
feature, cut_error = adapter._attempt_with_error(call)
|
|
1534
|
+
if cut_error is not None:
|
|
1535
|
+
fallback_errors.append(f"{api_name}{label}: {cut_error}")
|
|
1536
|
+
if feature:
|
|
1537
|
+
break
|
|
1538
|
+
if feature:
|
|
1539
|
+
break
|
|
1540
|
+
|
|
1541
|
+
if not feature:
|
|
1542
|
+
if fallback_errors:
|
|
1543
|
+
raise Exception(
|
|
1544
|
+
f"Failed to create cut extrude feature from sketch "
|
|
1545
|
+
f"'{target_sketch}' (SolidWorks major version {sw_major}). "
|
|
1546
|
+
+ " | ".join(fallback_errors)
|
|
1547
|
+
)
|
|
1548
|
+
# Name what was tried so the caller doesn't retry the same thing:
|
|
1549
|
+
# a one-direction cut has already been tried both ways.
|
|
1550
|
+
tried = "; ".join(
|
|
1551
|
+
label.strip(" []") or "the requested direction"
|
|
1552
|
+
for label, *_rest in forms
|
|
1553
|
+
)
|
|
1554
|
+
raise Exception(
|
|
1555
|
+
f"SolidWorks built no feature from sketch '{target_sketch}' "
|
|
1556
|
+
f"(tried: {tried}; SolidWorks major version {sw_major}). The "
|
|
1557
|
+
"profile removed no material in any of them. Check that the "
|
|
1558
|
+
"sketch is a closed profile, that it overlaps the solid body "
|
|
1559
|
+
f"within the {normalized.depth:g} mm depth (or use "
|
|
1560
|
+
"end_condition 'ThroughAll'), and that no other feature has "
|
|
1561
|
+
"already used the sketch."
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
return SolidWorksFeature(
|
|
1565
|
+
name=feature.Name,
|
|
1566
|
+
type="Cut-Extrude",
|
|
1567
|
+
id=adapter._get_feature_id(feature),
|
|
1568
|
+
parameters={
|
|
1569
|
+
"depth": normalized.depth,
|
|
1570
|
+
"draft_angle": normalized.draft_angle,
|
|
1571
|
+
"reverse_direction": normalized.reverse_direction,
|
|
1572
|
+
"sketch_name": target_sketch,
|
|
1573
|
+
# True when the requested direction found no material and the
|
|
1574
|
+
# cut went the other way (#193).
|
|
1575
|
+
"direction_flipped": direction_flipped,
|
|
1576
|
+
},
|
|
1577
|
+
properties={"created": datetime.now().isoformat()},
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
return cast(
|
|
1581
|
+
AdapterResult[SolidWorksFeature],
|
|
1582
|
+
adapter._handle_com_operation("create_cut_extrude", _cut_operation),
|
|
1583
|
+
)
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _parse_edge_spec(
|
|
1587
|
+
edge_name: str,
|
|
1588
|
+
) -> tuple[str, float, float, float, str]:
|
|
1589
|
+
"""Parse an edge/face specification string into (name, x, y, z, entity_type).
|
|
1590
|
+
|
|
1591
|
+
Supports three formats:
|
|
1592
|
+
- ``"Edge<1>"`` — name-based EDGE selection (x=y=z=0.0, SW looks up by
|
|
1593
|
+
topology name)
|
|
1594
|
+
- ``"x,y,z"`` — coordinate-based EDGE selection (name="", coordinate hint
|
|
1595
|
+
in metres)
|
|
1596
|
+
- ``"face:x,y,z"`` (case-insensitive prefix) — coordinate-based FACE
|
|
1597
|
+
selection. Selecting a whole face and handing it to
|
|
1598
|
+
``InsertFeatureChamfer``/``FeatureFillet3`` chamfers/fillets every
|
|
1599
|
+
edge bounding that face in one call. This is more robust than
|
|
1600
|
+
targeting a single edge by coordinate: a point exactly on a shared
|
|
1601
|
+
boundary between two faces (e.g. a hole rim flush with the top
|
|
1602
|
+
surface) can have its nearest-edge resolution silently shift to an
|
|
1603
|
+
unrelated edge after another feature is added earlier in the tree,
|
|
1604
|
+
picking a face interior point has no such ambiguity. Confirmed live
|
|
1605
|
+
2026-09-18: an edge-coordinate hole-rim chamfer silently landed on
|
|
1606
|
+
the block's 30mm outer edge instead (volume delta matched the
|
|
1607
|
+
straight-edge formula, not the circular-rim one); selecting the face
|
|
1608
|
+
instead reliably chamfers the real rim.
|
|
1609
|
+
"""
|
|
1610
|
+
prefix, _, rest = edge_name.partition(":")
|
|
1611
|
+
if prefix.strip().lower() == "face":
|
|
1612
|
+
parts = rest.split(",")
|
|
1613
|
+
if len(parts) == 3:
|
|
1614
|
+
try:
|
|
1615
|
+
x, y, z = float(parts[0]), float(parts[1]), float(parts[2])
|
|
1616
|
+
return "", x, y, z, "FACE"
|
|
1617
|
+
except ValueError:
|
|
1618
|
+
pass
|
|
1619
|
+
return rest, 0.0, 0.0, 0.0, "FACE"
|
|
1620
|
+
|
|
1621
|
+
parts = edge_name.split(",")
|
|
1622
|
+
if len(parts) == 3:
|
|
1623
|
+
try:
|
|
1624
|
+
x, y, z = float(parts[0]), float(parts[1]), float(parts[2])
|
|
1625
|
+
return "", x, y, z, "EDGE"
|
|
1626
|
+
except ValueError:
|
|
1627
|
+
pass
|
|
1628
|
+
return edge_name, 0.0, 0.0, 0.0, "EDGE"
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
#: A coordinate pick must lie this close to the edge/face it resolves to.
|
|
1632
|
+
#: Generous enough for a rounded midpoint, far below any real feature size,
|
|
1633
|
+
#: so a point that is not on any edge fails instead of grabbing a neighbour.
|
|
1634
|
+
_PICK_TOLERANCE_M = 5e-4
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
class _BodyEntityPicker:
|
|
1638
|
+
"""Find the body edge or face nearest a point, by true closest distance.
|
|
1639
|
+
|
|
1640
|
+
``SelectByID2("", "EDGE", x, y, z, ...)`` picks along the view direction
|
|
1641
|
+
through the point, so on a 12 mm plate seen from the front an edge at
|
|
1642
|
+
z=-6 is hidden behind the matching edge at z=+6 and the front one is
|
|
1643
|
+
selected instead - silently. This walks every solid body's edges (or
|
|
1644
|
+
faces) and measures the distance from the point to the closest point
|
|
1645
|
+
*on* each (``IEdge`` / ``IFace2.GetClosestPointOn``), which does not
|
|
1646
|
+
depend on the view, vertices or midpoints.
|
|
1647
|
+
|
|
1648
|
+
The entity lists are read once per picker, so one fillet over many edges
|
|
1649
|
+
costs one body walk, not one per edge.
|
|
1650
|
+
|
|
1651
|
+
Args:
|
|
1652
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
1653
|
+
"""
|
|
1654
|
+
|
|
1655
|
+
def __init__(self, adapter: Any) -> None:
|
|
1656
|
+
self._adapter = adapter
|
|
1657
|
+
self._cache: dict[str, list[Any]] = {}
|
|
1658
|
+
|
|
1659
|
+
def entities(self, getter: str) -> list[Any]:
|
|
1660
|
+
"""Return every solid body's ``GetEdges()`` or ``GetFaces()``."""
|
|
1661
|
+
if getter not in self._cache:
|
|
1662
|
+
model = self._adapter.currentModel
|
|
1663
|
+
_flag(self._adapter, model, "GetBodies2")
|
|
1664
|
+
bodies = as_list(
|
|
1665
|
+
_attempt(self._adapter, lambda: model.GetBodies2(0, False))
|
|
1666
|
+
)
|
|
1667
|
+
found: list[Any] = []
|
|
1668
|
+
for body in bodies:
|
|
1669
|
+
_flag(self._adapter, body, getter)
|
|
1670
|
+
found.extend(
|
|
1671
|
+
as_list(
|
|
1672
|
+
_attempt(self._adapter, lambda b=body: getattr(b, getter)())
|
|
1673
|
+
)
|
|
1674
|
+
)
|
|
1675
|
+
self._cache[getter] = found
|
|
1676
|
+
return self._cache[getter]
|
|
1677
|
+
|
|
1678
|
+
def nearest(
|
|
1679
|
+
self, getter: str, point: tuple[float, float, float]
|
|
1680
|
+
) -> tuple[Any, float] | None:
|
|
1681
|
+
"""Return ``(entity, distance_m)`` for the closest entity, or ``None``."""
|
|
1682
|
+
best: tuple[Any, float] | None = None
|
|
1683
|
+
for entity in self.entities(getter):
|
|
1684
|
+
_flag(self._adapter, entity, "GetClosestPointOn")
|
|
1685
|
+
closest = _attempt(
|
|
1686
|
+
self._adapter, lambda e=entity: list(e.GetClosestPointOn(*point))
|
|
1687
|
+
)
|
|
1688
|
+
if not closest or len(closest) < 3:
|
|
1689
|
+
continue
|
|
1690
|
+
distance = math.dist([float(c) for c in closest[:3]], point)
|
|
1691
|
+
if best is None or distance < best[1]:
|
|
1692
|
+
best = (entity, distance)
|
|
1693
|
+
return best
|
|
1694
|
+
|
|
1695
|
+
def select(self, entity: Any, append: bool, mark: int = 0) -> bool:
|
|
1696
|
+
"""Select ``entity`` with ``IEntity.Select4`` under ``mark``."""
|
|
1697
|
+
model = self._adapter.currentModel
|
|
1698
|
+
sel_mgr = _attempt(self._adapter, lambda: model.SelectionManager)
|
|
1699
|
+
_flag(self._adapter, sel_mgr, "CreateSelectData")
|
|
1700
|
+
select_data = _attempt(self._adapter, lambda: sel_mgr.CreateSelectData())
|
|
1701
|
+
if select_data is None:
|
|
1702
|
+
return False
|
|
1703
|
+
if mark:
|
|
1704
|
+
_attempt(self._adapter, lambda: setattr(select_data, "Mark", mark))
|
|
1705
|
+
_flag(self._adapter, entity, "Select4")
|
|
1706
|
+
return bool(
|
|
1707
|
+
_attempt(self._adapter, lambda: entity.Select4(append, select_data), False)
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
def _select_by_view_pick(
|
|
1712
|
+
adapter: Any,
|
|
1713
|
+
x: float,
|
|
1714
|
+
y: float,
|
|
1715
|
+
z: float,
|
|
1716
|
+
append: bool,
|
|
1717
|
+
mark: int,
|
|
1718
|
+
entity_type: str,
|
|
1719
|
+
) -> bool:
|
|
1720
|
+
"""Fallback pick through ``SelectByID2`` when no body geometry is readable.
|
|
1721
|
+
|
|
1722
|
+
Tries the point plus small radial/Y offsets. View-dependent (see
|
|
1723
|
+
:class:`_BodyEntityPicker`), so only used when the body walk found
|
|
1724
|
+
nothing to measure.
|
|
1725
|
+
"""
|
|
1726
|
+
model = adapter.currentModel
|
|
1727
|
+
r = math.sqrt(x**2 + z**2)
|
|
1728
|
+
if r > 0:
|
|
1729
|
+
candidates = [
|
|
1730
|
+
(x, y, z),
|
|
1731
|
+
(x * 0.999, y, z * 0.999),
|
|
1732
|
+
(x * 1.001, y, z * 1.001),
|
|
1733
|
+
(x * 0.997, y, z * 0.997),
|
|
1734
|
+
(x * 1.003, y, z * 1.003),
|
|
1735
|
+
(x, y * 0.999, z),
|
|
1736
|
+
(x, y * 1.001, z),
|
|
1737
|
+
]
|
|
1738
|
+
else:
|
|
1739
|
+
candidates = [(x, y, z), (x, y * 0.999, z), (x, y * 1.001, z)]
|
|
1740
|
+
|
|
1741
|
+
for cx, cy, cz in candidates:
|
|
1742
|
+
try:
|
|
1743
|
+
selected = bool(
|
|
1744
|
+
model.Extension.SelectByID2(
|
|
1745
|
+
"", entity_type, cx, cy, cz, append, mark, _null_callout(), 0
|
|
1746
|
+
)
|
|
1747
|
+
)
|
|
1748
|
+
except Exception:
|
|
1749
|
+
selected = False
|
|
1750
|
+
if selected:
|
|
1751
|
+
return True
|
|
1752
|
+
return False
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def _select_edge_by_coord(
|
|
1756
|
+
adapter: Any,
|
|
1757
|
+
x: float,
|
|
1758
|
+
y: float,
|
|
1759
|
+
z: float,
|
|
1760
|
+
append: bool,
|
|
1761
|
+
mark: int = 0,
|
|
1762
|
+
entity_type: str = "EDGE",
|
|
1763
|
+
picker: _BodyEntityPicker | None = None,
|
|
1764
|
+
) -> bool:
|
|
1765
|
+
"""Select the edge (or face) nearest to (x, y, z) in metres.
|
|
1766
|
+
|
|
1767
|
+
Calls ``ForceRebuild3`` on the first entity in the selection set so that
|
|
1768
|
+
recent features are fully tessellated and selectable, then resolves the
|
|
1769
|
+
point with :class:`_BodyEntityPicker` - the entity whose closest point is
|
|
1770
|
+
nearest, independent of the view. Only when no body edge/face can be read
|
|
1771
|
+
at all does it fall back to the view-dependent ``SelectByID2`` pick.
|
|
1772
|
+
|
|
1773
|
+
``entity_type`` is normally ``"EDGE"``, but pass ``"FACE"`` to select a
|
|
1774
|
+
whole face instead. Any other type (``"VERTEX"`` from
|
|
1775
|
+
``measure_entities``) goes straight to the ``SelectByID2`` pick: a vertex
|
|
1776
|
+
lies on its edges at distance 0, so the edge walk would select an edge
|
|
1777
|
+
in its place.
|
|
1778
|
+
|
|
1779
|
+
Args:
|
|
1780
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
1781
|
+
x: X in metres.
|
|
1782
|
+
y: Y in metres.
|
|
1783
|
+
z: Z in metres.
|
|
1784
|
+
append: Add to the current selection instead of replacing it.
|
|
1785
|
+
mark: Selection mark.
|
|
1786
|
+
entity_type: ``"EDGE"``, ``"FACE"``, or another ``SelectByID2``
|
|
1787
|
+
type such as ``"VERTEX"``.
|
|
1788
|
+
picker: Reuse a picker (and its body walk) across several picks.
|
|
1789
|
+
|
|
1790
|
+
Returns:
|
|
1791
|
+
bool: True if an entity was selected; False otherwise.
|
|
1792
|
+
|
|
1793
|
+
Raises:
|
|
1794
|
+
LookupError: When the nearest entity is further than
|
|
1795
|
+
``_PICK_TOLERANCE_M`` from the point - picking it anyway would
|
|
1796
|
+
silently act on the wrong edge.
|
|
1797
|
+
"""
|
|
1798
|
+
model = adapter.currentModel
|
|
1799
|
+
|
|
1800
|
+
# Rebuild to tessellate geometry from recent features before the first
|
|
1801
|
+
# entity in the selection set (when append=False this is the first one).
|
|
1802
|
+
if not append:
|
|
1803
|
+
adapter._attempt(lambda: model.ForceRebuild3(True), default=None)
|
|
1804
|
+
|
|
1805
|
+
# Only edges and faces are walked on the body; anything else (a vertex)
|
|
1806
|
+
# keeps the SelectByID2 pick, which honours the requested type.
|
|
1807
|
+
if entity_type not in ("EDGE", "FACE"):
|
|
1808
|
+
return _select_by_view_pick(adapter, x, y, z, append, mark, entity_type)
|
|
1809
|
+
|
|
1810
|
+
picker = picker or _BodyEntityPicker(adapter)
|
|
1811
|
+
getter = "GetFaces" if entity_type == "FACE" else "GetEdges"
|
|
1812
|
+
nearest = picker.nearest(getter, (x, y, z))
|
|
1813
|
+
if nearest is None:
|
|
1814
|
+
return _select_by_view_pick(adapter, x, y, z, append, mark, entity_type)
|
|
1815
|
+
|
|
1816
|
+
entity, distance = nearest
|
|
1817
|
+
if distance > _PICK_TOLERANCE_M:
|
|
1818
|
+
kind = entity_type.lower()
|
|
1819
|
+
raise LookupError(
|
|
1820
|
+
f"No {kind} passes through ({x * 1000:g}, {y * 1000:g}, "
|
|
1821
|
+
f"{z * 1000:g}) mm; the nearest is {distance * 1000:.3f} mm away. "
|
|
1822
|
+
f"Give a point on the {kind} itself."
|
|
1823
|
+
)
|
|
1824
|
+
return picker.select(entity, append, mark)
|
|
1825
|
+
|
|
1826
|
+
|
|
1827
|
+
def _select_face_edges_by_coord(
|
|
1828
|
+
adapter: Any,
|
|
1829
|
+
x: float,
|
|
1830
|
+
y: float,
|
|
1831
|
+
z: float,
|
|
1832
|
+
append: bool,
|
|
1833
|
+
picker: _BodyEntityPicker,
|
|
1834
|
+
) -> bool:
|
|
1835
|
+
"""Select every edge bounding the face through (x, y, z) in metres.
|
|
1836
|
+
|
|
1837
|
+
``IModelDoc2.FeatureFillet3`` (SW 2025+) given a selected *face* returns
|
|
1838
|
+
success but builds nothing - no feature, no volume change (seen live on
|
|
1839
|
+
SW 2026). Expanding the face to its edges first gives it what it can
|
|
1840
|
+
fillet.
|
|
1841
|
+
|
|
1842
|
+
Returns:
|
|
1843
|
+
bool: True when the face was found and all of its edges selected.
|
|
1844
|
+
|
|
1845
|
+
Raises:
|
|
1846
|
+
LookupError: When no face lies within ``_PICK_TOLERANCE_M``.
|
|
1847
|
+
"""
|
|
1848
|
+
if not append:
|
|
1849
|
+
adapter._attempt(lambda: adapter.currentModel.ForceRebuild3(True), default=None)
|
|
1850
|
+
nearest = picker.nearest("GetFaces", (x, y, z))
|
|
1851
|
+
if nearest is None:
|
|
1852
|
+
return False
|
|
1853
|
+
face, distance = nearest
|
|
1854
|
+
if distance > _PICK_TOLERANCE_M:
|
|
1855
|
+
raise LookupError(
|
|
1856
|
+
f"No face passes through ({x * 1000:g}, {y * 1000:g}, {z * 1000:g}) "
|
|
1857
|
+
f"mm; the nearest is {distance * 1000:.3f} mm away."
|
|
1858
|
+
)
|
|
1859
|
+
_flag(adapter, face, "GetEdges")
|
|
1860
|
+
edges = as_list(_attempt(adapter, lambda: face.GetEdges()))
|
|
1861
|
+
if not edges:
|
|
1862
|
+
return False
|
|
1863
|
+
for index, edge in enumerate(edges):
|
|
1864
|
+
if not picker.select(edge, append or index > 0):
|
|
1865
|
+
return False
|
|
1866
|
+
return True
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
def _add_fillet_impl(
|
|
1870
|
+
adapter: Any, radius: float, edge_names: list[str]
|
|
1871
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
1872
|
+
"""Create a constant-radius fillet on one or more named edges.
|
|
1873
|
+
|
|
1874
|
+
Each edge in ``edge_names`` is selected by name using
|
|
1875
|
+
``Extension.SelectByID2`` with entity type ``"EDGE"``. After all edges
|
|
1876
|
+
are in the selection set, ``FeatureFillet3`` is called to build the
|
|
1877
|
+
feature.
|
|
1878
|
+
|
|
1879
|
+
Args:
|
|
1880
|
+
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
|
|
1881
|
+
``currentModel``.
|
|
1882
|
+
radius: Fillet radius in **millimetres**. Converted to metres
|
|
1883
|
+
internally before the COM call.
|
|
1884
|
+
edge_names: List of SolidWorks edge entity names to fillet, e.g.
|
|
1885
|
+
``["Edge<1>", "Edge<2>"]``, coordinate hints in metres
|
|
1886
|
+
(``"x,y,z"``), or ``"face:x,y,z"`` to select a whole face and
|
|
1887
|
+
fillet every edge bounding it — see :func:`_parse_edge_spec`.
|
|
1888
|
+
|
|
1889
|
+
Returns:
|
|
1890
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
1891
|
+
``SolidWorksFeature`` whose ``type`` is ``"Fillet"``. On failure,
|
|
1892
|
+
``status`` is ``ERROR``.
|
|
1893
|
+
|
|
1894
|
+
Raises:
|
|
1895
|
+
Exception: Propagated through ``_handle_com_operation`` when an
|
|
1896
|
+
edge cannot be selected or ``FeatureFillet3`` returns ``None``.
|
|
1897
|
+
|
|
1898
|
+
Example::
|
|
1899
|
+
|
|
1900
|
+
result = pywin32_feature_ops.add_fillet(
|
|
1901
|
+
adapter, radius=3.0, edge_names=["Edge<1>", "Edge<3>"]
|
|
1902
|
+
)
|
|
1903
|
+
print(result.data.name) # e.g. "Fillet1"
|
|
1904
|
+
"""
|
|
1905
|
+
if not adapter.currentModel:
|
|
1906
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
1907
|
+
|
|
1908
|
+
def _fillet_operation() -> SolidWorksFeature:
|
|
1909
|
+
"""Inner COM closure that selects edges and invokes FeatureFillet3.
|
|
1910
|
+
|
|
1911
|
+
Returns:
|
|
1912
|
+
SolidWorksFeature: Populated feature descriptor.
|
|
1913
|
+
|
|
1914
|
+
Raises:
|
|
1915
|
+
Exception: If any edge selection fails or the feature is ``None``.
|
|
1916
|
+
"""
|
|
1917
|
+
# Detect SW major version for FeatureFillet3 parameter count
|
|
1918
|
+
fillet_sw_major = 0
|
|
1919
|
+
if getattr(adapter, "swApp", None):
|
|
1920
|
+
rev = adapter._attempt(
|
|
1921
|
+
lambda: adapter._get_attr_or_call(adapter.swApp, "RevisionNumber"),
|
|
1922
|
+
default="0",
|
|
1923
|
+
)
|
|
1924
|
+
try:
|
|
1925
|
+
fillet_sw_major = int(str(rev).split(".")[0])
|
|
1926
|
+
except (ValueError, IndexError):
|
|
1927
|
+
fillet_sw_major = 0
|
|
1928
|
+
|
|
1929
|
+
# Only touch the selection when edges/faces were explicitly named.
|
|
1930
|
+
# An empty edge_names means "use whatever is already selected" --
|
|
1931
|
+
# e.g. entities the user hand-picked in the live UI via
|
|
1932
|
+
# get_selected_entities. Clearing unconditionally here used to wipe
|
|
1933
|
+
# that selection out from under the caller before FeatureFillet3
|
|
1934
|
+
# ever ran, so a live multi-edge UI selection silently fell through
|
|
1935
|
+
# to "no selection" (SW's own fillet-all-edges fallback) or an
|
|
1936
|
+
# earlier partial/stale selection instead of what was actually
|
|
1937
|
+
# picked. See com-api-pitfalls.md #22.
|
|
1938
|
+
features_before = _feature_count(adapter)
|
|
1939
|
+
|
|
1940
|
+
if edge_names:
|
|
1941
|
+
adapter._attempt(
|
|
1942
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
1943
|
+
)
|
|
1944
|
+
|
|
1945
|
+
picker = _BodyEntityPicker(adapter)
|
|
1946
|
+
for idx, edge_name in enumerate(edge_names):
|
|
1947
|
+
sel_name, ex, ey, ez, entity_type = _parse_edge_spec(edge_name)
|
|
1948
|
+
append = idx > 0 # first edge starts fresh, subsequent ones append
|
|
1949
|
+
if sel_name == "" and entity_type == "FACE":
|
|
1950
|
+
# A selected face makes FeatureFillet3 build nothing, so
|
|
1951
|
+
# select the edges bounding it instead.
|
|
1952
|
+
selected = _select_face_edges_by_coord(
|
|
1953
|
+
adapter, ex, ey, ez, append=append, picker=picker
|
|
1954
|
+
)
|
|
1955
|
+
elif sel_name == "":
|
|
1956
|
+
# Coordinate-based: traverse body edges and pick the closest one.
|
|
1957
|
+
selected = _select_edge_by_coord(
|
|
1958
|
+
adapter,
|
|
1959
|
+
ex,
|
|
1960
|
+
ey,
|
|
1961
|
+
ez,
|
|
1962
|
+
append=append,
|
|
1963
|
+
entity_type=entity_type,
|
|
1964
|
+
picker=picker,
|
|
1965
|
+
)
|
|
1966
|
+
else:
|
|
1967
|
+
selected = adapter._attempt(
|
|
1968
|
+
lambda sn=sel_name, _x=ex, _y=ey, _z=ez, _ap=append, _et=entity_type: (
|
|
1969
|
+
adapter.currentModel.Extension.SelectByID2(
|
|
1970
|
+
sn, _et, _x, _y, _z, _ap, 0, _null_callout(), 0
|
|
1971
|
+
)
|
|
1972
|
+
),
|
|
1973
|
+
default=False,
|
|
1974
|
+
)
|
|
1975
|
+
if not selected:
|
|
1976
|
+
raise Exception(f"Failed to select edge: {edge_name}")
|
|
1977
|
+
|
|
1978
|
+
# SW 2025+ (major >= 33): IModelDoc2.FeatureFillet3 (9 params).
|
|
1979
|
+
# Returns a non-zero int on success — NOT an IFeature — so we look up
|
|
1980
|
+
# the last modified feature afterwards to get the name/id.
|
|
1981
|
+
# Older builds: IFeatureManager.FeatureFillet3 (15 params, returns IFeature).
|
|
1982
|
+
if fillet_sw_major >= 33:
|
|
1983
|
+
result_code = adapter.currentModel.FeatureFillet3(
|
|
1984
|
+
radius / 1000.0, # R1 in meters
|
|
1985
|
+
True, # Propagate (VT_BOOL)
|
|
1986
|
+
0, # Ftyp (VT_I4)
|
|
1987
|
+
False, # VarRadTyp (VT_BOOL — must be bool, not int)
|
|
1988
|
+
0, # OverflowType (VT_I4)
|
|
1989
|
+
0, # NRadii (VT_I4)
|
|
1990
|
+
None, # Radii (VT_VARIANT)
|
|
1991
|
+
False, # UseHelpPoint (VT_BOOL)
|
|
1992
|
+
False, # UseTangentHoldLine (VT_BOOL)
|
|
1993
|
+
)
|
|
1994
|
+
if not result_code:
|
|
1995
|
+
raise Exception(
|
|
1996
|
+
"Failed to create fillet (IModelDoc2.FeatureFillet3 returned 0)"
|
|
1997
|
+
)
|
|
1998
|
+
feature = None # int return — retrieve feature object below
|
|
1999
|
+
else:
|
|
2000
|
+
feature_manager = adapter.currentModel.FeatureManager
|
|
2001
|
+
feature = feature_manager.FeatureFillet3(
|
|
2002
|
+
radius / 1000.0,
|
|
2003
|
+
0,
|
|
2004
|
+
0,
|
|
2005
|
+
0,
|
|
2006
|
+
0,
|
|
2007
|
+
False,
|
|
2008
|
+
False,
|
|
2009
|
+
False,
|
|
2010
|
+
False,
|
|
2011
|
+
False,
|
|
2012
|
+
False,
|
|
2013
|
+
False,
|
|
2014
|
+
False,
|
|
2015
|
+
0,
|
|
2016
|
+
False,
|
|
2017
|
+
)
|
|
2018
|
+
if not feature:
|
|
2019
|
+
raise Exception("Failed to create fillet")
|
|
2020
|
+
|
|
2021
|
+
# IModelDoc2.FeatureFillet3 returns non-zero for a selection it then
|
|
2022
|
+
# builds nothing from (e.g. a bare face, seen live on SW 2026), so a
|
|
2023
|
+
# success code alone proves nothing: the tree must have grown. An
|
|
2024
|
+
# unreadable count is not evidence either way; the caller also
|
|
2025
|
+
# compares volume.
|
|
2026
|
+
features_after = _feature_count(adapter)
|
|
2027
|
+
if (
|
|
2028
|
+
features_before is not None
|
|
2029
|
+
and features_after is not None
|
|
2030
|
+
and features_after <= features_before
|
|
2031
|
+
):
|
|
2032
|
+
raise Exception(
|
|
2033
|
+
"FeatureFillet3 reported success but no fillet feature was added "
|
|
2034
|
+
f"(feature count stayed at {features_before}); the selected "
|
|
2035
|
+
"edges could not be filleted at this radius."
|
|
2036
|
+
)
|
|
2037
|
+
|
|
2038
|
+
# Resolve the feature name — FeatureFillet3 on SW 2025+ returns an int,
|
|
2039
|
+
# not an IFeature, so we can only report a default name here.
|
|
2040
|
+
feature_name = "Fillet"
|
|
2041
|
+
if feature is not None:
|
|
2042
|
+
# Not hasattr(): it only swallows AttributeError, so a stale COM
|
|
2043
|
+
# dispatch raising on Name would fail a fillet that was created.
|
|
2044
|
+
try:
|
|
2045
|
+
feature_name = feature.Name or "Fillet"
|
|
2046
|
+
except Exception: # noqa: BLE001 - int return / stale dispatch
|
|
2047
|
+
pass
|
|
2048
|
+
|
|
2049
|
+
return SolidWorksFeature(
|
|
2050
|
+
name=feature_name,
|
|
2051
|
+
type="Fillet",
|
|
2052
|
+
id=(
|
|
2053
|
+
adapter._attempt(lambda: adapter._get_feature_id(feature), default="")
|
|
2054
|
+
if feature
|
|
2055
|
+
else ""
|
|
2056
|
+
),
|
|
2057
|
+
parameters={"radius": radius, "edges": edge_names},
|
|
2058
|
+
properties={"created": datetime.now().isoformat()},
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
return cast(
|
|
2062
|
+
AdapterResult[SolidWorksFeature],
|
|
2063
|
+
adapter._handle_com_operation("add_fillet", _fillet_operation),
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
|
|
2067
|
+
def _add_chamfer_impl(
|
|
2068
|
+
adapter: Any, distance: float, edge_names: list[str]
|
|
2069
|
+
) -> AdapterResult[SolidWorksFeature]:
|
|
2070
|
+
"""Create an equal-distance chamfer on one or more named edges.
|
|
2071
|
+
|
|
2072
|
+
Each edge in ``edge_names`` is selected by name using
|
|
2073
|
+
``Extension.SelectByID2`` with entity type ``"EDGE"``. After all edges
|
|
2074
|
+
are in the selection set, ``IFeatureManager::InsertFeatureChamfer`` is
|
|
2075
|
+
called in equal-distance mode (type ``1``). A version-gated
|
|
2076
|
+
``IModelDoc2::FeatureChamferType`` path was tried for SW 2025+ but
|
|
2077
|
+
live-verified to silently create an empty, zero-volume feature -- see
|
|
2078
|
+
the comment at its call site for how this was confirmed and why
|
|
2079
|
+
``InsertFeatureChamfer`` is used unconditionally instead.
|
|
2080
|
+
|
|
2081
|
+
Args:
|
|
2082
|
+
adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
|
|
2083
|
+
``currentModel``.
|
|
2084
|
+
distance: Chamfer distance in **millimetres**. Converted to metres
|
|
2085
|
+
internally.
|
|
2086
|
+
edge_names: List of SolidWorks edge entity names, e.g.
|
|
2087
|
+
``["Edge<2>", "Edge<5>"]``, coordinate hints in metres
|
|
2088
|
+
(``"x,y,z"``), or ``"face:x,y,z"`` to select a whole face and
|
|
2089
|
+
chamfer every edge bounding it — see :func:`_parse_edge_spec`.
|
|
2090
|
+
|
|
2091
|
+
Returns:
|
|
2092
|
+
AdapterResult[SolidWorksFeature]: On success, ``data`` is a
|
|
2093
|
+
``SolidWorksFeature`` whose ``type`` is ``"Chamfer"``. On failure,
|
|
2094
|
+
``status`` is ``ERROR``.
|
|
2095
|
+
|
|
2096
|
+
Raises:
|
|
2097
|
+
Exception: Propagated through ``_handle_com_operation`` when an
|
|
2098
|
+
edge cannot be selected or ``InsertFeatureChamfer`` returns
|
|
2099
|
+
``None``.
|
|
2100
|
+
|
|
2101
|
+
Example::
|
|
2102
|
+
|
|
2103
|
+
result = pywin32_feature_ops.add_chamfer(
|
|
2104
|
+
adapter, distance=2.0, edge_names=["Edge<2>"]
|
|
2105
|
+
)
|
|
2106
|
+
print(result.data.name) # e.g. "Chamfer1"
|
|
2107
|
+
"""
|
|
2108
|
+
if not adapter.currentModel:
|
|
2109
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2110
|
+
|
|
2111
|
+
def _chamfer_operation() -> SolidWorksFeature:
|
|
2112
|
+
"""Inner COM closure that selects edges and invokes the chamfer API.
|
|
2113
|
+
|
|
2114
|
+
Returns:
|
|
2115
|
+
SolidWorksFeature: Populated feature descriptor.
|
|
2116
|
+
|
|
2117
|
+
Raises:
|
|
2118
|
+
Exception: If any edge selection fails or the feature is not created.
|
|
2119
|
+
"""
|
|
2120
|
+
import math
|
|
2121
|
+
|
|
2122
|
+
# Only touch the selection when edges/faces were explicitly named.
|
|
2123
|
+
# An empty edge_names means "use whatever is already selected" --
|
|
2124
|
+
# see the matching comment in _add_fillet_impl / com-api-pitfalls.md #22.
|
|
2125
|
+
if edge_names:
|
|
2126
|
+
adapter._attempt(
|
|
2127
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
2128
|
+
)
|
|
2129
|
+
|
|
2130
|
+
for idx, edge_name in enumerate(edge_names):
|
|
2131
|
+
sel_name, cx, cy, cz, entity_type = _parse_edge_spec(edge_name)
|
|
2132
|
+
append = idx > 0
|
|
2133
|
+
if sel_name == "":
|
|
2134
|
+
selected = _select_edge_by_coord(
|
|
2135
|
+
adapter,
|
|
2136
|
+
cx,
|
|
2137
|
+
cy,
|
|
2138
|
+
cz,
|
|
2139
|
+
append=append,
|
|
2140
|
+
mark=0,
|
|
2141
|
+
entity_type=entity_type,
|
|
2142
|
+
)
|
|
2143
|
+
else:
|
|
2144
|
+
selected = adapter._attempt(
|
|
2145
|
+
lambda sn=sel_name, _x=cx, _y=cy, _z=cz, _ap=append, _et=entity_type: (
|
|
2146
|
+
adapter.currentModel.Extension.SelectByID2(
|
|
2147
|
+
sn, _et, _x, _y, _z, _ap, 0, _null_callout(), 0
|
|
2148
|
+
)
|
|
2149
|
+
),
|
|
2150
|
+
default=False,
|
|
2151
|
+
)
|
|
2152
|
+
if not selected:
|
|
2153
|
+
raise Exception(f"Failed to select edge: {edge_name}")
|
|
2154
|
+
|
|
2155
|
+
fm = adapter.currentModel.FeatureManager
|
|
2156
|
+
_flag_feature_methods(fm, "IFeatureManager")
|
|
2157
|
+
|
|
2158
|
+
# IModelDoc2.FeatureChamferType (the documented "SW 2025+" call) was
|
|
2159
|
+
# tried here previously, gated on RevisionNumber >= 33. Live-verified
|
|
2160
|
+
# (2026-09-18, SW major 34 / SW2026): it returns cleanly and a
|
|
2161
|
+
# "Chamfer1" feature appears in the tree, but removes zero volume --
|
|
2162
|
+
# a silent no-op, not a real chamfer. IFeatureManager.InsertFeatureChamfer
|
|
2163
|
+
# was verified on the same install to actually remove material
|
|
2164
|
+
# (confirmed via mass-properties volume delta), so it is used
|
|
2165
|
+
# unconditionally rather than version-branching to a call that has
|
|
2166
|
+
# never been proven to work on any SW version.
|
|
2167
|
+
feature_name = "Chamfer"
|
|
2168
|
+
feature, insert_err = adapter._attempt_with_error(
|
|
2169
|
+
lambda: fm.InsertFeatureChamfer(
|
|
2170
|
+
1, # Options
|
|
2171
|
+
1, # ChamferType = equal distance
|
|
2172
|
+
distance / 1000.0, # Width in metres
|
|
2173
|
+
math.pi / 4, # 45 degree angle
|
|
2174
|
+
0.0,
|
|
2175
|
+
0.0,
|
|
2176
|
+
0.0,
|
|
2177
|
+
0.0,
|
|
2178
|
+
)
|
|
2179
|
+
)
|
|
2180
|
+
if feature and hasattr(feature, "Name"):
|
|
2181
|
+
feature_name = feature.Name or "Chamfer"
|
|
2182
|
+
elif not feature:
|
|
2183
|
+
raise Exception(
|
|
2184
|
+
f"Failed to create chamfer (InsertFeatureChamfer: {insert_err})"
|
|
2185
|
+
)
|
|
2186
|
+
|
|
2187
|
+
return SolidWorksFeature(
|
|
2188
|
+
name=feature_name,
|
|
2189
|
+
type="Chamfer",
|
|
2190
|
+
id=adapter._get_feature_id(feature) if feature else "",
|
|
2191
|
+
parameters={"distance": distance, "edges": edge_names},
|
|
2192
|
+
properties={"created": datetime.now().isoformat()},
|
|
2193
|
+
)
|
|
2194
|
+
|
|
2195
|
+
return cast(
|
|
2196
|
+
AdapterResult[SolidWorksFeature],
|
|
2197
|
+
adapter._handle_com_operation("add_chamfer", _chamfer_operation),
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
|
|
2201
|
+
def _select_feature_by_name(adapter: Any, name: str, append: bool = False) -> Any:
|
|
2202
|
+
"""Select a feature or sketch by name and return it.
|
|
2203
|
+
|
|
2204
|
+
Resolves the entity with ``IModelDoc2::FeatureByName`` and selects it with
|
|
2205
|
+
``IFeature::Select2(append, 0)`` - the same path used elsewhere in this
|
|
2206
|
+
adapter. ``SelectByID2`` is avoided here because it needs an entity-type
|
|
2207
|
+
string that differs between sketches and solid features, and because its
|
|
2208
|
+
``Callout`` argument raises ``Type mismatch`` unless passed an explicit
|
|
2209
|
+
``VT_DISPATCH`` null. Any ``name@document`` qualifier is stripped first.
|
|
2210
|
+
|
|
2211
|
+
Args:
|
|
2212
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2213
|
+
name: Feature or sketch name, e.g. ``"Boss-Extrude1"`` or ``"Sketch1"``.
|
|
2214
|
+
append: Add to the current selection instead of clearing it first.
|
|
2215
|
+
Defaults to False.
|
|
2216
|
+
|
|
2217
|
+
Returns:
|
|
2218
|
+
Any: The selected ``IFeature``, or ``None`` when it does not exist or
|
|
2219
|
+
could not be selected.
|
|
2220
|
+
"""
|
|
2221
|
+
bare = name.split("@", 1)[0]
|
|
2222
|
+
if not append:
|
|
2223
|
+
adapter._attempt(
|
|
2224
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
2225
|
+
)
|
|
2226
|
+
feature = adapter._attempt(
|
|
2227
|
+
lambda: adapter.currentModel.FeatureByName(bare), default=None
|
|
2228
|
+
)
|
|
2229
|
+
if not feature:
|
|
2230
|
+
return None
|
|
2231
|
+
selected = adapter._attempt(lambda: feature.Select2(append, 0), default=False)
|
|
2232
|
+
return feature if selected else None
|
|
2233
|
+
|
|
2234
|
+
|
|
2235
|
+
def _feature_count(adapter: Any) -> int | None:
|
|
2236
|
+
"""Return how many features the active model has, or ``None``.
|
|
2237
|
+
|
|
2238
|
+
Used to confirm an edit changed the tree rather than trusting a COM call
|
|
2239
|
+
that reports success without doing anything.
|
|
2240
|
+
|
|
2241
|
+
``IFeatureManager::GetFeatureCount`` is used rather than walking
|
|
2242
|
+
``FirstFeature``/``GetNextFeature``: the walk needs each dispatch flagged
|
|
2243
|
+
for ``IFeature`` before ``Name`` and ``GetNextFeature`` resolve, and on
|
|
2244
|
+
SW 2025 it still yields nothing, while the count is a single reliable
|
|
2245
|
+
call. ``FeatureByPositionReverse`` was measured returning nothing usable
|
|
2246
|
+
on the same document.
|
|
2247
|
+
|
|
2248
|
+
Args:
|
|
2249
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2250
|
+
|
|
2251
|
+
Returns:
|
|
2252
|
+
int | None: The feature count, or ``None`` when it cannot be read -
|
|
2253
|
+
which is deliberately distinct from ``0``.
|
|
2254
|
+
"""
|
|
2255
|
+
manager = adapter._attempt(
|
|
2256
|
+
lambda: adapter.currentModel.FeatureManager, default=None
|
|
2257
|
+
)
|
|
2258
|
+
if manager is None:
|
|
2259
|
+
return None
|
|
2260
|
+
flagged = sw_type_info.flagged(manager, "IFeatureManager")
|
|
2261
|
+
count = adapter._attempt(lambda: flagged.GetFeatureCount(True), default=None)
|
|
2262
|
+
return int(count) if isinstance(count, (int, float)) else None
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
def _is_suppressed(adapter: Any, feature: Any) -> bool | None:
|
|
2266
|
+
"""Return a feature's suppression state, or ``None`` when unreadable.
|
|
2267
|
+
|
|
2268
|
+
``None`` is deliberately distinct from ``False``: it means the state could
|
|
2269
|
+
not be read, which is not evidence that the feature is unsuppressed.
|
|
2270
|
+
|
|
2271
|
+
Args:
|
|
2272
|
+
adapter: A connected adapter.
|
|
2273
|
+
feature: An ``IFeature`` dispatch.
|
|
2274
|
+
|
|
2275
|
+
Returns:
|
|
2276
|
+
bool | None: ``True`` suppressed, ``False`` not, ``None`` unknown.
|
|
2277
|
+
"""
|
|
2278
|
+
state = adapter._attempt(
|
|
2279
|
+
lambda: adapter._get_attr_or_call(feature, "IsSuppressed"), default=None
|
|
2280
|
+
)
|
|
2281
|
+
if isinstance(state, bool):
|
|
2282
|
+
return state
|
|
2283
|
+
if isinstance(state, (list, tuple)) and state:
|
|
2284
|
+
return bool(state[0])
|
|
2285
|
+
if isinstance(state, int):
|
|
2286
|
+
return bool(state)
|
|
2287
|
+
return None
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
def _delete_feature_impl(adapter: Any, name: str) -> AdapterResult[dict[str, Any]]:
|
|
2291
|
+
"""Delete a named feature or sketch from the active model.
|
|
2292
|
+
|
|
2293
|
+
Selects the feature, then removes it with ``IModelDoc2::EditDelete``.
|
|
2294
|
+
Deleting a parent also removes its children, which is SolidWorks' normal
|
|
2295
|
+
cascade and the intended "erase this and what depends on it" behaviour -
|
|
2296
|
+
so the number of features removed is reported, not assumed to be one.
|
|
2297
|
+
|
|
2298
|
+
``EditDelete`` returns nothing useful, so success is confirmed by the
|
|
2299
|
+
feature being absent from the tree afterwards.
|
|
2300
|
+
|
|
2301
|
+
Args:
|
|
2302
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2303
|
+
name: Name of the feature or sketch to delete.
|
|
2304
|
+
|
|
2305
|
+
Returns:
|
|
2306
|
+
AdapterResult[dict[str, Any]]: What was deleted and how the tree
|
|
2307
|
+
changed. ``ERROR`` when there is no model or the feature is absent.
|
|
2308
|
+
|
|
2309
|
+
Raises:
|
|
2310
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2311
|
+
"""
|
|
2312
|
+
if not adapter.currentModel:
|
|
2313
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2314
|
+
|
|
2315
|
+
def _delete_operation() -> dict[str, Any]:
|
|
2316
|
+
before = _feature_count(adapter)
|
|
2317
|
+
if _select_feature_by_name(adapter, name) is None:
|
|
2318
|
+
raise Exception(f"Feature not found: {name}")
|
|
2319
|
+
|
|
2320
|
+
adapter._attempt(
|
|
2321
|
+
lambda: adapter._get_attr_or_call(adapter.currentModel, "EditDelete"),
|
|
2322
|
+
default=None,
|
|
2323
|
+
)
|
|
2324
|
+
adapter._attempt(lambda: adapter.currentModel.EditRebuild3(), default=None)
|
|
2325
|
+
|
|
2326
|
+
# EditDelete reports nothing useful, so confirm by lookup: the
|
|
2327
|
+
# feature must no longer resolve by name.
|
|
2328
|
+
bare = name.split("@", 1)[0]
|
|
2329
|
+
still_there = adapter._attempt(
|
|
2330
|
+
lambda: adapter.currentModel.FeatureByName(bare), default=None
|
|
2331
|
+
)
|
|
2332
|
+
if still_there:
|
|
2333
|
+
raise Exception(f"EditDelete did not remove feature: {name}")
|
|
2334
|
+
|
|
2335
|
+
after = _feature_count(adapter)
|
|
2336
|
+
return {
|
|
2337
|
+
"deleted": name,
|
|
2338
|
+
"features_before": before,
|
|
2339
|
+
"features_after": after,
|
|
2340
|
+
# Deleting a parent takes its children too, so this is often
|
|
2341
|
+
# more than one.
|
|
2342
|
+
"features_removed": (
|
|
2343
|
+
before - after if before is not None and after is not None else None
|
|
2344
|
+
),
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
return cast(
|
|
2348
|
+
AdapterResult[dict[str, Any]],
|
|
2349
|
+
adapter._handle_com_operation("delete_feature", _delete_operation),
|
|
2350
|
+
)
|
|
2351
|
+
|
|
2352
|
+
|
|
2353
|
+
def _suppress_feature_impl(
|
|
2354
|
+
adapter: Any, name: str, suppress: bool
|
|
2355
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2356
|
+
"""Suppress or unsuppress a named feature in the active model.
|
|
2357
|
+
|
|
2358
|
+
Suppressing rolls a feature and its children out of the model without
|
|
2359
|
+
deleting it - the reversible way to turn off a bad feature.
|
|
2360
|
+
|
|
2361
|
+
``EditSuppress2`` and ``EditUnsuppress2`` report through the error channel
|
|
2362
|
+
only, so the resulting state is read back from ``IFeature::IsSuppressed``
|
|
2363
|
+
rather than inferred from the call returning quietly.
|
|
2364
|
+
|
|
2365
|
+
Args:
|
|
2366
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2367
|
+
name: Feature name to toggle.
|
|
2368
|
+
suppress: ``True`` to suppress, ``False`` to unsuppress.
|
|
2369
|
+
|
|
2370
|
+
Returns:
|
|
2371
|
+
AdapterResult[dict[str, Any]]: The feature and its state afterwards.
|
|
2372
|
+
``suppressed`` is ``None`` when the state could not be read back,
|
|
2373
|
+
which is not the same as "not suppressed".
|
|
2374
|
+
|
|
2375
|
+
Raises:
|
|
2376
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2377
|
+
"""
|
|
2378
|
+
if not adapter.currentModel:
|
|
2379
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2380
|
+
|
|
2381
|
+
def _suppress_operation() -> dict[str, Any]:
|
|
2382
|
+
feature = _select_feature_by_name(adapter, name)
|
|
2383
|
+
if feature is None:
|
|
2384
|
+
raise Exception(f"Feature not found: {name}")
|
|
2385
|
+
|
|
2386
|
+
was = _is_suppressed(adapter, feature)
|
|
2387
|
+
# EditSuppress2/EditUnsuppress2 take no arguments, and late binding
|
|
2388
|
+
# resolves them as properties on some dispatches: calling with ()
|
|
2389
|
+
# performs the edit and *then* raises "'bool' object is not callable",
|
|
2390
|
+
# so the operation succeeds while the caller sees a failure.
|
|
2391
|
+
member = "EditSuppress2" if suppress else "EditUnsuppress2"
|
|
2392
|
+
_, err = adapter._attempt_with_error(
|
|
2393
|
+
lambda: adapter._get_attr_or_call(adapter.currentModel, member)
|
|
2394
|
+
)
|
|
2395
|
+
if err is not None:
|
|
2396
|
+
action = "suppress" if suppress else "unsuppress"
|
|
2397
|
+
raise Exception(f"Failed to {action} {name}: {err}")
|
|
2398
|
+
|
|
2399
|
+
adapter._attempt(lambda: adapter.currentModel.EditRebuild3(), default=None)
|
|
2400
|
+
|
|
2401
|
+
# Re-resolve: the dispatch held above can go stale across a rebuild.
|
|
2402
|
+
refreshed = adapter._attempt(
|
|
2403
|
+
lambda: adapter.currentModel.FeatureByName(name.split("@", 1)[0]),
|
|
2404
|
+
default=None,
|
|
2405
|
+
)
|
|
2406
|
+
now = _is_suppressed(adapter, refreshed) if refreshed is not None else None
|
|
2407
|
+
if now is not None and now != suppress:
|
|
2408
|
+
action = "suppressed" if suppress else "unsuppressed"
|
|
2409
|
+
raise Exception(
|
|
2410
|
+
f"{name} reports suppressed={now} after being {action}; the "
|
|
2411
|
+
"call was accepted but the feature state did not change."
|
|
2412
|
+
)
|
|
2413
|
+
|
|
2414
|
+
return {
|
|
2415
|
+
"feature": name,
|
|
2416
|
+
"requested": suppress,
|
|
2417
|
+
"suppressed": now,
|
|
2418
|
+
"was_suppressed": was,
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
return cast(
|
|
2422
|
+
AdapterResult[dict[str, Any]],
|
|
2423
|
+
adapter._handle_com_operation("suppress_feature", _suppress_operation),
|
|
2424
|
+
)
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def _rename_feature_impl(
|
|
2428
|
+
adapter: Any, old_name: str, new_name: str
|
|
2429
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2430
|
+
"""Rename a named feature in the active model.
|
|
2431
|
+
|
|
2432
|
+
``IFeature::Name`` is a settable property, not a method - it is assigned
|
|
2433
|
+
directly, never called (see the property-vs-method flagging note in the
|
|
2434
|
+
COM threading section of ``CLAUDE.md``). SolidWorks silently refuses a
|
|
2435
|
+
rename onto a name another feature already uses, so a clash is checked
|
|
2436
|
+
for first and the new name is read back afterwards; a mismatch is
|
|
2437
|
+
reported as a failure rather than a quiet success.
|
|
2438
|
+
|
|
2439
|
+
Args:
|
|
2440
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2441
|
+
old_name: The feature's current name. Any ``name@document`` qualifier
|
|
2442
|
+
is stripped.
|
|
2443
|
+
new_name: The name to give it.
|
|
2444
|
+
|
|
2445
|
+
Returns:
|
|
2446
|
+
AdapterResult[dict[str, Any]]: ``renamed`` (bool), ``old_name`` and
|
|
2447
|
+
``new_name``. ``renamed`` is ``False`` with a ``reason`` when the two
|
|
2448
|
+
names are the same. ``ERROR`` when there is no model, the feature is
|
|
2449
|
+
absent, the target name is taken, or the rename did not take.
|
|
2450
|
+
|
|
2451
|
+
Raises:
|
|
2452
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2453
|
+
"""
|
|
2454
|
+
# Resync from ActiveDoc: the user may have switched documents in the
|
|
2455
|
+
# SolidWorks UI since the last tool call (issue #91).
|
|
2456
|
+
sync = getattr(adapter, "_sync_current_model_from_active", None)
|
|
2457
|
+
if callable(sync):
|
|
2458
|
+
adapter._attempt(sync, default=None)
|
|
2459
|
+
if not adapter.currentModel:
|
|
2460
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2461
|
+
|
|
2462
|
+
new = new_name.split("@", 1)[0].strip()
|
|
2463
|
+
if not new:
|
|
2464
|
+
return AdapterResult(
|
|
2465
|
+
status=AdapterResultStatus.ERROR, error="new_name must not be empty"
|
|
2466
|
+
)
|
|
2467
|
+
|
|
2468
|
+
def _rename_operation() -> dict[str, Any]:
|
|
2469
|
+
bare = old_name.split("@", 1)[0]
|
|
2470
|
+
feature = adapter._attempt(
|
|
2471
|
+
lambda: adapter.currentModel.FeatureByName(bare), default=None
|
|
2472
|
+
)
|
|
2473
|
+
if not feature:
|
|
2474
|
+
raise Exception(f"Feature not found: {old_name}")
|
|
2475
|
+
|
|
2476
|
+
if new == bare:
|
|
2477
|
+
return {
|
|
2478
|
+
"renamed": False,
|
|
2479
|
+
"old_name": bare,
|
|
2480
|
+
"new_name": new,
|
|
2481
|
+
"reason": "old and new names are the same",
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
# SolidWorks silently ignores a rename onto an existing feature name,
|
|
2485
|
+
# which would otherwise look like a success. Catch it up front.
|
|
2486
|
+
clash = adapter._attempt(
|
|
2487
|
+
lambda: adapter.currentModel.FeatureByName(new), default=None
|
|
2488
|
+
)
|
|
2489
|
+
if clash:
|
|
2490
|
+
raise Exception(
|
|
2491
|
+
f"Cannot rename to {new!r}: a feature with that name already exists"
|
|
2492
|
+
)
|
|
2493
|
+
|
|
2494
|
+
_, err = adapter._attempt_with_error(lambda: setattr(feature, "Name", new))
|
|
2495
|
+
if err is not None:
|
|
2496
|
+
raise Exception(f"Failed to rename {bare} to {new}: {err}")
|
|
2497
|
+
|
|
2498
|
+
adapter._attempt(lambda: adapter.currentModel.EditRebuild3(), default=None)
|
|
2499
|
+
|
|
2500
|
+
refreshed = adapter._attempt(
|
|
2501
|
+
lambda: adapter.currentModel.FeatureByName(new), default=None
|
|
2502
|
+
)
|
|
2503
|
+
if refreshed is None:
|
|
2504
|
+
raise Exception(
|
|
2505
|
+
f"Rename to {new} was accepted but no feature resolves by that "
|
|
2506
|
+
"name afterwards; the call did not take."
|
|
2507
|
+
)
|
|
2508
|
+
actual = adapter._attempt(
|
|
2509
|
+
lambda: adapter._get_attr_or_call(refreshed, "Name"), default=None
|
|
2510
|
+
)
|
|
2511
|
+
return {
|
|
2512
|
+
"renamed": True,
|
|
2513
|
+
"old_name": bare,
|
|
2514
|
+
"new_name": actual if isinstance(actual, str) and actual else new,
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
return cast(
|
|
2518
|
+
AdapterResult[dict[str, Any]],
|
|
2519
|
+
adapter._handle_com_operation("rename_feature", _rename_operation),
|
|
2520
|
+
)
|
|
2521
|
+
|
|
2522
|
+
|
|
2523
|
+
def _undo_impl(adapter: Any, count: int) -> AdapterResult[dict[str, Any]]:
|
|
2524
|
+
"""Undo the last ``count`` operations in the active model.
|
|
2525
|
+
|
|
2526
|
+
``IModelDoc2::EditUndo2(Steps)`` is a ``Sub`` - it returns nothing - so a
|
|
2527
|
+
quiet call means only that it was made, not that anything was undone.
|
|
2528
|
+
SolidWorks accepts an undo with an empty stack without complaint. The
|
|
2529
|
+
feature tree is therefore compared before and after, and the payload says
|
|
2530
|
+
plainly whether it changed.
|
|
2531
|
+
|
|
2532
|
+
Args:
|
|
2533
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2534
|
+
count: Number of operations to undo; values below 1 are clamped.
|
|
2535
|
+
|
|
2536
|
+
Returns:
|
|
2537
|
+
AdapterResult[dict[str, Any]]: How many steps were requested and
|
|
2538
|
+
whether the feature tree actually changed.
|
|
2539
|
+
|
|
2540
|
+
Raises:
|
|
2541
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2542
|
+
"""
|
|
2543
|
+
if not adapter.currentModel:
|
|
2544
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2545
|
+
|
|
2546
|
+
steps = max(1, int(count))
|
|
2547
|
+
|
|
2548
|
+
def _undo_operation() -> dict[str, Any]:
|
|
2549
|
+
before = _feature_count(adapter)
|
|
2550
|
+
|
|
2551
|
+
_, err = adapter._attempt_with_error(
|
|
2552
|
+
lambda: adapter.currentModel.EditUndo2(steps)
|
|
2553
|
+
)
|
|
2554
|
+
if err is not None:
|
|
2555
|
+
# Older builds expose only the unsuffixed overload.
|
|
2556
|
+
_, legacy_err = adapter._attempt_with_error(
|
|
2557
|
+
lambda: adapter.currentModel.EditUndo(steps)
|
|
2558
|
+
)
|
|
2559
|
+
if legacy_err is not None:
|
|
2560
|
+
raise Exception(f"Undo failed: {err} | legacy: {legacy_err}")
|
|
2561
|
+
|
|
2562
|
+
adapter._attempt(lambda: adapter.currentModel.EditRebuild3(), default=None)
|
|
2563
|
+
after = _feature_count(adapter)
|
|
2564
|
+
|
|
2565
|
+
# None means the count could not be read, which is not evidence that
|
|
2566
|
+
# nothing happened - so tree_changed stays None rather than False.
|
|
2567
|
+
changed = None if before is None or after is None else before != after
|
|
2568
|
+
return {
|
|
2569
|
+
"requested_steps": steps,
|
|
2570
|
+
"features_before": before,
|
|
2571
|
+
"features_after": after,
|
|
2572
|
+
# False means the call was accepted and the tree is unchanged -
|
|
2573
|
+
# an undo with nothing left to undo. Reported rather than dressed
|
|
2574
|
+
# up as a success.
|
|
2575
|
+
"tree_changed": changed,
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
return cast(
|
|
2579
|
+
AdapterResult[dict[str, Any]],
|
|
2580
|
+
adapter._handle_com_operation("undo", _undo_operation),
|
|
2581
|
+
)
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
#: ``swRefPlaneReferenceConstraints_e`` members used by ``InsertRefPlane``.
|
|
2585
|
+
_REF_PLANE_DISTANCE = 8
|
|
2586
|
+
_REF_PLANE_ANGLE = 16
|
|
2587
|
+
_REF_PLANE_OPTION_FLIP = 256
|
|
2588
|
+
|
|
2589
|
+
#: Plane pairs whose intersection defines each principal axis. ``InsertAxis2``
|
|
2590
|
+
#: builds an axis from two selected planes, so "x" means the line where the
|
|
2591
|
+
#: Top and Front planes meet.
|
|
2592
|
+
_AXIS_PLANE_PAIRS: dict[str, tuple[str, str]] = {
|
|
2593
|
+
"x": ("Top Plane", "Front Plane"),
|
|
2594
|
+
"y": ("Front Plane", "Right Plane"),
|
|
2595
|
+
"z": ("Top Plane", "Right Plane"),
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
#: ``swRefPointType_e`` members used by ``IFeatureManager::InsertReferencePoint``.
|
|
2599
|
+
_SW_REF_POINT_ALONG_CURVE = 2
|
|
2600
|
+
_SW_REF_POINT_FACE_CENTER = 4
|
|
2601
|
+
#: ``swRefPointAlongCurveType_e``.
|
|
2602
|
+
_SW_REF_POINT_ALONG_CURVE_DISTANCE = 0
|
|
2603
|
+
_SW_REF_POINT_ALONG_CURVE_PERCENT = 1
|
|
2604
|
+
|
|
2605
|
+
|
|
2606
|
+
def _offset_plane_distance(offset_mm: float, base_flip: bool) -> tuple[float, int]:
|
|
2607
|
+
"""Resolve an offset-plane Distance constraint to ``(distance_m, flip_bits)``.
|
|
2608
|
+
|
|
2609
|
+
``IFeatureManager.InsertRefPlane``'s Distance constraint takes a positive
|
|
2610
|
+
magnitude; the *side* of the base plane is chosen by the ``OptionFlip``
|
|
2611
|
+
bit, not by the sign of the distance. Passing a negative distance does
|
|
2612
|
+
not place the plane on the opposite side - SolidWorks clamps it to 0,
|
|
2613
|
+
collapsing the new plane onto the base. So a negative ``offset_mm`` must
|
|
2614
|
+
be converted to a positive magnitude with the flip bit toggled relative
|
|
2615
|
+
to the caller's ``flip`` request (a negative offset plus ``flip=True``
|
|
2616
|
+
cancel out).
|
|
2617
|
+
|
|
2618
|
+
Credit: this fix (and the pure-helper extraction for direct unit
|
|
2619
|
+
coverage) ports the sign-resolution logic independently found and fixed
|
|
2620
|
+
by contributor @pedropaulovc in their fork (commit ``3c091fd``). See
|
|
2621
|
+
issue #84.
|
|
2622
|
+
|
|
2623
|
+
Args:
|
|
2624
|
+
offset_mm: Signed offset from the base plane, in millimetres.
|
|
2625
|
+
base_flip: Whether the caller requested ``flip``.
|
|
2626
|
+
|
|
2627
|
+
Returns:
|
|
2628
|
+
tuple[float, int]: A non-negative distance in metres, and the flip
|
|
2629
|
+
bits (``_REF_PLANE_OPTION_FLIP`` or 0) to OR into the constraint.
|
|
2630
|
+
"""
|
|
2631
|
+
distance_m = float(offset_mm) / 1000.0
|
|
2632
|
+
flip_bits = _REF_PLANE_OPTION_FLIP if base_flip else 0
|
|
2633
|
+
if distance_m < 0.0:
|
|
2634
|
+
distance_m = -distance_m
|
|
2635
|
+
flip_bits ^= _REF_PLANE_OPTION_FLIP
|
|
2636
|
+
return distance_m, flip_bits
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
class _NullCalloutSentinel:
|
|
2640
|
+
"""Stand-in for ``VARIANT(VT_DISPATCH, None)`` when pywin32 is absent.
|
|
2641
|
+
|
|
2642
|
+
Never passed to a real COM call (if pywin32 isn't importable, no live
|
|
2643
|
+
SolidWorks call can happen either), but must still be distinguishable
|
|
2644
|
+
from a bare ``None`` so callers can't accidentally regress to passing
|
|
2645
|
+
plain ``None`` as ``SelectByID2``'s ``Callout`` argument.
|
|
2646
|
+
"""
|
|
2647
|
+
|
|
2648
|
+
def __repr__(self) -> str:
|
|
2649
|
+
return "<null callout: pywin32 unavailable>"
|
|
2650
|
+
|
|
2651
|
+
|
|
2652
|
+
_NULL_CALLOUT_SENTINEL = _NullCalloutSentinel()
|
|
2653
|
+
|
|
2654
|
+
|
|
2655
|
+
def _null_callout() -> Any:
|
|
2656
|
+
"""Return a VT_DISPATCH null for ``SelectByID2``'s ``Callout`` parameter.
|
|
2657
|
+
|
|
2658
|
+
A plain Python ``None`` marshals as ``VT_NULL``, which SolidWorks rejects
|
|
2659
|
+
with ``DISP_E_TYPEMISMATCH`` - measured as
|
|
2660
|
+
``(-2147352571, 'Type mismatch.', None, 8)``. See runbook item 11.
|
|
2661
|
+
|
|
2662
|
+
Returns:
|
|
2663
|
+
Any: A ``VARIANT(VT_DISPATCH, None)``, or a non-``None`` sentinel
|
|
2664
|
+
when pywin32 is unavailable (mock/Linux runs, where no real COM call
|
|
2665
|
+
will be made anyway, but the value must still never be bare ``None``).
|
|
2666
|
+
"""
|
|
2667
|
+
try:
|
|
2668
|
+
import pythoncom
|
|
2669
|
+
import win32com.client as _win32com
|
|
2670
|
+
except ImportError:
|
|
2671
|
+
return _NULL_CALLOUT_SENTINEL
|
|
2672
|
+
return _win32com.VARIANT(pythoncom.VT_DISPATCH, None)
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
def _select_reference_entity(
|
|
2676
|
+
adapter: Any, reference: str, mark: int, append: bool
|
|
2677
|
+
) -> bool:
|
|
2678
|
+
"""Select a named plane or planar face under a given selection mark.
|
|
2679
|
+
|
|
2680
|
+
Prefers ``FeatureByName`` + ``IFeature::Select2``, which is reliable
|
|
2681
|
+
across builds, and falls back to ``SelectByID2`` for planar faces that are
|
|
2682
|
+
not named tree features.
|
|
2683
|
+
|
|
2684
|
+
Args:
|
|
2685
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2686
|
+
reference: Plane or face name, e.g. ``"Front Plane"`` or ``"Plane1"``.
|
|
2687
|
+
mark: Selection mark the SolidWorks call expects for this role.
|
|
2688
|
+
append: Add to the current selection rather than replacing it.
|
|
2689
|
+
|
|
2690
|
+
Returns:
|
|
2691
|
+
bool: True when the entity was selected.
|
|
2692
|
+
"""
|
|
2693
|
+
if _select_named_feature(adapter, reference, mark, append=append):
|
|
2694
|
+
return True
|
|
2695
|
+
|
|
2696
|
+
callout = _null_callout()
|
|
2697
|
+
for entity_type in ("PLANE", "FACE"):
|
|
2698
|
+
selected = adapter._attempt(
|
|
2699
|
+
lambda t=entity_type: adapter.currentModel.Extension.SelectByID2(
|
|
2700
|
+
reference, t, 0.0, 0.0, 0.0, append, mark, callout, 0
|
|
2701
|
+
),
|
|
2702
|
+
default=False,
|
|
2703
|
+
)
|
|
2704
|
+
if selected:
|
|
2705
|
+
return True
|
|
2706
|
+
return False
|
|
2707
|
+
|
|
2708
|
+
|
|
2709
|
+
def _create_reference_plane_impl(
|
|
2710
|
+
adapter: Any,
|
|
2711
|
+
reference: str,
|
|
2712
|
+
offset: float,
|
|
2713
|
+
angle: float,
|
|
2714
|
+
flip: bool,
|
|
2715
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2716
|
+
"""Create a reference plane offset from, or angled to, an existing plane.
|
|
2717
|
+
|
|
2718
|
+
Wraps ``IFeatureManager::InsertRefPlane(FirstConstraint,
|
|
2719
|
+
FirstConstraintAngleOrDistance, Second, 0, Third, 0)``. The reference
|
|
2720
|
+
entity must be selected under **mark 0** first, which is what the
|
|
2721
|
+
SolidWorks documentation example does before the identical call.
|
|
2722
|
+
|
|
2723
|
+
This is what lets a sketch be opened anywhere other than the six built-in
|
|
2724
|
+
planes. ``create_sketch`` already accepts a plane by its tree name, so the
|
|
2725
|
+
name returned here can be passed straight to it - verified live: a plane
|
|
2726
|
+
76.2 mm off the Front Plane came back as ``"Plane1"``, and a circle
|
|
2727
|
+
sketched on it extruded to exactly the expected volume.
|
|
2728
|
+
|
|
2729
|
+
Args:
|
|
2730
|
+
adapter: A connected ``PyWin32Adapter`` with a valid ``currentModel``.
|
|
2731
|
+
reference: Name of the reference plane or planar face.
|
|
2732
|
+
offset: Offset distance in **millimetres**, converted to metres.
|
|
2733
|
+
angle: Angle in **degrees**; used instead of ``offset`` when non-zero.
|
|
2734
|
+
flip: Reverse the offset or angle direction.
|
|
2735
|
+
|
|
2736
|
+
Returns:
|
|
2737
|
+
AdapterResult[dict[str, Any]]: The new plane's name and the parameters
|
|
2738
|
+
used. ``ERROR`` when there is no model, the reference cannot be
|
|
2739
|
+
selected, or no plane was added to the tree.
|
|
2740
|
+
|
|
2741
|
+
Raises:
|
|
2742
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2743
|
+
|
|
2744
|
+
Example::
|
|
2745
|
+
|
|
2746
|
+
plane = await adapter.create_reference_plane("Front Plane", offset=76.2)
|
|
2747
|
+
await adapter.create_sketch(plane.data["name"])
|
|
2748
|
+
"""
|
|
2749
|
+
if not adapter.currentModel:
|
|
2750
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2751
|
+
|
|
2752
|
+
if not reference:
|
|
2753
|
+
return AdapterResult(
|
|
2754
|
+
status=AdapterResultStatus.ERROR,
|
|
2755
|
+
error="create_reference_plane requires a reference plane/face name",
|
|
2756
|
+
)
|
|
2757
|
+
|
|
2758
|
+
if angle:
|
|
2759
|
+
# Measured on SW 2025: InsertRefPlane with the angle constraint and a
|
|
2760
|
+
# single selected plane adds nothing to the tree and reports no error.
|
|
2761
|
+
# An angled plane is under-defined by one reference - SolidWorks needs
|
|
2762
|
+
# a second entity (an axis or edge) under mark 1 to rotate about.
|
|
2763
|
+
# Refusing is better than returning a plane name that does not exist.
|
|
2764
|
+
return AdapterResult(
|
|
2765
|
+
status=AdapterResultStatus.ERROR,
|
|
2766
|
+
error=(
|
|
2767
|
+
"create_reference_plane does not support 'angle' yet: an "
|
|
2768
|
+
"angled plane needs a second reference (an axis or edge to "
|
|
2769
|
+
"rotate about) which this signature cannot take. Use 'offset' "
|
|
2770
|
+
"for parallel planes."
|
|
2771
|
+
),
|
|
2772
|
+
)
|
|
2773
|
+
|
|
2774
|
+
if not offset:
|
|
2775
|
+
return AdapterResult(
|
|
2776
|
+
status=AdapterResultStatus.ERROR,
|
|
2777
|
+
error=(
|
|
2778
|
+
"create_reference_plane requires a non-zero offset - a plane "
|
|
2779
|
+
"coincident with its reference is not useful"
|
|
2780
|
+
),
|
|
2781
|
+
)
|
|
2782
|
+
|
|
2783
|
+
def _plane_operation() -> dict[str, Any]:
|
|
2784
|
+
before = _feature_count(adapter)
|
|
2785
|
+
adapter._attempt(
|
|
2786
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
2787
|
+
)
|
|
2788
|
+
|
|
2789
|
+
if not _select_reference_entity(adapter, reference, 0, append=False):
|
|
2790
|
+
raise Exception(
|
|
2791
|
+
f"Failed to select reference plane/face: {reference}. "
|
|
2792
|
+
"Use an existing plane name (e.g. 'Front Plane') or a planar "
|
|
2793
|
+
"face."
|
|
2794
|
+
)
|
|
2795
|
+
|
|
2796
|
+
value, flip_bits = _offset_plane_distance(offset, flip)
|
|
2797
|
+
constraint = _REF_PLANE_DISTANCE | flip_bits
|
|
2798
|
+
|
|
2799
|
+
feature_manager = sw_type_info.flagged(
|
|
2800
|
+
adapter.currentModel.FeatureManager, "IFeatureManager"
|
|
2801
|
+
)
|
|
2802
|
+
plane = adapter._attempt(
|
|
2803
|
+
lambda: feature_manager.InsertRefPlane(constraint, value, 0, 0.0, 0, 0.0),
|
|
2804
|
+
default=None,
|
|
2805
|
+
)
|
|
2806
|
+
|
|
2807
|
+
after = _feature_count(adapter)
|
|
2808
|
+
if before is not None and after is not None and after <= before:
|
|
2809
|
+
raise Exception(
|
|
2810
|
+
f"No reference plane was added from '{reference}' - the "
|
|
2811
|
+
f"feature tree still has {after} feature(s)."
|
|
2812
|
+
)
|
|
2813
|
+
if plane is None:
|
|
2814
|
+
raise Exception(
|
|
2815
|
+
f"InsertRefPlane returned nothing for reference '{reference}'"
|
|
2816
|
+
)
|
|
2817
|
+
|
|
2818
|
+
name = adapter._attempt(
|
|
2819
|
+
lambda: adapter._get_attr_or_call(plane, "Name"), default=None
|
|
2820
|
+
)
|
|
2821
|
+
if not name:
|
|
2822
|
+
# No invented fallback: a caller needs the real name to sketch on
|
|
2823
|
+
# the plane, and a made-up one fails later and further away.
|
|
2824
|
+
raise Exception(
|
|
2825
|
+
"The reference plane was created but its name could not be "
|
|
2826
|
+
"read, so it cannot be referenced by create_sketch."
|
|
2827
|
+
)
|
|
2828
|
+
|
|
2829
|
+
return {
|
|
2830
|
+
"name": str(name),
|
|
2831
|
+
"reference": reference,
|
|
2832
|
+
"offset": offset or None,
|
|
2833
|
+
"angle": angle or None,
|
|
2834
|
+
"flip": flip,
|
|
2835
|
+
"features_before": before,
|
|
2836
|
+
"features_after": after,
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
return cast(
|
|
2840
|
+
AdapterResult[dict[str, Any]],
|
|
2841
|
+
adapter._handle_com_operation("create_reference_plane", _plane_operation),
|
|
2842
|
+
)
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
def _create_axis_impl(adapter: Any, reference: str) -> AdapterResult[dict[str, Any]]:
|
|
2846
|
+
"""Create a reference axis along a principal direction.
|
|
2847
|
+
|
|
2848
|
+
Wraps ``IModelDoc2::InsertAxis2(AutoSize)`` - note the interface: it is on
|
|
2849
|
+
``IModelDoc2``, **not** ``IFeatureManager``. It builds an axis from two
|
|
2850
|
+
selected planes, so an axis is requested by naming the direction and the
|
|
2851
|
+
matching plane pair is selected here.
|
|
2852
|
+
|
|
2853
|
+
``InsertAxis2`` returns a plain boolean rather than the axis, so success is
|
|
2854
|
+
confirmed by the feature count rising.
|
|
2855
|
+
|
|
2856
|
+
Args:
|
|
2857
|
+
adapter: A connected ``PyWin32Adapter`` with a valid ``currentModel``.
|
|
2858
|
+
reference: ``"x"``, ``"y"`` or ``"z"``.
|
|
2859
|
+
|
|
2860
|
+
Returns:
|
|
2861
|
+
AdapterResult[dict[str, Any]]: The planes used and how the tree
|
|
2862
|
+
changed. ``ERROR`` for an unknown direction or when no axis appeared.
|
|
2863
|
+
|
|
2864
|
+
Raises:
|
|
2865
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2866
|
+
"""
|
|
2867
|
+
if not adapter.currentModel:
|
|
2868
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2869
|
+
|
|
2870
|
+
key = str(reference or "").strip().lower().lstrip("+-")
|
|
2871
|
+
if key not in _AXIS_PLANE_PAIRS:
|
|
2872
|
+
return AdapterResult(
|
|
2873
|
+
status=AdapterResultStatus.ERROR,
|
|
2874
|
+
error=(
|
|
2875
|
+
f"Unknown axis reference '{reference}'. "
|
|
2876
|
+
f"Use one of: {', '.join(sorted(_AXIS_PLANE_PAIRS))}."
|
|
2877
|
+
),
|
|
2878
|
+
)
|
|
2879
|
+
|
|
2880
|
+
plane_a, plane_b = _AXIS_PLANE_PAIRS[key]
|
|
2881
|
+
|
|
2882
|
+
def _axis_operation() -> dict[str, Any]:
|
|
2883
|
+
before = _feature_count(adapter)
|
|
2884
|
+
adapter._attempt(
|
|
2885
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
2886
|
+
)
|
|
2887
|
+
|
|
2888
|
+
for index, plane in enumerate((plane_a, plane_b)):
|
|
2889
|
+
if not _select_reference_entity(adapter, plane, 0, append=index > 0):
|
|
2890
|
+
raise Exception(f"Failed to select '{plane}' for the axis")
|
|
2891
|
+
|
|
2892
|
+
adapter._attempt(lambda: adapter.currentModel.InsertAxis2(True), default=None)
|
|
2893
|
+
|
|
2894
|
+
after = _feature_count(adapter)
|
|
2895
|
+
if before is None or after is None:
|
|
2896
|
+
raise Exception(
|
|
2897
|
+
"The feature count could not be read, so whether an axis was "
|
|
2898
|
+
"created is unknown."
|
|
2899
|
+
)
|
|
2900
|
+
if after <= before:
|
|
2901
|
+
raise Exception(
|
|
2902
|
+
f"No reference axis was created from {plane_a} + {plane_b}. "
|
|
2903
|
+
"InsertAxis2 reports only a boolean and the feature tree is "
|
|
2904
|
+
"unchanged."
|
|
2905
|
+
)
|
|
2906
|
+
|
|
2907
|
+
return {
|
|
2908
|
+
"reference": key,
|
|
2909
|
+
"planes": [plane_a, plane_b],
|
|
2910
|
+
"features_before": before,
|
|
2911
|
+
"features_after": after,
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
return cast(
|
|
2915
|
+
AdapterResult[dict[str, Any]],
|
|
2916
|
+
adapter._handle_com_operation("create_axis", _axis_operation),
|
|
2917
|
+
)
|
|
2918
|
+
|
|
2919
|
+
|
|
2920
|
+
def _create_reference_point_impl(
|
|
2921
|
+
adapter: Any,
|
|
2922
|
+
mode: str,
|
|
2923
|
+
x: float,
|
|
2924
|
+
y: float,
|
|
2925
|
+
z: float,
|
|
2926
|
+
distance: float | None,
|
|
2927
|
+
percent: float | None,
|
|
2928
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
2929
|
+
"""Create a reference point on the active part.
|
|
2930
|
+
|
|
2931
|
+
Two modes:
|
|
2932
|
+
|
|
2933
|
+
* ``"along_curve"`` - selects the edge under the millimetre coordinate
|
|
2934
|
+
``(x, y, z)`` and places a point ``distance`` mm, or ``percent``
|
|
2935
|
+
(0-100) of the edge length, from the edge start.
|
|
2936
|
+
* ``"face_center"`` - selects the face under ``(x, y, z)`` and places a
|
|
2937
|
+
point at its centroid.
|
|
2938
|
+
|
|
2939
|
+
Coordinate selection uses ``IModelDocExtension::SelectByID2``; per this
|
|
2940
|
+
repo's runbook a ``ForceRebuild3`` is issued first so freshly created
|
|
2941
|
+
edges are tessellated, and the ``Callout`` argument is passed an explicit
|
|
2942
|
+
``VT_DISPATCH`` null. ``InsertReferencePoint`` returns a Feature whether
|
|
2943
|
+
or not it produced anything, so the feature count is compared before and
|
|
2944
|
+
after.
|
|
2945
|
+
|
|
2946
|
+
Args:
|
|
2947
|
+
adapter: A connected adapter with a valid ``currentModel``.
|
|
2948
|
+
mode: ``"along_curve"`` or ``"face_center"``.
|
|
2949
|
+
x: X of a point on the target edge/face, in millimetres.
|
|
2950
|
+
y: Y of a point on the target edge/face, in millimetres.
|
|
2951
|
+
z: Z of a point on the target edge/face, in millimetres.
|
|
2952
|
+
distance: For ``along_curve``, offset from the edge start in mm.
|
|
2953
|
+
percent: For ``along_curve``, position as 0-100 of the edge length.
|
|
2954
|
+
Exactly one of ``distance`` / ``percent`` is required.
|
|
2955
|
+
|
|
2956
|
+
Returns:
|
|
2957
|
+
AdapterResult[dict[str, Any]]: The mode, the coordinate and resolved
|
|
2958
|
+
parameter, and how the feature tree changed. ``ERROR`` for an unknown
|
|
2959
|
+
mode, a bad distance/percent combination, a failed selection, or when
|
|
2960
|
+
no new feature appeared.
|
|
2961
|
+
|
|
2962
|
+
Raises:
|
|
2963
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
2964
|
+
"""
|
|
2965
|
+
if not adapter.currentModel:
|
|
2966
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
2967
|
+
|
|
2968
|
+
key = str(mode or "").strip().lower()
|
|
2969
|
+
if key not in ("along_curve", "face_center"):
|
|
2970
|
+
return AdapterResult(
|
|
2971
|
+
status=AdapterResultStatus.ERROR,
|
|
2972
|
+
error=f"Unknown mode {mode!r}. Use 'along_curve' or 'face_center'.",
|
|
2973
|
+
)
|
|
2974
|
+
|
|
2975
|
+
along_type = 0
|
|
2976
|
+
along_value = 0.0
|
|
2977
|
+
if key == "along_curve":
|
|
2978
|
+
if (distance is None) == (percent is None):
|
|
2979
|
+
return AdapterResult(
|
|
2980
|
+
status=AdapterResultStatus.ERROR,
|
|
2981
|
+
error="along_curve needs exactly one of distance or percent",
|
|
2982
|
+
)
|
|
2983
|
+
if distance is not None:
|
|
2984
|
+
if float(distance) <= 0.0:
|
|
2985
|
+
return AdapterResult(
|
|
2986
|
+
status=AdapterResultStatus.ERROR,
|
|
2987
|
+
error="distance must be positive",
|
|
2988
|
+
)
|
|
2989
|
+
along_type = _SW_REF_POINT_ALONG_CURVE_DISTANCE
|
|
2990
|
+
along_value = float(distance) / 1000.0
|
|
2991
|
+
else:
|
|
2992
|
+
if not 0.0 < float(percent) < 100.0:
|
|
2993
|
+
return AdapterResult(
|
|
2994
|
+
status=AdapterResultStatus.ERROR,
|
|
2995
|
+
error="percent must be between 0 and 100 (exclusive)",
|
|
2996
|
+
)
|
|
2997
|
+
along_type = _SW_REF_POINT_ALONG_CURVE_PERCENT
|
|
2998
|
+
along_value = float(percent) / 100.0
|
|
2999
|
+
|
|
3000
|
+
def _point_operation() -> dict[str, Any]:
|
|
3001
|
+
model = adapter.currentModel
|
|
3002
|
+
before = _feature_count(adapter)
|
|
3003
|
+
adapter._attempt(lambda: model.ForceRebuild3(True), default=None)
|
|
3004
|
+
adapter._attempt(lambda: model.ClearSelection2(True), default=None)
|
|
3005
|
+
|
|
3006
|
+
entity_type = "EDGE" if key == "along_curve" else "FACE"
|
|
3007
|
+
callout = _null_callout()
|
|
3008
|
+
selected = adapter._attempt(
|
|
3009
|
+
lambda: model.Extension.SelectByID2(
|
|
3010
|
+
"",
|
|
3011
|
+
entity_type,
|
|
3012
|
+
float(x) / 1000.0,
|
|
3013
|
+
float(y) / 1000.0,
|
|
3014
|
+
float(z) / 1000.0,
|
|
3015
|
+
False,
|
|
3016
|
+
0,
|
|
3017
|
+
callout,
|
|
3018
|
+
0,
|
|
3019
|
+
),
|
|
3020
|
+
default=False,
|
|
3021
|
+
)
|
|
3022
|
+
if not selected:
|
|
3023
|
+
raise Exception(
|
|
3024
|
+
f"No {entity_type.lower()} found at ({x}, {y}, {z}) mm - the "
|
|
3025
|
+
"coordinate must lie on the target entity."
|
|
3026
|
+
)
|
|
3027
|
+
|
|
3028
|
+
manager = sw_type_info.flagged(
|
|
3029
|
+
adapter._attempt(lambda: model.FeatureManager, default=None),
|
|
3030
|
+
"IFeatureManager",
|
|
3031
|
+
)
|
|
3032
|
+
if manager is None:
|
|
3033
|
+
raise Exception("Part has no FeatureManager")
|
|
3034
|
+
|
|
3035
|
+
if key == "along_curve":
|
|
3036
|
+
adapter._attempt(
|
|
3037
|
+
lambda: manager.InsertReferencePoint(
|
|
3038
|
+
_SW_REF_POINT_ALONG_CURVE, along_type, along_value, 1
|
|
3039
|
+
),
|
|
3040
|
+
default=None,
|
|
3041
|
+
)
|
|
3042
|
+
else:
|
|
3043
|
+
adapter._attempt(
|
|
3044
|
+
lambda: manager.InsertReferencePoint(
|
|
3045
|
+
_SW_REF_POINT_FACE_CENTER, 0, 0.0, 1
|
|
3046
|
+
),
|
|
3047
|
+
default=None,
|
|
3048
|
+
)
|
|
3049
|
+
|
|
3050
|
+
adapter._attempt(lambda: model.EditRebuild3(), default=None)
|
|
3051
|
+
after = _feature_count(adapter)
|
|
3052
|
+
if before is None or after is None:
|
|
3053
|
+
raise Exception(
|
|
3054
|
+
"The feature count could not be read, so whether a reference "
|
|
3055
|
+
"point was created is unknown."
|
|
3056
|
+
)
|
|
3057
|
+
if after <= before:
|
|
3058
|
+
raise Exception(
|
|
3059
|
+
"InsertReferencePoint added no feature - the selected entity "
|
|
3060
|
+
"may not support a reference point in this mode."
|
|
3061
|
+
)
|
|
3062
|
+
return {
|
|
3063
|
+
"mode": key,
|
|
3064
|
+
"at_mm": [x, y, z],
|
|
3065
|
+
"distance_mm": distance if key == "along_curve" else None,
|
|
3066
|
+
"percent": percent if key == "along_curve" else None,
|
|
3067
|
+
"features_before": before,
|
|
3068
|
+
"features_after": after,
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
return cast(
|
|
3072
|
+
AdapterResult[dict[str, Any]],
|
|
3073
|
+
adapter._handle_com_operation("create_reference_point", _point_operation),
|
|
3074
|
+
)
|
|
3075
|
+
|
|
3076
|
+
|
|
3077
|
+
#: Selection marks ``InsertMirrorFeature`` expects, per the SolidWorks API
|
|
3078
|
+
#: reference: features 1, faces 128, bodies 256, and the mirror plane 2.
|
|
3079
|
+
#: Wrong marks and the call reports a feature while mirroring nothing.
|
|
3080
|
+
_MIRROR_MARK_FEATURES = 1
|
|
3081
|
+
_MIRROR_MARK_BODIES = 256
|
|
3082
|
+
_MIRROR_MARK_PLANE = 2
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
def _mirror_feature_impl(
|
|
3086
|
+
adapter: Any,
|
|
3087
|
+
features: list[str],
|
|
3088
|
+
mirror_plane: str,
|
|
3089
|
+
merge: bool,
|
|
3090
|
+
mirror_bodies: bool,
|
|
3091
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3092
|
+
"""Select the sources and the plane, then call InsertMirrorFeature.
|
|
3093
|
+
|
|
3094
|
+
Wraps ``IFeatureManager::InsertMirrorFeature(BMirrorBody,
|
|
3095
|
+
BGeometryPattern, BMerge, BKnit)``. There is no ``...2`` overload of this
|
|
3096
|
+
call. Everything it acts on must be pre-selected under the right mark, so
|
|
3097
|
+
the marks are the whole game - see ``_MIRROR_MARK_*``.
|
|
3098
|
+
|
|
3099
|
+
Whether the mirror actually produced geometry is checked by the caller,
|
|
3100
|
+
which can read volume through ``get_mass_properties``.
|
|
3101
|
+
|
|
3102
|
+
Args:
|
|
3103
|
+
adapter: A connected ``PyWin32Adapter`` with a valid ``currentModel``.
|
|
3104
|
+
features: Body or feature names to mirror.
|
|
3105
|
+
mirror_plane: Plane name to mirror about.
|
|
3106
|
+
merge: Merge the result with the original.
|
|
3107
|
+
mirror_bodies: Select and mirror whole bodies rather than features.
|
|
3108
|
+
|
|
3109
|
+
Returns:
|
|
3110
|
+
AdapterResult[dict[str, Any]]: The mirror feature's name and inputs.
|
|
3111
|
+
|
|
3112
|
+
Raises:
|
|
3113
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
3114
|
+
"""
|
|
3115
|
+
if not adapter.currentModel:
|
|
3116
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
3117
|
+
|
|
3118
|
+
names = [n for n in (features or []) if n]
|
|
3119
|
+
if not names:
|
|
3120
|
+
return AdapterResult(
|
|
3121
|
+
status=AdapterResultStatus.ERROR,
|
|
3122
|
+
error="mirror_feature requires at least one body or feature name",
|
|
3123
|
+
)
|
|
3124
|
+
if not mirror_plane:
|
|
3125
|
+
return AdapterResult(
|
|
3126
|
+
status=AdapterResultStatus.ERROR,
|
|
3127
|
+
error="mirror_feature requires a mirror plane name",
|
|
3128
|
+
)
|
|
3129
|
+
|
|
3130
|
+
source_mark = _MIRROR_MARK_BODIES if mirror_bodies else _MIRROR_MARK_FEATURES
|
|
3131
|
+
entity_type = "SOLIDBODY" if mirror_bodies else None
|
|
3132
|
+
|
|
3133
|
+
def _mirror_operation() -> dict[str, Any]:
|
|
3134
|
+
adapter._attempt(
|
|
3135
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
3136
|
+
)
|
|
3137
|
+
callout = _null_callout()
|
|
3138
|
+
|
|
3139
|
+
for index, name in enumerate(names):
|
|
3140
|
+
append = index > 0
|
|
3141
|
+
selected = False
|
|
3142
|
+
if entity_type is not None:
|
|
3143
|
+
selected = bool(
|
|
3144
|
+
adapter._attempt(
|
|
3145
|
+
lambda n=name, a=append: (
|
|
3146
|
+
adapter.currentModel.Extension.SelectByID2(
|
|
3147
|
+
n,
|
|
3148
|
+
entity_type,
|
|
3149
|
+
0.0,
|
|
3150
|
+
0.0,
|
|
3151
|
+
0.0,
|
|
3152
|
+
a,
|
|
3153
|
+
source_mark,
|
|
3154
|
+
callout,
|
|
3155
|
+
0,
|
|
3156
|
+
)
|
|
3157
|
+
),
|
|
3158
|
+
default=False,
|
|
3159
|
+
)
|
|
3160
|
+
)
|
|
3161
|
+
if not selected:
|
|
3162
|
+
selected = _select_named_feature(
|
|
3163
|
+
adapter, name, source_mark, append=append
|
|
3164
|
+
)
|
|
3165
|
+
if not selected:
|
|
3166
|
+
kind = "body" if mirror_bodies else "feature"
|
|
3167
|
+
raise Exception(f"Failed to select {kind} to mirror: {name}")
|
|
3168
|
+
|
|
3169
|
+
if not _select_reference_entity(
|
|
3170
|
+
adapter, mirror_plane, _MIRROR_MARK_PLANE, append=True
|
|
3171
|
+
):
|
|
3172
|
+
raise Exception(f"Failed to select mirror plane: {mirror_plane}")
|
|
3173
|
+
|
|
3174
|
+
feature_manager = sw_type_info.flagged(
|
|
3175
|
+
adapter.currentModel.FeatureManager, "IFeatureManager"
|
|
3176
|
+
)
|
|
3177
|
+
feature = adapter._attempt(
|
|
3178
|
+
lambda: feature_manager.InsertMirrorFeature(
|
|
3179
|
+
bool(mirror_bodies), # BMirrorBody
|
|
3180
|
+
False, # BGeometryPattern - solve the whole feature
|
|
3181
|
+
bool(merge), # BMerge
|
|
3182
|
+
False, # BKnit
|
|
3183
|
+
),
|
|
3184
|
+
default=None,
|
|
3185
|
+
)
|
|
3186
|
+
if feature is None:
|
|
3187
|
+
raise Exception(
|
|
3188
|
+
f"InsertMirrorFeature returned nothing (sources={names}, "
|
|
3189
|
+
f"plane={mirror_plane})"
|
|
3190
|
+
)
|
|
3191
|
+
|
|
3192
|
+
name = adapter._attempt(
|
|
3193
|
+
lambda: adapter._get_attr_or_call(feature, "Name"), default=None
|
|
3194
|
+
)
|
|
3195
|
+
return {
|
|
3196
|
+
# No invented fallback: an unreadable name is reported as None
|
|
3197
|
+
# rather than as the literal "Mirror", which would not resolve.
|
|
3198
|
+
"name": str(name) if name else None,
|
|
3199
|
+
"mirrored": names,
|
|
3200
|
+
"mirror_plane": mirror_plane,
|
|
3201
|
+
"merge": bool(merge),
|
|
3202
|
+
"mirror_bodies": bool(mirror_bodies),
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
return cast(
|
|
3206
|
+
AdapterResult[dict[str, Any]],
|
|
3207
|
+
adapter._handle_com_operation("mirror_feature", _mirror_operation),
|
|
3208
|
+
)
|
|
3209
|
+
|
|
3210
|
+
|
|
3211
|
+
#: Selection marks ``FeatureCircularPattern5`` expects, per the SolidWorks API
|
|
3212
|
+
#: reference: the direction axis is mark 1 and the features to pattern are
|
|
3213
|
+
#: mark 4. These differ from the mirror marks - reusing those silently
|
|
3214
|
+
#: produces a feature that patterns nothing.
|
|
3215
|
+
_PATTERN_MARK_AXIS = 1
|
|
3216
|
+
_PATTERN_MARK_FEATURES = 4
|
|
3217
|
+
|
|
3218
|
+
|
|
3219
|
+
def _pattern_circular_impl(
|
|
3220
|
+
adapter: Any,
|
|
3221
|
+
features: list[str],
|
|
3222
|
+
axis: str,
|
|
3223
|
+
count: int,
|
|
3224
|
+
angle: float,
|
|
3225
|
+
equal_spacing: bool,
|
|
3226
|
+
) -> AdapterResult[dict[str, Any]]:
|
|
3227
|
+
"""Select the axis and features, then call FeatureCircularPattern5.
|
|
3228
|
+
|
|
3229
|
+
This build exposes FeatureCircularPattern through ...5; v5 is used, with
|
|
3230
|
+
the 14 arguments it declares. ``DName`` is a direction *name string*, so
|
|
3231
|
+
the axis is both selected under mark 1 and named in the call.
|
|
3232
|
+
|
|
3233
|
+
Whether anything was produced is checked by the caller, which can read
|
|
3234
|
+
volume through ``get_mass_properties``.
|
|
3235
|
+
|
|
3236
|
+
Args:
|
|
3237
|
+
adapter: A connected ``PyWin32Adapter`` with a valid ``currentModel``.
|
|
3238
|
+
features: Feature names to pattern.
|
|
3239
|
+
axis: Axis name to rotate about.
|
|
3240
|
+
count: Total instances including the original.
|
|
3241
|
+
angle: Total spread in degrees.
|
|
3242
|
+
equal_spacing: Space instances evenly across ``angle``.
|
|
3243
|
+
|
|
3244
|
+
Returns:
|
|
3245
|
+
AdapterResult[dict[str, Any]]: The pattern's name and inputs.
|
|
3246
|
+
|
|
3247
|
+
Raises:
|
|
3248
|
+
Exception: Propagated through ``_handle_com_operation``.
|
|
3249
|
+
"""
|
|
3250
|
+
if not adapter.currentModel:
|
|
3251
|
+
return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
|
|
3252
|
+
|
|
3253
|
+
names = [n for n in (features or []) if n]
|
|
3254
|
+
if not names:
|
|
3255
|
+
return AdapterResult(
|
|
3256
|
+
status=AdapterResultStatus.ERROR,
|
|
3257
|
+
error="pattern_circular requires at least one feature name",
|
|
3258
|
+
)
|
|
3259
|
+
if not axis:
|
|
3260
|
+
return AdapterResult(
|
|
3261
|
+
status=AdapterResultStatus.ERROR,
|
|
3262
|
+
error="pattern_circular requires an axis name to rotate about",
|
|
3263
|
+
)
|
|
3264
|
+
if int(count) < 2:
|
|
3265
|
+
return AdapterResult(
|
|
3266
|
+
status=AdapterResultStatus.ERROR,
|
|
3267
|
+
error=(
|
|
3268
|
+
f"pattern_circular needs a count of at least 2 (got {count}); "
|
|
3269
|
+
"a pattern of one is just the original feature"
|
|
3270
|
+
),
|
|
3271
|
+
)
|
|
3272
|
+
|
|
3273
|
+
def _pattern_operation() -> dict[str, Any]:
|
|
3274
|
+
import math
|
|
3275
|
+
|
|
3276
|
+
adapter._attempt(
|
|
3277
|
+
lambda: adapter.currentModel.ClearSelection2(True), default=None
|
|
3278
|
+
)
|
|
3279
|
+
callout = _null_callout()
|
|
3280
|
+
|
|
3281
|
+
axis_selected = bool(
|
|
3282
|
+
adapter._attempt(
|
|
3283
|
+
lambda: adapter.currentModel.Extension.SelectByID2(
|
|
3284
|
+
axis, "AXIS", 0.0, 0.0, 0.0, False, _PATTERN_MARK_AXIS, callout, 0
|
|
3285
|
+
),
|
|
3286
|
+
default=False,
|
|
3287
|
+
)
|
|
3288
|
+
)
|
|
3289
|
+
if not axis_selected:
|
|
3290
|
+
raise Exception(
|
|
3291
|
+
f"Failed to select axis '{axis}'. Create one with create_axis "
|
|
3292
|
+
"first, and pass the name it returns."
|
|
3293
|
+
)
|
|
3294
|
+
|
|
3295
|
+
for name in names:
|
|
3296
|
+
selected = bool(
|
|
3297
|
+
adapter._attempt(
|
|
3298
|
+
lambda n=name: adapter.currentModel.Extension.SelectByID2(
|
|
3299
|
+
n,
|
|
3300
|
+
"BODYFEATURE",
|
|
3301
|
+
0.0,
|
|
3302
|
+
0.0,
|
|
3303
|
+
0.0,
|
|
3304
|
+
True,
|
|
3305
|
+
_PATTERN_MARK_FEATURES,
|
|
3306
|
+
callout,
|
|
3307
|
+
0,
|
|
3308
|
+
),
|
|
3309
|
+
default=False,
|
|
3310
|
+
)
|
|
3311
|
+
)
|
|
3312
|
+
if not selected:
|
|
3313
|
+
selected = _select_named_feature(
|
|
3314
|
+
adapter, name, _PATTERN_MARK_FEATURES, append=True
|
|
3315
|
+
)
|
|
3316
|
+
if not selected:
|
|
3317
|
+
raise Exception(f"Failed to select feature to pattern: {name}")
|
|
3318
|
+
|
|
3319
|
+
feature_manager = sw_type_info.flagged(
|
|
3320
|
+
adapter.currentModel.FeatureManager, "IFeatureManager"
|
|
3321
|
+
)
|
|
3322
|
+
feature = adapter._attempt(
|
|
3323
|
+
lambda: feature_manager.FeatureCircularPattern5(
|
|
3324
|
+
int(count), # Number
|
|
3325
|
+
math.radians(float(angle)), # Spacing (radians)
|
|
3326
|
+
False, # FlipDirection
|
|
3327
|
+
axis, # DName
|
|
3328
|
+
False, # GeometryPattern
|
|
3329
|
+
bool(equal_spacing), # EqualSpacing
|
|
3330
|
+
False, # VaryInstance
|
|
3331
|
+
False, # SyncSubAssemblies
|
|
3332
|
+
False, # BDir2
|
|
3333
|
+
False, # BSymmetric
|
|
3334
|
+
0, # Number2
|
|
3335
|
+
0.0, # Spacing2
|
|
3336
|
+
"", # DName2
|
|
3337
|
+
False, # EqualSpacing2
|
|
3338
|
+
),
|
|
3339
|
+
default=None,
|
|
3340
|
+
)
|
|
3341
|
+
if feature is None:
|
|
3342
|
+
raise Exception(
|
|
3343
|
+
f"FeatureCircularPattern5 returned nothing (features={names}, "
|
|
3344
|
+
f"axis={axis})"
|
|
3345
|
+
)
|
|
3346
|
+
|
|
3347
|
+
pattern_name = adapter._attempt(
|
|
3348
|
+
lambda: adapter._get_attr_or_call(feature, "Name"), default=None
|
|
3349
|
+
)
|
|
3350
|
+
return {
|
|
3351
|
+
"name": str(pattern_name) if pattern_name else None,
|
|
3352
|
+
"patterned": names,
|
|
3353
|
+
"axis": axis,
|
|
3354
|
+
"count": int(count),
|
|
3355
|
+
"angle": float(angle),
|
|
3356
|
+
"equal_spacing": bool(equal_spacing),
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
return cast(
|
|
3360
|
+
AdapterResult[dict[str, Any]],
|
|
3361
|
+
adapter._handle_com_operation("pattern_circular", _pattern_operation),
|
|
3362
|
+
)
|