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

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