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,2545 @@
1
+ """Sketch-domain mixin for PyWin32 SolidWorks operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import sys
7
+ import time
8
+ from typing import Any, cast
9
+
10
+ from .. import sw_type_info as _sw_type_info
11
+ from ..base import AdapterResult, AdapterResultStatus
12
+
13
+ # swConstraintType_e values per the official SolidWorks API enum docs
14
+ # (SolidWorks.Interop.swconst). The legacy IModelDoc2.SketchAddConstraints
15
+ # API takes string identifiers but on SW 2026/3DEXPERIENCE silently no-ops
16
+ # without adding the relation. ISketchRelationManager.AddRelation takes
17
+ # these integer enum values and works reliably.
18
+ RELATION_NAME_MAP: dict[str, int] = {
19
+ "horizontal": 4,
20
+ "vertical": 5,
21
+ "tangent": 6,
22
+ "parallel": 7,
23
+ "perpendicular": 8,
24
+ "coincident": 9,
25
+ "concentric": 10,
26
+ "symmetric": 11, # swConstraintType_SYMMETRIC — requires entity3 (centerline)
27
+ "equal": 14, # swConstraintType_SAMELENGTH
28
+ "fix": 17, # swConstraintType_FIXED
29
+ "collinear": 27, # swConstraintType_COLINEAR (single-l spelling)
30
+ }
31
+
32
+ # Relations that take a third entity (the centerline of symmetry for now).
33
+ # All other relations reject a non-null ``entity3``.
34
+ _THREE_ENTITY_RELATIONS: frozenset[str] = frozenset({"symmetric"})
35
+
36
+
37
+ class SolidWorksSketchMixin:
38
+ """Expose sketch creation and editing methods via mixin-local implementation."""
39
+
40
+ @staticmethod
41
+ def _adapter(obj: Any) -> Any:
42
+ """Return the runtime adapter object for dynamic attribute access."""
43
+ return cast(Any, obj)
44
+
45
+ def _point_xyz(self, point_obj: Any) -> tuple[float, float, float] | None:
46
+ adapter = self._adapter(self)
47
+ return cast(
48
+ tuple[float, float, float] | None,
49
+ adapter._sketch_geometry.point_xyz(point_obj),
50
+ )
51
+
52
+ def _set_point_xyz(self, point_obj: Any, x: float, y: float, z: float) -> bool:
53
+ adapter = self._adapter(self)
54
+ return cast(bool, adapter._sketch_geometry.set_point_xyz(point_obj, x, y, z))
55
+
56
+ def _read_segment_endpoints(
57
+ self, entity: Any
58
+ ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None:
59
+ adapter = self._adapter(self)
60
+ return cast(
61
+ tuple[tuple[float, float, float], tuple[float, float, float]] | None,
62
+ adapter._sketch_geometry.read_segment_endpoints(entity),
63
+ )
64
+
65
+ def _segment_point_objects(self, entity: Any) -> tuple[Any | None, Any | None]:
66
+ adapter = self._adapter(self)
67
+ return cast(
68
+ tuple[Any | None, Any | None],
69
+ adapter._sketch_geometry.segment_point_objects(entity),
70
+ )
71
+
72
+ def _shared_segment_vertex(
73
+ self, entity1: Any, entity2: Any
74
+ ) -> tuple[Any, Any, Any] | None:
75
+ adapter = self._adapter(self)
76
+ return cast(
77
+ tuple[Any, Any, Any] | None,
78
+ adapter._sketch_geometry.shared_segment_vertex(entity1, entity2),
79
+ )
80
+
81
+ def _smart_dimension_direction(self, dx: float, dy: float) -> int:
82
+ adapter = self._adapter(self)
83
+ return cast(int, adapter._sketch_geometry.smart_dimension_direction(dx, dy))
84
+
85
+ def _single_line_dimension_placement(
86
+ self, entity: Any
87
+ ) -> tuple[float, float, float, int] | None:
88
+ adapter = self._adapter(self)
89
+ return cast(
90
+ tuple[float, float, float, int] | None,
91
+ adapter._sketch_geometry.single_line_dimension_placement(entity),
92
+ )
93
+
94
+ def _angular_dimension_placement(
95
+ self, entity1: Any, entity2: Any
96
+ ) -> tuple[float, float, float, int] | None:
97
+ adapter = self._adapter(self)
98
+ return cast(
99
+ tuple[float, float, float, int] | None,
100
+ adapter._sketch_geometry.angular_dimension_placement(entity1, entity2),
101
+ )
102
+
103
+ async def create_sketch(self, plane: str) -> AdapterResult[str]:
104
+ return _create_sketch_impl(self, plane)
105
+
106
+ async def add_line(
107
+ self, x1: float, y1: float, x2: float, y2: float
108
+ ) -> AdapterResult[str]:
109
+ return _add_line_impl(self, x1, y1, x2, y2)
110
+
111
+ async def add_circle(
112
+ self, center_x: float, center_y: float, radius: float
113
+ ) -> AdapterResult[str]:
114
+ return _add_circle_impl(self, center_x, center_y, radius)
115
+
116
+ async def add_rectangle(
117
+ self, x1: float, y1: float, x2: float, y2: float
118
+ ) -> AdapterResult[str]:
119
+ return _add_rectangle_impl(self, x1, y1, x2, y2)
120
+
121
+ async def add_arc(
122
+ self,
123
+ center_x: float,
124
+ center_y: float,
125
+ start_x: float,
126
+ start_y: float,
127
+ end_x: float,
128
+ end_y: float,
129
+ ) -> AdapterResult[str]:
130
+ return _add_arc_impl(self, center_x, center_y, start_x, start_y, end_x, end_y)
131
+
132
+ async def add_spline(self, points: list[dict[str, float]]) -> AdapterResult[str]:
133
+ return _add_spline_impl(self, points)
134
+
135
+ async def add_centerline(
136
+ self, x1: float, y1: float, x2: float, y2: float
137
+ ) -> AdapterResult[str]:
138
+ return _add_centerline_impl(self, x1, y1, x2, y2)
139
+
140
+ async def add_polygon(
141
+ self, center_x: float, center_y: float, radius: float, sides: int
142
+ ) -> AdapterResult[str]:
143
+ return _add_polygon_impl(self, center_x, center_y, radius, sides)
144
+
145
+ async def add_ellipse(
146
+ self, center_x: float, center_y: float, major_axis: float, minor_axis: float
147
+ ) -> AdapterResult[str]:
148
+ return _add_ellipse_impl(self, center_x, center_y, major_axis, minor_axis)
149
+
150
+ async def add_sketch_constraint(
151
+ self,
152
+ entity1: str,
153
+ entity2: str | None,
154
+ relation_type: str,
155
+ entity3: str | None = None,
156
+ ) -> AdapterResult[str]:
157
+ return _add_sketch_constraint_impl(
158
+ self, entity1, entity2, relation_type, entity3
159
+ )
160
+
161
+ async def add_sketch_dimension(
162
+ self, entity1: str, entity2: str | None, dimension_type: str, value: float
163
+ ) -> AdapterResult[str]:
164
+ return _add_sketch_dimension_impl(self, entity1, entity2, dimension_type, value)
165
+
166
+ async def sketch_linear_pattern(
167
+ self,
168
+ entities: list[str],
169
+ direction_x: float,
170
+ direction_y: float,
171
+ spacing: float,
172
+ count: int,
173
+ ) -> AdapterResult[str]:
174
+ return _sketch_linear_pattern_impl(
175
+ self, entities, direction_x, direction_y, spacing, count
176
+ )
177
+
178
+ async def sketch_circular_pattern(
179
+ self,
180
+ entities: list[str],
181
+ angle: float,
182
+ count: int,
183
+ ) -> AdapterResult[str]:
184
+ return _sketch_circular_pattern_impl(self, entities, angle, count)
185
+
186
+ async def sketch_mirror(
187
+ self, entities: list[str], mirror_line: str
188
+ ) -> AdapterResult[str]:
189
+ return _sketch_mirror_impl(self, entities, mirror_line)
190
+
191
+ async def sketch_offset(
192
+ self, entities: list[str], offset_distance: float, reverse_direction: bool
193
+ ) -> AdapterResult[str]:
194
+ return _sketch_offset_impl(self, entities, offset_distance, reverse_direction)
195
+
196
+ async def exit_sketch(self) -> AdapterResult[None]:
197
+ return _exit_sketch_impl(self)
198
+
199
+ async def check_sketch_fully_defined(
200
+ self, sketch_name: str | None = None
201
+ ) -> AdapterResult[dict[str, Any]]:
202
+ return _check_sketch_fully_defined_impl(self, sketch_name)
203
+
204
+
205
+ def _create_sketch_impl(adapter: Any, plane: str) -> AdapterResult[str]:
206
+ """Open a new sketch on a named reference plane.
207
+
208
+ The function resolves English short-hand names (``"Top"``, ``"Front"``,
209
+ ``"Right"``, ``"XY"``, ``"XZ"``, ``"YZ"``) to their full SolidWorks plane
210
+ names and also tries Spanish locale aliases (``"Planta"``, ``"Alzado"``,
211
+ ``"Vista lateral"``). If named lookup fails, ``Extension.SelectByID2``
212
+ with entity type ``"PLANE"`` is attempted with three different callout
213
+ variants.
214
+
215
+ When the sketch is successfully opened, ``adapter.currentSketchManager``,
216
+ ``adapter.currentSketch``, ``adapter._sketch_count``, and
217
+ ``adapter._last_sketch_name`` are updated, and ``ISketchManager.AddToDB``
218
+ is set so entities land at exactly the requested coordinates
219
+ (``exit_sketch`` clears it again).
220
+
221
+ Args:
222
+ adapter: A fully connected ``PyWin32Adapter`` with a non-``None``
223
+ ``currentModel``.
224
+ plane: Reference-plane identifier. Accepts short English names
225
+ (``"Top"``, ``"Front"``, ``"Right"``, ``"XY"``, ``"XZ"``,
226
+ ``"YZ"``) or the exact SolidWorks plane name as it appears in
227
+ the FeatureManager tree.
228
+
229
+ Returns:
230
+ AdapterResult[str]: On success, ``data`` is the sketch name string
231
+ (e.g. ``"Sketch1"``). On failure, ``status`` is ``ERROR``.
232
+
233
+ Raises:
234
+ Exception: Propagated through ``_handle_com_operation`` when no plane
235
+ candidate can be selected.
236
+
237
+ Example::
238
+
239
+ from solidworks_mcp.adapters import pywin32_sketch_ops
240
+
241
+ result = pywin32_sketch_ops.create_sketch(adapter, "Front")
242
+ print(result.data) # "Sketch1"
243
+ """
244
+ if not adapter.currentModel:
245
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
246
+
247
+ def _sketch_operation() -> str:
248
+ """Inner COM closure that selects the plane and opens the sketch.
249
+
250
+ Iterates through all locale aliases and ``SelectByID2`` fallbacks.
251
+ Updates adapter sketch state on success.
252
+
253
+ Returns:
254
+ str: The resolved SolidWorks sketch name.
255
+
256
+ Raises:
257
+ Exception: If no plane candidate could be selected.
258
+ """
259
+ plane_name_map = {
260
+ "Top": "Top Plane",
261
+ "Front": "Front Plane",
262
+ "Right": "Right Plane",
263
+ "XY": "Top Plane",
264
+ "XZ": "Front Plane",
265
+ "YZ": "Right Plane",
266
+ }
267
+
268
+ semantic_plane_aliases = {
269
+ "Top": ["Top Plane", "Planta", "上视基准面", "上視基準面"],
270
+ "Front": ["Front Plane", "Alzado", "前视基准面", "前視基準面"],
271
+ "Right": ["Right Plane", "Vista lateral", "右视基准面", "右視基準面"],
272
+ "XY": ["Top Plane", "Planta", "上视基准面"],
273
+ "XZ": ["Front Plane", "Alzado", "前视基准面"],
274
+ "YZ": ["Right Plane", "Vista lateral", "右视基准面"],
275
+ }
276
+
277
+ actual_plane = plane_name_map.get(plane, plane)
278
+ selected = False
279
+ selection_error = None
280
+
281
+ plane_candidates = [
282
+ *semantic_plane_aliases.get(plane, []),
283
+ actual_plane,
284
+ plane,
285
+ "Top Plane",
286
+ "Front Plane",
287
+ "Right Plane",
288
+ "Planta",
289
+ "Alzado",
290
+ "Vista lateral",
291
+ "上视基准面",
292
+ "前视基准面",
293
+ "右视基准面",
294
+ "上視基準面",
295
+ "前視基準面",
296
+ "右視基準面",
297
+ ]
298
+ for candidate in plane_candidates:
299
+ if not candidate:
300
+ continue
301
+ plane_feature, selection_error_candidate = adapter._attempt_with_error(
302
+ lambda c=candidate: adapter.currentModel.FeatureByName(c)
303
+ )
304
+ if selection_error_candidate:
305
+ selection_error = selection_error_candidate
306
+ continue
307
+ selected = bool(
308
+ plane_feature
309
+ and adapter._attempt(
310
+ lambda pf=plane_feature: pf.Select2(False, 0), default=False
311
+ )
312
+ )
313
+ if selected:
314
+ break
315
+
316
+ if not selected:
317
+ # SW 2022: callout must be None (empty string causes type mismatch)
318
+ selected, selection_error_candidate = adapter._attempt_with_error(
319
+ lambda: adapter.currentModel.Extension.SelectByID2(
320
+ actual_plane,
321
+ "PLANE",
322
+ 0,
323
+ 0,
324
+ 0,
325
+ False,
326
+ 0,
327
+ None,
328
+ 0,
329
+ )
330
+ )
331
+ if selection_error_candidate:
332
+ selection_error = selection_error_candidate
333
+ elif selected:
334
+ pass
335
+
336
+ if not selected:
337
+ if selection_error:
338
+ raise Exception(
339
+ f"Failed to select plane: {actual_plane} ({selection_error})"
340
+ )
341
+ raise Exception(f"Failed to select plane: {actual_plane}")
342
+
343
+ adapter.currentSketchManager = adapter.currentModel.SketchManager
344
+ adapter._attempt(
345
+ lambda: _sw_type_info.flag_methods(
346
+ adapter.currentSketchManager, "ISketchManager"
347
+ ),
348
+ default=0,
349
+ )
350
+ adapter._reset_sketch_entity_registry()
351
+ try:
352
+ adapter.currentSketch = adapter.currentSketchManager.InsertSketch(True)
353
+ except Exception:
354
+ adapter.currentSketch = adapter.currentSketchManager.InsertSketch()
355
+ # Without AddToDB every Create* point snaps to nearby inferred
356
+ # alignments: an end at x=5.2915 landed on x=5 (vertical with an
357
+ # earlier point at x=5) and a 22 mm ring came out 23 mm. Writing
358
+ # straight to the sketch database keeps the requested coordinates.
359
+ # DisplayWhenAdded stays on: it only defers drawing, which would hide
360
+ # entities from a user watching SolidWorks until the next redraw.
361
+ adapter.currentSketchManager.AddToDB = True
362
+
363
+ if not (adapter.currentSketch and hasattr(adapter.currentSketch, "Name")):
364
+ # ISketchManager.ActiveSketch is the documented property for
365
+ # "whatever sketch is open right now" — more reliable than
366
+ # trusting InsertSketch's return value, which some SW versions
367
+ # return as an empty Sub result. Try it before the GetActiveSketch2
368
+ # method fallbacks: a bare ``swApp.ActiveDoc`` is not flagged, so
369
+ # its ``GetActiveSketch2`` resolves to a non-callable and silently
370
+ # yields ``None`` — which forces the synthetic ``Sketch_N``
371
+ # fallback name and makes the sketch unselectable by later
372
+ # features (create_cut_extrude included).
373
+ adapter.currentSketch = (
374
+ adapter._attempt(lambda: adapter.currentSketchManager.ActiveSketch)
375
+ or adapter._attempt(lambda: adapter.currentModel.GetActiveSketch2())
376
+ or adapter._attempt(lambda: adapter.swApp.ActiveDoc.GetActiveSketch2())
377
+ )
378
+
379
+ adapter._sketch_count += 1
380
+
381
+ if adapter.currentSketch and hasattr(adapter.currentSketch, "Name"):
382
+ sketch_name = str(adapter.currentSketch.Name)
383
+ adapter._last_sketch_name = sketch_name
384
+ return sketch_name
385
+
386
+ sketch_name = f"Sketch_{adapter._sketch_count}"
387
+ adapter._last_sketch_name = sketch_name
388
+ return sketch_name
389
+
390
+ return cast(
391
+ AdapterResult[str],
392
+ adapter._handle_com_operation("create_sketch", _sketch_operation),
393
+ )
394
+
395
+
396
+ def _add_line_impl(
397
+ adapter: Any, x1: float, y1: float, x2: float, y2: float
398
+ ) -> AdapterResult[str]:
399
+ """Add a straight line segment to the active sketch.
400
+
401
+ Calls ``SketchManager.CreateLine`` and registers the resulting entity in
402
+ the adapter\'s internal sketch-entity registry so it can be referenced by
403
+ subsequent dimension and constraint calls.
404
+
405
+ Args:
406
+ adapter: A ``PyWin32Adapter`` with an open sketch
407
+ (``currentSketchManager`` must be non-``None``).
408
+ x1: Start X coordinate in **millimetres**.
409
+ y1: Start Y coordinate in **millimetres**.
410
+ x2: End X coordinate in **millimetres**.
411
+ y2: End Y coordinate in **millimetres**.
412
+
413
+ Returns:
414
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
415
+ string (e.g. ``"Line_1"``). On failure, ``status`` is ``ERROR``.
416
+
417
+ Raises:
418
+ Exception: Propagated through ``_handle_com_operation`` when
419
+ ``CreateLine`` returns ``None``.
420
+
421
+ Example::
422
+
423
+ result = pywin32_sketch_ops.add_line(adapter, 0, 0, 50, 0)
424
+ print(result.data) # "Line_1"
425
+ """
426
+ if not adapter.currentSketchManager:
427
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
428
+
429
+ def _line_operation() -> str:
430
+ """Inner COM closure that calls CreateLine and registers the entity.
431
+
432
+ Returns:
433
+ str: Registered entity ID for the new line.
434
+
435
+ Raises:
436
+ Exception: If ``CreateLine`` returns ``None``.
437
+ """
438
+ line = adapter.currentSketchManager.CreateLine(
439
+ x1 / 1000.0, y1 / 1000.0, 0, x2 / 1000.0, y2 / 1000.0, 0
440
+ )
441
+ if not line:
442
+ raise Exception("Failed to create line")
443
+ entity_id: str = adapter._register_sketch_entity("Line", line)
444
+ # The midpoint, in mm: where a dimension to this line is placed.
445
+ adapter._sketch_entity_centers[entity_id] = ((x1 + x2) / 2.0, (y1 + y2) / 2.0)
446
+ return entity_id
447
+
448
+ return cast(
449
+ AdapterResult[str], adapter._handle_com_operation("add_line", _line_operation)
450
+ )
451
+
452
+
453
+ def _add_circle_impl(
454
+ adapter: Any, center_x: float, center_y: float, radius: float
455
+ ) -> AdapterResult[str]:
456
+ """Add a circle to the active sketch defined by centre and radius.
457
+
458
+ Calls ``SketchManager.CreateCircleByRadius`` and registers the entity.
459
+
460
+ Args:
461
+ adapter: A ``PyWin32Adapter`` with an open sketch.
462
+ center_x: Circle centre X coordinate in **millimetres**.
463
+ center_y: Circle centre Y coordinate in **millimetres**.
464
+ radius: Circle radius in **millimetres**.
465
+
466
+ Returns:
467
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
468
+ (e.g. ``"Circle_2"``). On failure, ``status`` is ``ERROR``.
469
+
470
+ Raises:
471
+ Exception: Propagated through ``_handle_com_operation`` when
472
+ ``CreateCircleByRadius`` returns ``None``.
473
+
474
+ Example::
475
+
476
+ result = pywin32_sketch_ops.add_circle(adapter, 25.0, 0.0, 10.0)
477
+ print(result.data) # "Circle_2"
478
+ """
479
+ if not adapter.currentSketchManager:
480
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
481
+
482
+ def _circle_operation() -> str:
483
+ """Inner COM closure that calls CreateCircleByRadius and registers the entity.
484
+
485
+ Returns:
486
+ str: Registered entity ID for the new circle.
487
+
488
+ Raises:
489
+ Exception: If ``CreateCircleByRadius`` returns ``None``.
490
+ """
491
+ circle = adapter.currentSketchManager.CreateCircleByRadius(
492
+ center_x / 1000.0, center_y / 1000.0, 0, radius / 1000.0
493
+ )
494
+ if not circle:
495
+ raise Exception("Failed to create circle")
496
+ entity_id: str = adapter._register_sketch_entity("Circle", circle)
497
+ # The centre, in mm: where a dimension to this circle is placed.
498
+ adapter._sketch_entity_centers[entity_id] = (center_x, center_y)
499
+ return entity_id
500
+
501
+ return cast(
502
+ AdapterResult[str],
503
+ adapter._handle_com_operation("add_circle", _circle_operation),
504
+ )
505
+
506
+
507
+ def _add_rectangle_impl(
508
+ adapter: Any, x1: float, y1: float, x2: float, y2: float
509
+ ) -> AdapterResult[str]:
510
+ """Add a corner-defined rectangle to the active sketch.
511
+
512
+ Calls ``SketchManager.CreateCornerRectangle`` which returns an array of
513
+ four line entities. The array is registered as a single composite entity.
514
+
515
+ Args:
516
+ adapter: A ``PyWin32Adapter`` with an open sketch.
517
+ x1: First corner X coordinate in **millimetres**.
518
+ y1: First corner Y coordinate in **millimetres**.
519
+ x2: Opposite corner X coordinate in **millimetres**.
520
+ y2: Opposite corner Y coordinate in **millimetres**.
521
+
522
+ Returns:
523
+ AdapterResult[str]: On success, ``data`` is the composite entity ID
524
+ (e.g. ``"Rectangle_3"``). On failure, ``status`` is ``ERROR``.
525
+
526
+ Raises:
527
+ Exception: Propagated through ``_handle_com_operation`` when
528
+ ``CreateCornerRectangle`` returns ``None``.
529
+
530
+ Example::
531
+
532
+ result = pywin32_sketch_ops.add_rectangle(adapter, 0, 0, 40, 20)
533
+ print(result.data) # "Rectangle_3"
534
+ """
535
+ if not adapter.currentSketchManager:
536
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
537
+
538
+ def _rectangle_operation() -> str:
539
+ """Inner COM closure that calls CreateCornerRectangle and registers the entity.
540
+
541
+ Returns:
542
+ str: Registered entity ID for the new rectangle.
543
+
544
+ Raises:
545
+ Exception: If ``CreateCornerRectangle`` returns ``None``.
546
+ """
547
+ lines = adapter.currentSketchManager.CreateCornerRectangle(
548
+ x1 / 1000.0, y1 / 1000.0, 0, x2 / 1000.0, y2 / 1000.0, 0
549
+ )
550
+ if not lines:
551
+ raise Exception("Failed to create rectangle")
552
+ entity_id = cast(str, adapter._register_sketch_entity("Rectangle", lines))
553
+ # Like polygons, rectangles register as a SAFEARRAY tuple of segment
554
+ # handles — no single dispatch ``GetCenterPoint`` to recover the
555
+ # geometric centre from later. Stash it now so the rectangle ID is a
556
+ # valid seed for ``sketch_circular_pattern``.
557
+ adapter._sketch_entity_centers[entity_id] = (
558
+ (x1 + x2) / 2.0,
559
+ (y1 + y2) / 2.0,
560
+ )
561
+ return entity_id
562
+
563
+ return cast(
564
+ AdapterResult[str],
565
+ adapter._handle_com_operation("add_rectangle", _rectangle_operation),
566
+ )
567
+
568
+
569
+ def _add_arc_impl(
570
+ adapter: Any,
571
+ center_x: float,
572
+ center_y: float,
573
+ start_x: float,
574
+ start_y: float,
575
+ end_x: float,
576
+ end_y: float,
577
+ ) -> AdapterResult[str]:
578
+ """Add a circular arc to the active sketch.
579
+
580
+ Calls ``SketchManager.CreateArc`` with a counter-clockwise direction
581
+ flag (``1``). All coordinates must lie in the sketch plane; the Z
582
+ component is always forced to ``0``.
583
+
584
+ Args:
585
+ adapter: A ``PyWin32Adapter`` with an open sketch.
586
+ center_x: Arc centre X in **millimetres**.
587
+ center_y: Arc centre Y in **millimetres**.
588
+ start_x: Arc start point X in **millimetres**.
589
+ start_y: Arc start point Y in **millimetres**.
590
+ end_x: Arc end point X in **millimetres**.
591
+ end_y: Arc end point Y in **millimetres**.
592
+
593
+ Returns:
594
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
595
+ (e.g. ``"Arc_4"``). On failure, ``status`` is ``ERROR``.
596
+
597
+ Raises:
598
+ Exception: Propagated through ``_handle_com_operation`` when
599
+ ``CreateArc`` returns ``None``.
600
+
601
+ Example::
602
+
603
+ result = pywin32_sketch_ops.add_arc(
604
+ adapter,
605
+ center_x=0, center_y=0,
606
+ start_x=10, start_y=0,
607
+ end_x=0, end_y=10,
608
+ )
609
+ print(result.data) # "Arc_4"
610
+ """
611
+ if not adapter.currentSketchManager:
612
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
613
+
614
+ def _arc_operation() -> str:
615
+ """Inner COM closure that calls CreateArc and registers the entity.
616
+
617
+ Returns:
618
+ str: Registered entity ID for the new arc.
619
+
620
+ Raises:
621
+ Exception: If ``CreateArc`` returns ``None``.
622
+ """
623
+ arc = adapter.currentSketchManager.CreateArc(
624
+ center_x / 1000.0,
625
+ center_y / 1000.0,
626
+ 0,
627
+ start_x / 1000.0,
628
+ start_y / 1000.0,
629
+ 0,
630
+ end_x / 1000.0,
631
+ end_y / 1000.0,
632
+ 0,
633
+ 1,
634
+ )
635
+ if not arc:
636
+ raise Exception("Failed to create arc")
637
+ return adapter._register_sketch_entity("Arc", arc) # type: ignore[no-any-return]
638
+
639
+ return cast(
640
+ AdapterResult[str], adapter._handle_com_operation("add_arc", _arc_operation)
641
+ )
642
+
643
+
644
+ def _add_spline_impl(
645
+ adapter: Any, points: list[dict[str, float]]
646
+ ) -> AdapterResult[str]:
647
+ """Add a NURBS spline through the supplied control points.
648
+
649
+ Calls ``SketchManager.CreateSpline2(points, simulateNaturalEnds=False)``
650
+ with a flattened XYZ coordinate list. Each point dict must contain
651
+ ``"x"`` and ``"y"`` keys; the Z component is forced to ``0``.
652
+
653
+ Args:
654
+ adapter: A ``PyWin32Adapter`` with an open sketch.
655
+ points: Ordered list of control-point dicts with keys ``"x"`` and
656
+ ``"y"`` (in **millimetres**). Minimum 2 points required by
657
+ SolidWorks. Example::
658
+
659
+ [{"x": 0, "y": 0}, {"x": 25, "y": 10}, {"x": 50, "y": 0}]
660
+
661
+ Returns:
662
+ AdapterResult[str]: On success, ``data`` is the registered entity
663
+ ID string (e.g. ``"Spline_5"``). On failure, ``status`` is
664
+ ``ERROR``.
665
+
666
+ Raises:
667
+ Exception: Propagated through ``_handle_com_operation`` when fewer
668
+ than two points are supplied or ``CreateSpline2`` returns
669
+ ``None``.
670
+
671
+ Example::
672
+
673
+ pts = [{"x": 0, "y": 0}, {"x": 20, "y": 15}, {"x": 40, "y": 0}]
674
+ result = pywin32_sketch_ops.add_spline(adapter, pts)
675
+ print(result.data) # "Spline_1"
676
+ """
677
+ if not adapter.currentSketchManager:
678
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
679
+
680
+ def _spline_operation() -> str:
681
+ if len(points) < 2:
682
+ raise Exception("add_spline requires at least 2 points")
683
+
684
+ spline_points: list[float] = []
685
+ for point in points:
686
+ spline_points.extend([point["x"] / 1000.0, point["y"] / 1000.0, 0.0])
687
+
688
+ # pywin32 late binding unpacks a bare list into N positional
689
+ # VARIANTs, so SolidWorks sees 3*N+1 arguments instead of 2 and
690
+ # rejects the call with DISP_E_BADPARAMCOUNT. Wrap the doubles in a
691
+ # SAFEARRAY VARIANT (VT_ARRAY|VT_R8) — the same lazy-import dance as
692
+ # add_sketch_constraint so non-Windows CI still exercises this path.
693
+ try:
694
+ import pythoncom as _pythoncom
695
+ from win32com.client import VARIANT as _VARIANT
696
+ except ImportError:
697
+ _pythoncom = None
698
+ _VARIANT = None
699
+
700
+ points_arg: Any
701
+ if _pythoncom is not None and _VARIANT is not None:
702
+ points_arg = _VARIANT(_pythoncom.VT_ARRAY | _pythoncom.VT_R8, spline_points)
703
+ elif sys.platform == "win32":
704
+ raise Exception("pywin32 is required for add_spline on Windows")
705
+ else:
706
+ points_arg = spline_points
707
+
708
+ spline = adapter.currentSketchManager.CreateSpline2(points_arg, False)
709
+ if not spline:
710
+ raise Exception("Failed to create spline")
711
+
712
+ # ISketchSpline dispatch does not expose Select4 via IDispatch —
713
+ # GetIDsOfNames returns Member not found. Retrieve the entity as
714
+ # ISketchSegment from the active sketch instead; that interface
715
+ # exposes Select4, which mirror/offset require.
716
+ if adapter.currentSketch is not None:
717
+ adapter._attempt(
718
+ lambda: _sw_type_info.flag_methods(adapter.currentSketch, "ISketch"),
719
+ default=0,
720
+ )
721
+ segments = adapter._attempt(
722
+ lambda: adapter.currentSketch.GetSketchSegments(), default=None
723
+ )
724
+ if segments is not None and len(segments) > 0:
725
+ return cast(
726
+ str, adapter._register_sketch_entity("Spline", segments[-1])
727
+ )
728
+
729
+ return cast(str, adapter._register_sketch_entity("Spline", spline))
730
+
731
+ return cast(
732
+ AdapterResult[str],
733
+ adapter._handle_com_operation("add_spline", _spline_operation),
734
+ )
735
+
736
+
737
+ def _add_centerline_impl(
738
+ adapter: Any, x1: float, y1: float, x2: float, y2: float
739
+ ) -> AdapterResult[str]:
740
+ """Add a construction centre-line to the active sketch.
741
+
742
+ Centre-lines are used as rotation axes for revolve features and as
743
+ mirror lines for symmetric sketches. Calls
744
+ ``SketchManager.CreateCenterLine``; the resulting entity is not
745
+ registered in the entity registry because it cannot be dimensioned
746
+ independently.
747
+
748
+ Args:
749
+ adapter: A ``PyWin32Adapter`` with an open sketch.
750
+ x1: Start X in **millimetres**.
751
+ y1: Start Y in **millimetres**.
752
+ x2: End X in **millimetres**.
753
+ y2: End Y in **millimetres**.
754
+
755
+ Returns:
756
+ AdapterResult[str]: On success, ``data`` is a timestamped ID string
757
+ (e.g. ``"Centerline_9341"``). On failure, ``status`` is ``ERROR``.
758
+
759
+ Raises:
760
+ Exception: Propagated through ``_handle_com_operation`` when
761
+ ``CreateCenterLine`` returns ``None``.
762
+
763
+ Example::
764
+
765
+ result = pywin32_sketch_ops.add_centerline(adapter, 0, -20, 0, 20)
766
+ print(result.data) # "Centerline_9341"
767
+ """
768
+ if not adapter.currentSketchManager:
769
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
770
+
771
+ def _centerline_operation() -> str:
772
+ """Inner COM closure that calls CreateCenterLine and registers
773
+ the resulting entity so it can be referenced by subsequent
774
+ dimension and constraint calls (e.g. as the centerline of a
775
+ ``symmetric`` relation).
776
+
777
+ Returns:
778
+ str: Registered entity ID (e.g. ``"Centerline_3"``).
779
+
780
+ Raises:
781
+ Exception: If ``CreateCenterLine`` returns ``None``.
782
+ """
783
+ centerline = adapter.currentSketchManager.CreateCenterLine(
784
+ x1 / 1000.0, y1 / 1000.0, 0, x2 / 1000.0, y2 / 1000.0, 0
785
+ )
786
+ if not centerline:
787
+ raise Exception("Failed to create centerline")
788
+ return cast(str, adapter._register_sketch_entity("Centerline", centerline))
789
+
790
+ return cast(
791
+ AdapterResult[str],
792
+ adapter._handle_com_operation("add_centerline", _centerline_operation),
793
+ )
794
+
795
+
796
+ def _add_polygon_impl(
797
+ adapter: Any,
798
+ center_x: float,
799
+ center_y: float,
800
+ radius: float,
801
+ sides: int,
802
+ ) -> AdapterResult[str]:
803
+ """Add a regular polygon inscribed in a circle to the active sketch.
804
+
805
+ Calls ``SketchManager.CreatePolygon(XC, YC, Zc, Xp, Yp, Zp, Sides,
806
+ Inscribed)``. All eight arguments are required by the COM API — passing
807
+ fewer arguments surfaces a pywin32 ``"Parameter not optional."`` error at
808
+ the SOLIDWORKS boundary. The vertex point ``(Xp, Yp, Zp)`` is placed on
809
+ the positive X axis at ``radius`` from centre, which fixes the polygon's
810
+ rotation reproducibly.
811
+
812
+ Args:
813
+ adapter: A ``PyWin32Adapter`` with an open sketch.
814
+ center_x: Polygon centre X in **millimetres**.
815
+ center_y: Polygon centre Y in **millimetres**.
816
+ radius: Circumradius in **millimetres** (distance from centre to each
817
+ vertex). Corresponds to ``CreatePolygon(..., Inscribed=True)``,
818
+ i.e. the polygon is inscribed in a circle of this radius.
819
+ sides: Number of polygon sides. SolidWorks accepts 3–40.
820
+
821
+ Returns:
822
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
823
+ (e.g. ``"Polygon_3"``). The ID is stored in
824
+ ``adapter._sketch_entities`` so it can be passed back to
825
+ ``sketch_linear_pattern`` / ``sketch_circular_pattern`` /
826
+ ``sketch_mirror`` / ``sketch_offset``. On failure, ``status`` is
827
+ ``ERROR``.
828
+
829
+ Raises:
830
+ Exception: Propagated through ``_handle_com_operation`` when
831
+ ``CreatePolygon`` returns ``None``.
832
+
833
+ Example::
834
+
835
+ result = pywin32_sketch_ops.add_polygon(
836
+ adapter, center_x=0, center_y=0, radius=15.0, sides=6
837
+ )
838
+ print(result.data) # "Polygon_3"
839
+ """
840
+ if not adapter.currentSketchManager:
841
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
842
+
843
+ def _polygon_operation() -> str:
844
+ """Inner COM closure that calls CreatePolygon.
845
+
846
+ Returns:
847
+ str: Registered entity ID for the polygon (e.g. ``"Polygon_3"``).
848
+
849
+ Raises:
850
+ Exception: If ``CreatePolygon`` returns ``None``.
851
+ """
852
+ polygon = adapter.currentSketchManager.CreatePolygon(
853
+ center_x / 1000.0,
854
+ center_y / 1000.0,
855
+ 0,
856
+ (center_x + radius) / 1000.0,
857
+ center_y / 1000.0,
858
+ 0,
859
+ sides,
860
+ True,
861
+ )
862
+ if not polygon:
863
+ raise Exception("Failed to create polygon")
864
+ # Register so the returned ID is usable by sketch_linear_pattern,
865
+ # sketch_circular_pattern, sketch_mirror, and sketch_offset — without
866
+ # this the polygon string is opaque and every downstream op fails
867
+ # with "Unknown sketch entity 'Polygon_*'".
868
+ entity_id = cast(str, adapter._register_sketch_entity("Polygon", polygon))
869
+ # Polygons register as a SAFEARRAY tuple of segments — there's no
870
+ # single dispatch ``GetCenterPoint`` to recover the center from later.
871
+ # Stash the known center so ``sketch_circular_pattern`` can derive the
872
+ # seed-to-axis offset for polygon seeds.
873
+ adapter._sketch_entity_centers[entity_id] = (center_x, center_y)
874
+ return entity_id
875
+
876
+ return cast(
877
+ AdapterResult[str],
878
+ adapter._handle_com_operation("add_polygon", _polygon_operation),
879
+ )
880
+
881
+
882
+ def _add_ellipse_impl(
883
+ adapter: Any,
884
+ center_x: float,
885
+ center_y: float,
886
+ major_axis: float,
887
+ minor_axis: float,
888
+ ) -> AdapterResult[str]:
889
+ """Add an axis-aligned ellipse to the active sketch.
890
+
891
+ Calls ``SketchManager.CreateEllipse`` with the major-axis endpoint on the
892
+ positive X direction from the centre and the minor-axis endpoint on the
893
+ positive Y direction. The ellipse is therefore axis-aligned and cannot
894
+ be rotated via this function.
895
+
896
+ The created ellipse is registered in the adapter's sketch-entity
897
+ registry so subsequent constraint and dimension calls can reference it
898
+ by ID, matching the behaviour of ``add_line`` / ``add_circle`` /
899
+ ``add_arc`` and the mock adapter.
900
+
901
+ Args:
902
+ adapter: A ``PyWin32Adapter`` with an open sketch.
903
+ center_x: Ellipse centre X in **millimetres**.
904
+ center_y: Ellipse centre Y in **millimetres**.
905
+ major_axis: Full major-axis length in **millimetres** (half is used
906
+ as the offset from centre).
907
+ minor_axis: Full minor-axis length in **millimetres** (half is used
908
+ as the offset from centre).
909
+
910
+ Returns:
911
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
912
+ (e.g. ``"Ellipse_4"``). On failure, ``status`` is ``ERROR``.
913
+
914
+ Raises:
915
+ Exception: Propagated through ``_handle_com_operation`` when
916
+ ``CreateEllipse`` returns ``None``.
917
+
918
+ Example::
919
+
920
+ result = pywin32_sketch_ops.add_ellipse(
921
+ adapter, center_x=0, center_y=0, major_axis=30.0, minor_axis=15.0
922
+ )
923
+ print(result.data) # "Ellipse_4"
924
+ """
925
+ if not adapter.currentSketchManager:
926
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
927
+
928
+ def _ellipse_operation() -> str:
929
+ """Inner COM closure that calls CreateEllipse and registers the entity.
930
+
931
+ Returns:
932
+ str: Registered entity ID for the new ellipse.
933
+
934
+ Raises:
935
+ Exception: If ``CreateEllipse`` returns ``None``.
936
+ """
937
+ ellipse = adapter.currentSketchManager.CreateEllipse(
938
+ center_x / 1000.0,
939
+ center_y / 1000.0,
940
+ 0,
941
+ (center_x + major_axis / 2) / 1000.0,
942
+ center_y / 1000.0,
943
+ 0,
944
+ center_x / 1000.0,
945
+ (center_y + minor_axis / 2) / 1000.0,
946
+ 0,
947
+ )
948
+ if not ellipse:
949
+ raise Exception("Failed to create ellipse")
950
+ return cast(str, adapter._register_sketch_entity("Ellipse", ellipse))
951
+
952
+ return cast(
953
+ AdapterResult[str],
954
+ adapter._handle_com_operation("add_ellipse", _ellipse_operation),
955
+ )
956
+
957
+
958
+ def _add_sketch_constraint_impl(
959
+ adapter: Any,
960
+ entity1: str,
961
+ entity2: str | None,
962
+ relation_type: str,
963
+ entity3: str | None = None,
964
+ ) -> AdapterResult[str]:
965
+ """Add a geometric relation (constraint) between sketch entities.
966
+
967
+ Resolves ``entity1`` (and ``entity2``/``entity3`` if provided) against the
968
+ adapter's sketch-entity registry, then calls
969
+ ``ISketchRelationManager.AddRelation(entities, relation_type_enum)`` on
970
+ the active sketch. Entity handles are passed as a
971
+ ``VARIANT(VT_ARRAY | VT_DISPATCH, [...])`` — pywin32 will not marshal a
972
+ plain Python list of CDispatch objects to a SAFEARRAY by itself.
973
+
974
+ The legacy ``IModelDoc2.SketchAddConstraints`` API silently no-ops on
975
+ SW 2026/3DEXPERIENCE despite accepting the call, so this implementation
976
+ uses the modern ``ISketchRelationManager.AddRelation`` per the official
977
+ SolidWorks API docs.
978
+
979
+ Supported ``relation_type`` strings (case-insensitive): ``"horizontal"``,
980
+ ``"vertical"``, ``"parallel"``, ``"perpendicular"``, ``"tangent"``,
981
+ ``"coincident"``, ``"concentric"``, ``"equal"``, ``"symmetric"``,
982
+ ``"collinear"``, ``"fix"``.
983
+
984
+ ``"symmetric"`` is the only relation that takes a third entity
985
+ (``entity3`` — the centerline of symmetry). All other relations reject
986
+ a non-null ``entity3``.
987
+
988
+ Args:
989
+ adapter: A ``PyWin32Adapter`` with an open sketch and a valid
990
+ ``currentModel``.
991
+ entity1: Registered entity ID of the primary sketch entity (from a
992
+ prior ``add_line`` / ``add_circle`` call).
993
+ entity2: Registered entity ID of the secondary sketch entity, or
994
+ ``None`` for single-entity relations (horizontal, vertical, fix).
995
+ relation_type: Constraint type string (see above).
996
+ entity3: Registered ID of a third entity. Only meaningful for
997
+ ``"symmetric"`` — pass the centerline ID (from ``add_centerline``)
998
+ as the line of symmetry. Must be ``None`` for every other
999
+ relation type.
1000
+
1001
+ Returns:
1002
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
1003
+ of the new constraint object (e.g. ``"Constraint_3"``). On failure,
1004
+ ``status`` is ``ERROR``.
1005
+ """
1006
+ if not adapter.currentSketchManager:
1007
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
1008
+
1009
+ def _constraint_operation() -> str:
1010
+ if not adapter.currentModel:
1011
+ raise Exception("No active model")
1012
+
1013
+ rt_norm = (relation_type or "").strip().lower()
1014
+ relation_type_enum = RELATION_NAME_MAP.get(rt_norm)
1015
+ if relation_type_enum is None:
1016
+ supported = ", ".join(sorted(RELATION_NAME_MAP))
1017
+ raise Exception(
1018
+ f"Unsupported relation type '{relation_type}'. Supported: {supported}"
1019
+ )
1020
+
1021
+ # Arity validation per relation type
1022
+ if rt_norm in _THREE_ENTITY_RELATIONS:
1023
+ if entity2 is None or entity3 is None:
1024
+ raise Exception(
1025
+ f"Relation '{relation_type}' requires entity1, entity2, "
1026
+ "and entity3 (the centerline of symmetry)"
1027
+ )
1028
+ elif entity3 is not None:
1029
+ raise Exception(
1030
+ f"Relation '{relation_type}' does not accept entity3 — only "
1031
+ "'symmetric' takes a third entity (the centerline)"
1032
+ )
1033
+
1034
+ entity1_obj = adapter._sketch_entities.get(entity1)
1035
+ if entity1_obj is None:
1036
+ raise Exception(
1037
+ f"Unknown sketch entity '{entity1}'. Use IDs returned by add_line/add_arc/add_circle/add_spline/add_centerline."
1038
+ )
1039
+
1040
+ entities = [entity1_obj]
1041
+ if entity2:
1042
+ entity2_obj = adapter._sketch_entities.get(entity2)
1043
+ if entity2_obj is None:
1044
+ raise Exception(
1045
+ f"Unknown sketch entity '{entity2}'. Use IDs returned by add_line/add_arc/add_circle/add_spline/add_centerline."
1046
+ )
1047
+ entities.append(entity2_obj)
1048
+ if entity3:
1049
+ entity3_obj = adapter._sketch_entities.get(entity3)
1050
+ if entity3_obj is None:
1051
+ raise Exception(
1052
+ f"Unknown sketch entity '{entity3}'. Use IDs returned by add_line/add_arc/add_circle/add_spline/add_centerline."
1053
+ )
1054
+ entities.append(entity3_obj)
1055
+
1056
+ # Flag IModelDoc2 + ISketch + ISketchRelationManager so late-binding
1057
+ # resolves GetActiveSketch2, RelationManager, and AddRelation as
1058
+ # methods/properties correctly.
1059
+ adapter._attempt(
1060
+ lambda: _sw_type_info.flag_methods(adapter.currentModel, "IModelDoc2"),
1061
+ default=0,
1062
+ )
1063
+
1064
+ active_sketch = adapter._attempt(
1065
+ lambda: adapter.currentModel.GetActiveSketch2(), default=None
1066
+ )
1067
+ if active_sketch is None:
1068
+ raise Exception(
1069
+ "No active sketch on the model — create_sketch first or "
1070
+ "open the existing sketch for edit."
1071
+ )
1072
+ adapter._attempt(
1073
+ lambda: _sw_type_info.flag_methods(active_sketch, "ISketch"),
1074
+ default=0,
1075
+ )
1076
+
1077
+ relmgr = adapter._attempt(lambda: active_sketch.RelationManager, default=None)
1078
+ if relmgr is None:
1079
+ raise Exception("Active sketch has no RelationManager")
1080
+ adapter._attempt(
1081
+ lambda: _sw_type_info.flag_methods(relmgr, "ISketchRelationManager"),
1082
+ default=0,
1083
+ )
1084
+
1085
+ # pywin32 won't auto-marshal a Python list of CDispatch entities to a
1086
+ # SAFEARRAY — the VT_ARRAY|VT_DISPATCH variant is the shape SolidWorks
1087
+ # accepts. Mirror the lazy-import dance used for sw_type_info above so
1088
+ # non-Windows CI (where pywin32 isn't installed) still exercises this
1089
+ # call path; fake adapters used in unit tests accept any sequence.
1090
+ try:
1091
+ import pythoncom as _pythoncom
1092
+ from win32com.client import VARIANT as _VARIANT
1093
+ except ImportError:
1094
+ _pythoncom = None
1095
+ _VARIANT = None
1096
+
1097
+ ents_variant: Any
1098
+ if _pythoncom is not None and _VARIANT is not None:
1099
+ ents_variant = _VARIANT(
1100
+ _pythoncom.VT_ARRAY | _pythoncom.VT_DISPATCH, entities
1101
+ )
1102
+ elif sys.platform == "win32":
1103
+ # On Windows the real adapter feeds entities to a live COM method
1104
+ # that requires the SAFEARRAY shape — fail clearly rather than let
1105
+ # AddRelation surface a low-level "server threw an exception" COM
1106
+ # error from an unwrappable list argument.
1107
+ raise Exception("pywin32 is required for add_sketch_constraint on Windows")
1108
+ else:
1109
+ # Non-Windows: this branch is exercised only by mocked unit tests
1110
+ # whose fake AddRelation accepts any sequence.
1111
+ ents_variant = entities
1112
+ sketch_relation, add_err = adapter._attempt_with_error(
1113
+ lambda: relmgr.AddRelation(ents_variant, relation_type_enum)
1114
+ )
1115
+ if add_err is not None or sketch_relation is None:
1116
+ target = f"'{entity1}'" + (f" and '{entity2}'" if entity2 else "")
1117
+ detail = f": {add_err}" if add_err else ""
1118
+ raise Exception(
1119
+ f"SolidWorks rejected '{relation_type}' relation on {target}{detail}"
1120
+ )
1121
+
1122
+ return cast(
1123
+ str,
1124
+ adapter._register_sketch_entity("Constraint", sketch_relation),
1125
+ )
1126
+
1127
+ return cast(
1128
+ AdapterResult[str],
1129
+ adapter._handle_com_operation("add_sketch_constraint", _constraint_operation),
1130
+ )
1131
+
1132
+
1133
+ def _add_sketch_dimension_impl(
1134
+ adapter: Any,
1135
+ entity1: str,
1136
+ entity2: str | None,
1137
+ dimension_type: str,
1138
+ value: float,
1139
+ ) -> AdapterResult[str]:
1140
+ """Add a driven dimension to one or two registered sketch entities.
1141
+
1142
+ Supports linear, angular, radial, and diameter dimensions:
1143
+
1144
+ * **linear** — places a horizontal or vertical smart dimension on a single
1145
+ entity. The text-placement point is computed by
1146
+ ``adapter._single_line_dimension_placement``.
1147
+ * **angular** — places an angular dimension between two connected line
1148
+ segments sharing a common vertex. The vertex is found via
1149
+ ``adapter._shared_segment_vertex`` and multiple direction/segment
1150
+ combinations are tried until one succeeds.
1151
+ * **radial** / **diameter** — places a radius or diameter dimension on a
1152
+ selected sketch arc or circle using ``IModelDoc2.AddRadialDimension2`` or
1153
+ ``IModelDoc2.AddDiameterDimension2``.
1154
+
1155
+ SolidWorks can otherwise enter the interactive ``Modify`` approval flow
1156
+ during sketch dimension creation. The adapter keeps the relevant
1157
+ sketch-input preferences disabled for the full automation session in
1158
+ ``_ComSessionCoordinator.set_automation_preferences`` and uses the dedicated
1159
+ radial/diameter APIs here because that path is more reliable in unattended
1160
+ COM sessions.
1161
+
1162
+ Dimensional values for **linear** dimensions are in **millimetres**;
1163
+ for **angular** dimensions they are in **degrees**.
1164
+
1165
+ Args:
1166
+ adapter: A ``PyWin32Adapter`` with an open sketch and a valid
1167
+ ``currentModel`` and ``swApp``.
1168
+ entity1: Registered entity ID of the primary sketch entity.
1169
+ entity2: Registered entity ID of a secondary sketch entity (required
1170
+ for angular dimensions), or ``None``.
1171
+ dimension_type: ``"linear"``, ``"angular"``, ``"radial"``, or
1172
+ ``"diameter"`` (case-insensitive).
1173
+ value: Dimension value. Millimetres for linear, radial, and diameter;
1174
+ degrees for angular.
1175
+
1176
+ Returns:
1177
+ AdapterResult[str]: On success, ``data`` is the registered entity ID
1178
+ of the new dimension object. On failure, ``status`` is ``ERROR``.
1179
+
1180
+ Raises:
1181
+ Exception: Propagated through ``_handle_com_operation`` when the
1182
+ entity IDs cannot be resolved, the placement cannot be computed,
1183
+ or the SolidWorks ``AddDimension`` call fails.
1184
+
1185
+ Example::
1186
+
1187
+ # Dimension a line to 50 mm
1188
+ result = pywin32_sketch_ops.add_sketch_dimension(
1189
+ adapter, "Line_1", None, "linear", 50.0
1190
+ )
1191
+ print(result.data) # "Dimension_7"
1192
+
1193
+ # Angular dimension between two intersecting lines
1194
+ result = pywin32_sketch_ops.add_sketch_dimension(
1195
+ adapter, "Line_1", "Line_2", "angular", 45.0
1196
+ )
1197
+ """
1198
+ if not adapter.currentSketchManager:
1199
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
1200
+
1201
+ def _dimension_operation() -> str:
1202
+ """Inner COM closure that resolves entities, places and values the dimension.
1203
+
1204
+ Resolves entity objects from the adapter registry, computes the
1205
+ placement point, suppresses the interactive value dialog, calls
1206
+ ``AddDimension``, then sets the dimensional value via
1207
+ ``SetSystemValue3`` / ``SetSystemValue2`` / ``SystemValue`` fallbacks.
1208
+
1209
+ Returns:
1210
+ str: Registered entity ID for the new dimension display object.
1211
+
1212
+ Raises:
1213
+ Exception: If entity IDs are unknown, placement cannot be
1214
+ determined, or the COM call fails.
1215
+ """
1216
+ import math as _math_dim
1217
+
1218
+ # Re-assert per-call: SolidWorks resets swInputDimValOnCreate (10)
1219
+ # when entering a new sketch, so the global connect-time suppression
1220
+ # is not sufficient. Setting it to False here prevents the Modify
1221
+ # dialog from blocking unattended automation.
1222
+ _sw = getattr(adapter, "swApp", None)
1223
+ if _sw:
1224
+ adapter._attempt(lambda: _sw.SetUserPreferenceToggle(10, False))
1225
+ adapter._attempt(lambda: _sw.SetUserPreferenceToggle(372, False))
1226
+ adapter._attempt(lambda: _sw.SetUserPreferenceToggle(520, False))
1227
+
1228
+ def _radial_dimension_placement() -> tuple[float, float, float, int]:
1229
+ """Compute a deterministic placement for radial/diameter dimensions.
1230
+
1231
+ Avoids additional COM geometry reads that can block on some live sessions.
1232
+ """
1233
+ offset = max(0.01, min(0.05, abs(value) / 1000.0 + 0.01))
1234
+ return (
1235
+ offset,
1236
+ offset,
1237
+ 0.0,
1238
+ adapter.constants["swSmartDimensionDirectionUp"],
1239
+ )
1240
+
1241
+ if not adapter.currentModel:
1242
+ return f"Dimension_{int(time.time() * 1000) % 10000}"
1243
+
1244
+ entity1_obj = adapter._sketch_entities.get(entity1)
1245
+ if entity1_obj is None:
1246
+ raise Exception(
1247
+ f"Unknown sketch entity '{entity1}'. Use IDs returned by add_line/add_arc/add_circle/add_spline/add_centerline."
1248
+ )
1249
+
1250
+ entity2_obj = None
1251
+ if entity2:
1252
+ entity2_obj = adapter._sketch_entities.get(entity2)
1253
+ if entity2_obj is None:
1254
+ raise Exception(
1255
+ f"Unknown sketch entity '{entity2}'. Use IDs returned by add_line/add_arc/add_circle/add_spline/add_centerline."
1256
+ )
1257
+
1258
+ dim_type = (dimension_type or "linear").strip().lower()
1259
+ placement = None
1260
+ if dim_type == "angular" and entity2_obj is not None:
1261
+ placement = adapter._angular_dimension_placement(entity1_obj, entity2_obj)
1262
+ elif dim_type == "linear":
1263
+ placement = adapter._single_line_dimension_placement(entity1_obj)
1264
+ elif dim_type in {"radial", "diameter"}:
1265
+ placement = _radial_dimension_placement()
1266
+
1267
+ if placement is None:
1268
+ raise Exception(
1269
+ f"Unsupported or ambiguous dimension placement for type '{dim_type}'"
1270
+ )
1271
+
1272
+ text_x, text_y, text_z, direction = placement
1273
+
1274
+ def _try_create_angular_dimension() -> Any:
1275
+ """Attempt to create an angular dimension between two line segments.
1276
+
1277
+ Finds the shared vertex between ``entity1_obj`` and
1278
+ ``entity2_obj``, then iterates through every (segment, vertex,
1279
+ direction) combination until SolidWorks accepts the placement.
1280
+
1281
+ Returns:
1282
+ Any: The SolidWorks display-dimension COM object on success,
1283
+ or ``None`` if all attempts fail.
1284
+ """
1285
+ shared_vertex = adapter._shared_segment_vertex(entity1_obj, entity2_obj)
1286
+ if shared_vertex is None:
1287
+ return None
1288
+
1289
+ _, vertex1, vertex2 = shared_vertex
1290
+ segment_attempts = ((entity1_obj, vertex1), (entity2_obj, vertex2))
1291
+ direction_attempts = [direction] + [
1292
+ candidate
1293
+ for candidate in (
1294
+ adapter.constants["swSmartDimensionDirectionRight"],
1295
+ adapter.constants["swSmartDimensionDirectionUp"],
1296
+ adapter.constants["swSmartDimensionDirectionLeft"],
1297
+ adapter.constants["swSmartDimensionDirectionDown"],
1298
+ )
1299
+ if candidate != direction
1300
+ ]
1301
+
1302
+ for segment_obj, vertex_obj in segment_attempts:
1303
+ for candidate_direction in direction_attempts:
1304
+ adapter.currentModel.ClearSelection2(True)
1305
+ if not adapter._select_sketch_entity(segment_obj, append=False):
1306
+ continue
1307
+ if not adapter._attempt(
1308
+ lambda vo=vertex_obj: bool(vo.Select2(True, 0)), default=False
1309
+ ):
1310
+ continue
1311
+ display_dim = adapter._attempt(
1312
+ lambda d=candidate_direction: (
1313
+ adapter.currentModel.Extension.AddDimension(
1314
+ text_x, text_y, text_z, d
1315
+ )
1316
+ ),
1317
+ default=None,
1318
+ )
1319
+ if display_dim:
1320
+ return display_dim
1321
+ return None
1322
+
1323
+ if dim_type == "angular":
1324
+ display_dim = _try_create_angular_dimension()
1325
+ elif dim_type == "linear" and entity2_obj is not None:
1326
+ adapter.currentModel.ClearSelection2(True)
1327
+ if not adapter._select_sketch_entity(entity1_obj, append=False):
1328
+ raise Exception(f"Failed to select primary entity '{entity1}'")
1329
+ if not adapter._select_sketch_entity(entity2_obj, append=True):
1330
+ raise Exception(f"Failed to select secondary entity '{entity2}'")
1331
+ display_dim = _two_entity_linear_dimension(
1332
+ adapter, (entity1, entity2), entity1_obj, entity2_obj, direction
1333
+ )
1334
+ else:
1335
+ adapter.currentModel.ClearSelection2(True)
1336
+ if not adapter._select_sketch_entity(entity1_obj, append=False):
1337
+ raise Exception(f"Failed to select primary entity '{entity1}'")
1338
+ if entity2_obj is not None and not adapter._select_sketch_entity(
1339
+ entity2_obj, append=True
1340
+ ):
1341
+ raise Exception(f"Failed to select secondary entity '{entity2}'")
1342
+
1343
+ if dim_type == "radial":
1344
+ display_dim = adapter._attempt(
1345
+ lambda: adapter.currentModel.AddRadialDimension2(
1346
+ text_x, text_y, text_z
1347
+ ),
1348
+ default=None,
1349
+ )
1350
+ elif dim_type == "diameter":
1351
+ display_dim = adapter._attempt(
1352
+ lambda: adapter.currentModel.AddDiameterDimension2(
1353
+ text_x, text_y, text_z
1354
+ ),
1355
+ default=None,
1356
+ )
1357
+ else:
1358
+ # Use a single deterministic AddDimension call for non-angular
1359
+ # dimensions that require extension-line direction.
1360
+ display_dim = adapter._attempt(
1361
+ lambda: adapter.currentModel.Extension.AddDimension(
1362
+ text_x, text_y, text_z, direction
1363
+ ),
1364
+ default=None,
1365
+ )
1366
+
1367
+ if not display_dim:
1368
+ raise Exception("SolidWorks failed to create sketch dimension")
1369
+
1370
+ if dim_type == "angular":
1371
+ value_si = value * _math_dim.pi / 180.0
1372
+ else:
1373
+ value_si = value / 1000.0
1374
+
1375
+ dim_obj = (
1376
+ adapter._attempt(lambda: display_dim.GetDimension2(0), default=None)
1377
+ or adapter._attempt(lambda: display_dim.GetDimension(), default=None)
1378
+ or display_dim
1379
+ )
1380
+ if (
1381
+ adapter._attempt(
1382
+ lambda: dim_obj.SetSystemValue3(value_si, 1, None), default=None
1383
+ )
1384
+ is None
1385
+ ):
1386
+ if (
1387
+ adapter._attempt(
1388
+ lambda: dim_obj.SetSystemValue2(value_si, 1), default=None
1389
+ )
1390
+ is None
1391
+ ):
1392
+ if hasattr(dim_obj, "SystemValue"):
1393
+ dim_obj.SystemValue = value_si
1394
+
1395
+ return adapter._register_sketch_entity("Dimension", display_dim) # type: ignore[no-any-return]
1396
+
1397
+ return cast(
1398
+ AdapterResult[str],
1399
+ adapter._handle_com_operation("add_sketch_dimension", _dimension_operation),
1400
+ )
1401
+
1402
+
1403
+ def _sketch_point_xyz(
1404
+ adapter: Any, entity_id: str, entity: Any
1405
+ ) -> tuple[float, float, float] | None:
1406
+ """A sketch entity's reference point in metres: a circle's centre, a line's midpoint.
1407
+
1408
+ Lines and circles record that point (in mm) when they are created, so it
1409
+ is read from ``_sketch_entity_centers`` without a COM call. Reading it
1410
+ back from SolidWorks returned nothing on SW 2026; that read stays only as
1411
+ the fallback for entities that record no point.
1412
+
1413
+ Args:
1414
+ adapter: A ``PyWin32Adapter``.
1415
+ entity_id: The entity's registered ID.
1416
+ entity: The registered ``ISketchArc`` or ``ISketchLine`` dispatch.
1417
+
1418
+ Returns:
1419
+ tuple[float, float, float] | None: The point, or ``None`` when it
1420
+ cannot be read.
1421
+ """
1422
+ cached = adapter._sketch_entity_centers.get(entity_id)
1423
+ if cached is not None:
1424
+ return (cached[0] / 1000.0, cached[1] / 1000.0, 0.0)
1425
+ adapter._attempt(
1426
+ lambda: _sw_type_info.flag_methods(entity, "ISketchArc"), default=0
1427
+ )
1428
+ centre = adapter._attempt(lambda: entity.GetCenterPoint(), default=None)
1429
+ if isinstance(centre, (list, tuple)) and len(centre) >= 3:
1430
+ return (float(centre[0]), float(centre[1]), float(centre[2]))
1431
+ ends = adapter._attempt(
1432
+ lambda: adapter._sketch_geometry.read_segment_endpoints(entity), default=None
1433
+ )
1434
+ if ends is None:
1435
+ return None
1436
+ (x1, y1, z1), (x2, y2, z2) = ends
1437
+ return ((x1 + x2) / 2.0, (y1 + y2) / 2.0, (z1 + z2) / 2.0)
1438
+
1439
+
1440
+ #: How far past a line's end a dimension to it is placed, in metres.
1441
+ _DIMENSION_CLEARANCE_M = 0.008
1442
+
1443
+
1444
+ def _text_beside_line(
1445
+ adapter: Any, line: Any, point: tuple[float, float, float]
1446
+ ) -> tuple[float, float, float] | None:
1447
+ """Where to put a line-to-point dimension so it sits outside the outline.
1448
+
1449
+ Midway between the line and the point along the measurement, then moved
1450
+ along the line past its end nearest the point, so the dimensions to the
1451
+ holes of a plate stand clear of the plate instead of piling up inside it.
1452
+
1453
+ Args:
1454
+ adapter: A ``PyWin32Adapter``.
1455
+ line: The ``ISketchLine`` dispatch.
1456
+ point: The other entity's point, in metres.
1457
+
1458
+ Returns:
1459
+ tuple[float, float, float] | None: The text position, or ``None``
1460
+ when the line's ends cannot be read.
1461
+ """
1462
+ ends = adapter._attempt(
1463
+ lambda: adapter._sketch_geometry.read_segment_endpoints(line), default=None
1464
+ )
1465
+ if ends is None:
1466
+ return None
1467
+ (ax, ay, _az), (bx, by, _bz) = ends
1468
+ length = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
1469
+ if length <= 1e-9:
1470
+ return None
1471
+ ux, uy = (bx - ax) / length, (by - ay) / length
1472
+ along = (point[0] - ax) * ux + (point[1] - ay) * uy
1473
+ foot = (ax + along * ux, ay + along * uy)
1474
+ mid = ((foot[0] + point[0]) / 2.0, (foot[1] + point[1]) / 2.0)
1475
+ shift = (
1476
+ -along - _DIMENSION_CLEARANCE_M
1477
+ if along < length / 2.0
1478
+ else length - along + _DIMENSION_CLEARANCE_M
1479
+ )
1480
+ return (mid[0] + shift * ux, mid[1] + shift * uy, point[2])
1481
+
1482
+
1483
+ def _two_entity_linear_dimension(
1484
+ adapter: Any, ids: tuple[str, str], entity1: Any, entity2: Any, direction: int
1485
+ ) -> Any:
1486
+ """A linear dimension between two sketch entities, e.g. a line and a circle.
1487
+
1488
+ The text goes beside a line, clear of the outline (see
1489
+ :func:`_text_beside_line`), or midway between the two entities when the
1490
+ first is not a line. ``Extension.AddDimension`` with the
1491
+ first entity's single-line placement returned nothing for a line and a
1492
+ circle on SW 2026, so the calls are tried in turn until one returns a
1493
+ display dimension: ``IModelDoc2::AddDimension2`` with both entities
1494
+ selected (what the macro recorder writes for a smart dimension), the same
1495
+ with the circle's centre point selected, then ``Extension.AddDimension``.
1496
+
1497
+ Args:
1498
+ adapter: A ``PyWin32Adapter`` with an open sketch.
1499
+ ids: The two entities' registered IDs.
1500
+ entity1: The first entity, usually a line.
1501
+ entity2: The second entity, usually a circle.
1502
+ direction: ``swSmartDimensionDirection_e`` for the last attempt.
1503
+
1504
+ Returns:
1505
+ Any: The display dimension, or ``None`` when every attempt failed
1506
+ (an attempt whose entities cannot be selected is skipped). The caller
1507
+ has already checked that both entities can be selected.
1508
+
1509
+ Raises:
1510
+ Exception: When the entities' positions cannot be read.
1511
+ """
1512
+ model = adapter.currentModel
1513
+ points = [
1514
+ _sketch_point_xyz(adapter, i, e)
1515
+ for i, e in zip(ids, (entity1, entity2), strict=True)
1516
+ ]
1517
+ if points[0] is None or points[1] is None:
1518
+ raise Exception("Cannot read where the two entities are to place the dimension")
1519
+ text = _text_beside_line(adapter, entity1, points[1]) or tuple(
1520
+ (a + b) / 2.0 for a, b in zip(points[0], points[1], strict=True)
1521
+ )
1522
+ centre = adapter._attempt(
1523
+ lambda: adapter._get_attr_or_call(entity2, "GetCenterPoint2"), default=None
1524
+ )
1525
+
1526
+ def _selected(second: Any) -> bool:
1527
+ adapter._attempt(lambda: model.ClearSelection2(True), default=None)
1528
+ return bool(
1529
+ adapter._select_sketch_entity(entity1, append=False)
1530
+ and adapter._select_sketch_entity(second, append=True)
1531
+ )
1532
+
1533
+ attempts = [
1534
+ (entity2, lambda: model.AddDimension2(*text)),
1535
+ *([(centre, lambda: model.AddDimension2(*text))] if centre is not None else []),
1536
+ (entity2, lambda: model.Extension.AddDimension(*text, direction)),
1537
+ ]
1538
+ for second, create in attempts:
1539
+ if not _selected(second):
1540
+ continue
1541
+ display_dim = adapter._attempt(create, default=None)
1542
+ if display_dim:
1543
+ return display_dim
1544
+ return None
1545
+
1546
+
1547
+ def _select_sketch_entities(adapter: Any, entity_ids: list[str], mark: int) -> None:
1548
+ """Select sketch entities from the registry under a specific mark.
1549
+
1550
+ Resolves each ID against ``adapter._sketch_entities`` and calls
1551
+ ``ISketchSegment.Select4(Append=True, Data)`` on each — with ``Data``
1552
+ being a configured ``ISelectData`` carrying the requested ``mark`` so
1553
+ SolidWorks knows how to interpret the selection (e.g. mark=1 sketch
1554
+ segments + mark=2 centerline for ``SketchMirror``).
1555
+
1556
+ The ``ISelectionMgr`` dispatch needs ``sw_type_info.flag_methods``
1557
+ flagging or pywin32 late binding cannot resolve ``CreateSelectData``
1558
+ and surfaces ``"Member not found."`` from the COM boundary. This
1559
+ matches the lazy-import dance used by ``add_sketch_constraint``;
1560
+ when ``sw_type_info`` cannot be imported the flagging step is skipped
1561
+ but ``ISelectionMgr.CreateSelectData`` and ``ISketchSegment.Select4``
1562
+ are still invoked. Callers must therefore not invoke this helper
1563
+ without a live ``ISelectionMgr`` on ``adapter.currentModel``.
1564
+
1565
+ Args:
1566
+ adapter: A ``PyWin32Adapter``. ``adapter.currentModel`` must be a
1567
+ live ``IModelDoc2`` dispatch.
1568
+ entity_ids: Registry IDs returned by ``add_line`` / ``add_arc`` /
1569
+ etc. Must be non-empty; resolution failure raises.
1570
+ mark: ``ISelectData.Mark`` value applied to every selection.
1571
+
1572
+ Raises:
1573
+ Exception: If an entity ID is not in the registry or a Select4
1574
+ call returns ``False``.
1575
+ """
1576
+ sel_mgr = adapter.currentModel.SelectionManager
1577
+ adapter._attempt(
1578
+ lambda: _sw_type_info.flag_methods(sel_mgr, "ISelectionMgr"),
1579
+ default=0,
1580
+ )
1581
+ select_data = sel_mgr.CreateSelectData()
1582
+ select_data.Mark = mark
1583
+ for ent_id in entity_ids:
1584
+ entity = adapter._sketch_entities.get(ent_id)
1585
+ if entity is None:
1586
+ raise Exception(
1587
+ f"Unknown sketch entity '{ent_id}'. Use IDs returned by "
1588
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1589
+ )
1590
+ # ``ISketchManager::CreatePolygon`` returns the polygon's edges as
1591
+ # a SAFEARRAY, which pywin32 unmarshals to a tuple of
1592
+ # ``ISketchSegment`` handles — there is no single COM object to
1593
+ # ``Select4`` on. Treat any iterable (tuple/list) as a group of
1594
+ # segments and select each, so a polygon ID can flow into
1595
+ # sketch_linear_pattern / sketch_circular_pattern / sketch_mirror /
1596
+ # sketch_offset the same as any other entity. Single-segment
1597
+ # entities (lines, arcs, splines, ellipses, centerlines) keep the
1598
+ # original Select4 path.
1599
+ if isinstance(entity, (list, tuple)):
1600
+ for segment in entity:
1601
+ adapter._attempt(
1602
+ lambda s=segment: _sw_type_info.flag_methods(s, "ISketchSegment"),
1603
+ default=0,
1604
+ )
1605
+ ok = segment.Select4(True, select_data)
1606
+ if not ok:
1607
+ raise Exception(
1608
+ f"Failed to select segment of sketch entity '{ent_id}'"
1609
+ )
1610
+ else:
1611
+ adapter._attempt(
1612
+ lambda e=entity: _sw_type_info.flag_methods(e, "ISketchSegment"),
1613
+ default=0,
1614
+ )
1615
+ ok = entity.Select4(True, select_data)
1616
+ if not ok:
1617
+ raise Exception(f"Failed to select sketch entity '{ent_id}'")
1618
+
1619
+
1620
+ def _sketch_linear_pattern_impl(
1621
+ adapter: Any,
1622
+ entities: list[str],
1623
+ direction_x: float,
1624
+ direction_y: float,
1625
+ spacing: float,
1626
+ count: int,
1627
+ ) -> AdapterResult[str]:
1628
+ """Create a linear sketch pattern from the registered seed entities.
1629
+
1630
+ Selects ``entities`` then calls
1631
+ ``ISketchManager::CreateLinearSketchStepAndRepeat(NumX, NumY, SpacingX,
1632
+ SpacingY, AngleX, AngleY, DeleteInstances, XSpacingDim, YSpacingDim,
1633
+ AngleDim, CreateNumOfInstancesDimInXDir, CreateNumOfInstancesDimInYDir)``.
1634
+ The COM API expects spacing in metres and angles in radians; this
1635
+ function does both conversions internally.
1636
+
1637
+ The ``(direction_x, direction_y)`` vector defines pattern direction 1
1638
+ (``AngleX``). Direction 2 (``AngleY``) is set perpendicular so the SW
1639
+ UI shows a clean axis frame, but ``NumY`` stays at 1 so no second-axis
1640
+ instances are produced.
1641
+
1642
+ Args:
1643
+ adapter: A ``PyWin32Adapter`` with an open sketch.
1644
+ entities: Registered entity IDs to pattern. Must be non-empty.
1645
+ direction_x: Pattern direction X component (unit-less, any non-zero
1646
+ vector is normalised internally via ``atan2``).
1647
+ direction_y: Pattern direction Y component.
1648
+ spacing: Distance between instances in **millimetres**.
1649
+ count: Total number of instances (including the seed). Must be at
1650
+ least 2.
1651
+
1652
+ Returns:
1653
+ AdapterResult[str]: On success, ``data`` is a synthesised
1654
+ ``"LinearPattern_<count>x<spacing>_<rand>"`` ID — the COM method
1655
+ only returns a boolean, so no usable SW handle exists.
1656
+
1657
+ Raises:
1658
+ Exception: Propagated through ``_handle_com_operation`` when
1659
+ inputs are invalid, an entity isn't registered, or
1660
+ ``CreateLinearSketchStepAndRepeat`` returns ``False``.
1661
+
1662
+ Example::
1663
+
1664
+ # 5 copies of Line_1 along +X, 15 mm apart
1665
+ result = pywin32_sketch_ops.sketch_linear_pattern(
1666
+ adapter, ["Line_1"], direction_x=1.0, direction_y=0.0,
1667
+ spacing=15.0, count=5
1668
+ )
1669
+ print(result.data) # "LinearPattern_5x15.0_8765"
1670
+ """
1671
+ if not adapter.currentSketchManager:
1672
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
1673
+
1674
+ def _linear_pattern_operation() -> str:
1675
+ if not entities:
1676
+ raise Exception("sketch_linear_pattern requires at least one entity")
1677
+ if count < 2:
1678
+ raise Exception("sketch_linear_pattern requires count >= 2")
1679
+ if spacing <= 0:
1680
+ raise Exception("sketch_linear_pattern requires spacing > 0")
1681
+ if math.hypot(direction_x, direction_y) < 1e-9:
1682
+ raise Exception(
1683
+ "sketch_linear_pattern requires a non-zero direction vector"
1684
+ )
1685
+
1686
+ # Validate every entity ID exists in the registry before mutating
1687
+ # selection state, so an unknown ID doesn't leave SW with a
1688
+ # half-built selection.
1689
+ for ent_id in entities:
1690
+ if ent_id not in adapter._sketch_entities:
1691
+ raise Exception(
1692
+ f"Unknown sketch entity '{ent_id}'. Use IDs returned by "
1693
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1694
+ )
1695
+
1696
+ # Clear any pre-existing selection so SW only sees the seed entities.
1697
+ adapter.currentModel.ClearSelection2(True)
1698
+ try:
1699
+ _select_sketch_entities(adapter, entities, mark=0)
1700
+
1701
+ angle_x = math.atan2(direction_y, direction_x)
1702
+ # Direction 2 (Y) goes 90° from direction 1; NumY=1 keeps it
1703
+ # single-row so the second-axis spacing/angle aren't actually
1704
+ # consumed, but SW still wants well-formed values.
1705
+ angle_y = angle_x + math.pi / 2.0
1706
+
1707
+ ok = adapter.currentSketchManager.CreateLinearSketchStepAndRepeat(
1708
+ count, # NumX
1709
+ 1, # NumY
1710
+ spacing / 1000.0, # SpacingX (metres)
1711
+ 0.0, # SpacingY
1712
+ angle_x, # AngleX (radians)
1713
+ angle_y, # AngleY (radians)
1714
+ "", # DeleteInstances
1715
+ False, # XSpacingDim
1716
+ False, # YSpacingDim
1717
+ False, # AngleDim
1718
+ False, # CreateNumOfInstancesDimInXDir
1719
+ False, # CreateNumOfInstancesDimInYDir
1720
+ )
1721
+ if not ok:
1722
+ raise Exception("Failed to create linear sketch pattern")
1723
+ finally:
1724
+ adapter.currentModel.ClearSelection2(True)
1725
+
1726
+ return f"LinearPattern_{count}x{spacing}_{int(time.time() * 1000) % 10000}"
1727
+
1728
+ return cast(
1729
+ AdapterResult[str],
1730
+ adapter._handle_com_operation(
1731
+ "sketch_linear_pattern", _linear_pattern_operation
1732
+ ),
1733
+ )
1734
+
1735
+
1736
+ def _sketch_circular_pattern_impl(
1737
+ adapter: Any,
1738
+ entities: list[str],
1739
+ angle: float,
1740
+ count: int,
1741
+ ) -> AdapterResult[str]:
1742
+ """Create a circular sketch pattern from the registered seed entities.
1743
+
1744
+ Selects ``entities`` then calls
1745
+ ``ISketchManager::CreateCircularSketchStepAndRepeat(ArcRadius, ArcAngle,
1746
+ PatternNum, PatternSpacing, PatternRotate, DeleteInstances, RadiusDim,
1747
+ AngleDim, CreateNumOfInstancesDim)``.
1748
+
1749
+ ``ArcRadius`` is the radius at which SW places the pattern instances
1750
+ — getting it wrong puts every copy on a tight cluster around the
1751
+ seed rather than the intended ring. We derive it from the first
1752
+ registered entity's centre (via ``ISketchArc.GetCenterPoint`` once
1753
+ the dispatch is flagged) relative to the sketch origin. The COM
1754
+ API has no pattern-centre parameter — the rotation axis is implied
1755
+ by ``(ArcRadius, ArcAngle)`` relative to the seed, so the rotation
1756
+ axis is always the **sketch origin**. Callers who want a different
1757
+ pattern centre must position the seed relative to the desired
1758
+ centre (then translate the sketch as a whole, or use a sketch
1759
+ plane offset).
1760
+
1761
+ ``PatternSpacing`` is the per-instance angle in radians. For a
1762
+ full 360° pattern we use ``angle / count`` so the last instance
1763
+ lands one slot before the seed (tiles cleanly). For partial sweeps
1764
+ (< 360°) we use ``angle / (count - 1)`` so the last instance lands
1765
+ at the full requested angle.
1766
+
1767
+ Args:
1768
+ adapter: A ``PyWin32Adapter`` with an open sketch.
1769
+ entities: Registered entity IDs to pattern. Must be non-empty.
1770
+ angle: Total swept angle in **degrees** (e.g. ``360`` for a full
1771
+ ring or ``180`` for a half-circle). Must be > 0.
1772
+ count: Total number of instances (including the seed). Must be
1773
+ at least 2.
1774
+
1775
+ Returns:
1776
+ AdapterResult[str]: On success, ``data`` is a synthesised
1777
+ ``"CircularPattern_<count>x<angle>deg_<rand>"`` ID — the COM
1778
+ method only returns a boolean.
1779
+
1780
+ Raises:
1781
+ Exception: Propagated through ``_handle_com_operation`` when
1782
+ inputs are invalid, an entity isn't registered, or the COM
1783
+ call returns ``False``.
1784
+
1785
+ Example::
1786
+
1787
+ # 6 evenly-spaced copies of Circle_1 around the origin
1788
+ result = pywin32_sketch_ops.sketch_circular_pattern(
1789
+ adapter, ["Circle_1"], 360.0, 6
1790
+ )
1791
+ print(result.data) # "CircularPattern_6x360.0deg_4321"
1792
+ """
1793
+ if not adapter.currentSketchManager:
1794
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
1795
+
1796
+ def _circular_pattern_operation() -> str:
1797
+ if not entities:
1798
+ raise Exception("sketch_circular_pattern requires at least one entity")
1799
+ if count < 2:
1800
+ raise Exception("sketch_circular_pattern requires count >= 2")
1801
+ if angle <= 0:
1802
+ raise Exception("sketch_circular_pattern requires angle > 0")
1803
+
1804
+ # Validate every entity ID exists before mutating selection state.
1805
+ for ent_id in entities:
1806
+ if ent_id not in adapter._sketch_entities:
1807
+ raise Exception(
1808
+ f"Unknown sketch entity '{ent_id}'. Use IDs returned by "
1809
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1810
+ )
1811
+
1812
+ adapter.currentModel.ClearSelection2(True)
1813
+ try:
1814
+ _select_sketch_entities(adapter, entities, mark=0)
1815
+
1816
+ # The COM API doesn't take pattern-centre coordinates directly.
1817
+ # Instead:
1818
+ # ``ArcRadius`` = distance from the seed to the rotation axis.
1819
+ # ``ArcAngle`` = angle (radians) **from the seed toward the
1820
+ # rotation axis**, NOT a starting angle. With
1821
+ # ArcAngle=0 SW puts the axis at +X relative
1822
+ # to the seed.
1823
+ # We recover both by reading the seed's centre via
1824
+ # ``ISketchArc.GetCenterPoint`` (after flagging the dispatch
1825
+ # with sw_type_info — pywin32 late binding otherwise resolves
1826
+ # the method as a tuple-valued property) and computing the
1827
+ # offset to the rotation-axis origin. Caller-supplied
1828
+ # ``(center_x, center_y)`` is rejected above when non-zero, so
1829
+ # the rotation axis is always at the sketch origin here.
1830
+ #
1831
+ # Without this fix, ArcAngle=0 + ArcRadius=1 mm puts every
1832
+ # instance on a tiny ring beside the seed instead of the
1833
+ # intended pattern (caught by the #17 live screenshot). Falls
1834
+ # back to placing the axis at angle π from the seed if
1835
+ # GetCenterPoint isn't available; that still works when the
1836
+ # user positions the seed on the +X side of the origin.
1837
+ first_entity = adapter._sketch_entities.get(entities[0])
1838
+
1839
+ seed_xy: tuple[float, float] | None = None
1840
+
1841
+ # Group entities (polygons via ``CreatePolygon``, rectangles via
1842
+ # ``CreateCornerRectangle``) register as a SAFEARRAY of segments
1843
+ # — a tuple, not a single dispatch — so the ``GetCenterPoint``
1844
+ # path below would silently fall back to a 1 mm placeholder
1845
+ # radius. ``_add_polygon_impl`` / ``_add_rectangle_impl`` stash
1846
+ # the seed centre in ``_sketch_entity_centers`` at register time;
1847
+ # use that. Any future tuple-registering primitive that forgets
1848
+ # to populate the cache falls through to the clear-error branch
1849
+ # below.
1850
+ if isinstance(first_entity, (list, tuple)):
1851
+ seed_xy = adapter._sketch_entity_centers.get(entities[0])
1852
+ if seed_xy is None:
1853
+ # Polygons and rectangles always reach the cached branch
1854
+ # above, so naming them here would be misleading — the
1855
+ # caller is hitting this with a group seed whose
1856
+ # ``add_*`` writer never stashed a centre. List only the
1857
+ # always-works primitive types.
1858
+ raise Exception(
1859
+ f"sketch_circular_pattern can't derive the seed centre "
1860
+ f"for '{entities[0]}' — this entity type registers as "
1861
+ f"a group (tuple of segments) with no cached centre. "
1862
+ f"Use a circle, arc, or ellipse seed."
1863
+ )
1864
+
1865
+ if seed_xy is None and first_entity is not None:
1866
+ # GetCenterPoint lives on multiple sketch-entity interfaces
1867
+ # (ISketchArc for arcs/circles, ISketchEllipse for ellipses),
1868
+ # all with the same zero-arg signature. Flag every interface
1869
+ # we might encounter so the lookup works regardless of seed
1870
+ # type — without this, an ellipse seed silently resolves
1871
+ # GetCenterPoint as a property and the pattern is laid out
1872
+ # at a bogus 1 mm radius.
1873
+ adapter._attempt(
1874
+ lambda: _sw_type_info.flag_methods(
1875
+ first_entity,
1876
+ "ISketchArc",
1877
+ "ISketchEllipse",
1878
+ ),
1879
+ default=0,
1880
+ )
1881
+ point = adapter._attempt(lambda: first_entity.GetCenterPoint())
1882
+ if point is not None and hasattr(point, "__len__") and len(point) >= 2:
1883
+ seed_xy = (float(point[0]) * 1000.0, float(point[1]) * 1000.0)
1884
+
1885
+ # Single-dispatch seeds without ``GetCenterPoint`` (line, spline,
1886
+ # centerline) used to fall through here with ``seed_xy is None``
1887
+ # and silently produce a 1 mm placeholder pattern at the wrong
1888
+ # radius — same bug class as the polygon/rectangle tuple case,
1889
+ # but quieter because no exception fires. Surface a clear error
1890
+ # naming the offending seed instead.
1891
+ if seed_xy is None:
1892
+ raise Exception(
1893
+ f"sketch_circular_pattern can't derive the seed centre "
1894
+ f"for '{entities[0]}' — this seed type has no "
1895
+ f"GetCenterPoint dispatch on ISketchArc/ISketchEllipse. "
1896
+ f"Use a circle, arc, ellipse, or polygon seed."
1897
+ )
1898
+
1899
+ # Rotation axis is always the sketch origin (0, 0) — SW's
1900
+ # ``CreateCircularSketchStepAndRepeat`` has no pattern-centre
1901
+ # parameter, so dx/dy from seed to axis is just ``-seed``.
1902
+ dx_mm = -seed_xy[0]
1903
+ dy_mm = -seed_xy[1]
1904
+ arc_radius_mm = math.hypot(dx_mm, dy_mm)
1905
+ arc_angle_rad = math.atan2(dy_mm, dx_mm) if arc_radius_mm > 0 else 0.0
1906
+
1907
+ # ``CreateCircularSketchStepAndRepeat`` silently returns False on
1908
+ # negative ``ArcAngle`` values — the bundled VBA/C# examples all
1909
+ # pass positive radians (e.g. ``4.732863934409`` ≈ 271°). Python's
1910
+ # ``atan2`` produces ``-π`` for a seed on the +X axis (because
1911
+ # ``-seed_xy[1]`` is ``-0.0``), which is geometrically equivalent
1912
+ # to ``+π`` but fails the COM call. Normalise to ``[0, 2π)``.
1913
+ if arc_angle_rad < 0:
1914
+ arc_angle_rad += 2.0 * math.pi
1915
+
1916
+ # 1 mm minimum keeps SW from silently rejecting the call when
1917
+ # the seed sits right on the pattern centre.
1918
+ arc_radius_m = max(arc_radius_mm / 1000.0, 0.001)
1919
+ # For a full 360° pattern, ``angle / count`` keeps adjacent
1920
+ # instances evenly spaced (instance ``count`` would coincide
1921
+ # with the seed). For partial sweeps the last instance should
1922
+ # land at the full requested angle, so divide by ``count - 1``
1923
+ # instead — otherwise ``angle=180, count=3`` would reach only
1924
+ # 120°.
1925
+ angle_rad = math.radians(angle)
1926
+ if abs(angle - 360.0) < 1e-9:
1927
+ pattern_spacing = angle_rad / count
1928
+ else:
1929
+ pattern_spacing = angle_rad / (count - 1)
1930
+
1931
+ ok = adapter.currentSketchManager.CreateCircularSketchStepAndRepeat(
1932
+ arc_radius_m, # ArcRadius — seed-to-axis distance (metres)
1933
+ arc_angle_rad, # ArcAngle — direction from seed to axis (radians)
1934
+ count, # PatternNum
1935
+ pattern_spacing, # PatternSpacing (radians)
1936
+ True, # PatternRotate
1937
+ "", # DeleteInstances
1938
+ False, # RadiusDim
1939
+ False, # AngleDim
1940
+ False, # CreateNumOfInstancesDim
1941
+ )
1942
+ if not ok:
1943
+ raise Exception("Failed to create circular sketch pattern")
1944
+ finally:
1945
+ adapter.currentModel.ClearSelection2(True)
1946
+
1947
+ return f"CircularPattern_{count}x{angle}deg_{int(time.time() * 1000) % 10000}"
1948
+
1949
+ return cast(
1950
+ AdapterResult[str],
1951
+ adapter._handle_com_operation(
1952
+ "sketch_circular_pattern", _circular_pattern_operation
1953
+ ),
1954
+ )
1955
+
1956
+
1957
+ def _sketch_mirror_impl(
1958
+ adapter: Any, entities: list[str], mirror_line: str
1959
+ ) -> AdapterResult[str]:
1960
+ """Mirror sketch entities about a registered centreline.
1961
+
1962
+ Selects the ``entities`` under mark **1** and the ``mirror_line``
1963
+ centreline under mark **2** — those are the marks SOLIDWORKS expects
1964
+ per the ``IModelDoc2::SketchMirror`` documentation — then invokes the
1965
+ method with no arguments. ``SketchMirror`` returns ``void``, so
1966
+ success is reported by the absence of a COM error and the resulting
1967
+ ID is a synthesised ``"Mirror_<mirror_line_id>_<rand>"`` string.
1968
+
1969
+ Args:
1970
+ adapter: A ``PyWin32Adapter`` with an open sketch.
1971
+ entities: Registered entity IDs to mirror. Must be non-empty.
1972
+ mirror_line: Registered entity ID of the centreline. Must be a
1973
+ value previously returned by ``add_centerline``.
1974
+
1975
+ Returns:
1976
+ AdapterResult[str]: On success, ``data`` is a synthesised mirror
1977
+ ID. On failure, ``status`` is ``ERROR`` with the COM error or a
1978
+ validation message describing the problem.
1979
+
1980
+ Raises:
1981
+ Exception: Propagated through ``_handle_com_operation`` when the
1982
+ inputs are invalid, an entity isn't in the registry, or the
1983
+ COM call raises.
1984
+
1985
+ Example::
1986
+
1987
+ result = pywin32_sketch_ops.sketch_mirror(
1988
+ adapter, ["Line_1", "Line_2"], "Centerline_1"
1989
+ )
1990
+ print(result.data) # "Mirror_Centerline_1_3456"
1991
+ """
1992
+ if not adapter.currentSketchManager:
1993
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
1994
+
1995
+ def _mirror_operation() -> str:
1996
+ if not entities:
1997
+ raise Exception("sketch_mirror requires at least one entity")
1998
+ if not mirror_line:
1999
+ raise Exception(
2000
+ "sketch_mirror requires a mirror_line entity ID (add_centerline)"
2001
+ )
2002
+ if mirror_line not in adapter._sketch_entities:
2003
+ raise Exception(
2004
+ f"Unknown mirror_line entity '{mirror_line}'. Use the ID "
2005
+ "returned by add_centerline."
2006
+ )
2007
+ # IModelDoc2::SketchMirror specifies that the mirror axis must be
2008
+ # a centreline; selecting any other segment under mark=2 silently
2009
+ # no-ops on SW. ``add_centerline`` returns IDs prefixed with
2010
+ # ``Centerline_``, so reject anything else up front.
2011
+ # TODO(#24-followup): replace the prefix-string parse with a
2012
+ # proper introspection of the entity's ``ConstructionGeometry``
2013
+ # property on the real adapter (still keep the prefix check on
2014
+ # the mock, where there's no real dispatch to inspect). The
2015
+ # prefix parse is brittle if either side renames its ID scheme;
2016
+ # the SW invariant we're really enforcing is "this segment has
2017
+ # construction-geometry flagged".
2018
+ if not mirror_line.startswith("Centerline_"):
2019
+ raise Exception(
2020
+ f"mirror_line must be a centerline (from add_centerline), "
2021
+ f"got '{mirror_line}'"
2022
+ )
2023
+
2024
+ # Validate every source entity ID up front so an unknown ID does
2025
+ # not leave SW with a half-built selection state.
2026
+ for ent_id in entities:
2027
+ if ent_id not in adapter._sketch_entities:
2028
+ raise Exception(
2029
+ f"Unknown sketch entity '{ent_id}'. Use IDs returned by "
2030
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
2031
+ )
2032
+
2033
+ adapter.currentModel.ClearSelection2(True)
2034
+ try:
2035
+ # Mark 1 for the source segments per IModelDoc2::SketchMirror docs.
2036
+ _select_sketch_entities(adapter, entities, mark=1)
2037
+ # Mark 2 for the centreline.
2038
+ _select_sketch_entities(adapter, [mirror_line], mark=2)
2039
+
2040
+ # IModelDoc2::SketchMirror is VT_VOID — no return value, so a
2041
+ # successful invocation is its own success signal.
2042
+ adapter.currentModel.SketchMirror()
2043
+ finally:
2044
+ adapter.currentModel.ClearSelection2(True)
2045
+
2046
+ return f"Mirror_{mirror_line}_{int(time.time() * 1000) % 10000}"
2047
+
2048
+ return cast(
2049
+ AdapterResult[str],
2050
+ adapter._handle_com_operation("sketch_mirror", _mirror_operation),
2051
+ )
2052
+
2053
+
2054
+ def _sketch_offset_impl(
2055
+ adapter: Any,
2056
+ entities: list[str],
2057
+ offset_distance: float,
2058
+ reverse_direction: bool,
2059
+ ) -> AdapterResult[str]:
2060
+ """Offset selected sketch entities by a fixed distance.
2061
+
2062
+ Selects ``entities`` then calls
2063
+ ``ISketchManager::SketchOffset2(Offset, BothDirections, Chain, CapEnds,
2064
+ MakeConstruction, AddDimensions)``.
2065
+
2066
+ ``Offset`` is in metres; a negative value flips the offset direction
2067
+ per the COM docs, so ``reverse_direction=True`` is implemented by
2068
+ negating the value rather than relying on ``BothDirections``.
2069
+
2070
+ The remaining flags are pinned to predictable defaults so the call is
2071
+ deterministic for automation: no caps, no auto-conversion to
2072
+ construction geometry, no on-canvas dimension, and ``Chain=False`` so
2073
+ only the supplied entities are offset (rather than the entire
2074
+ contour they belong to).
2075
+
2076
+ Args:
2077
+ adapter: A ``PyWin32Adapter`` with an open sketch.
2078
+ entities: Registered entity IDs to offset. Must be non-empty.
2079
+ offset_distance: Offset distance in **millimetres**. Must be > 0
2080
+ — the direction is controlled by ``reverse_direction``, not
2081
+ the sign.
2082
+ reverse_direction: When ``True``, offset in the opposite of SW's
2083
+ default direction (effectively a negated ``Offset`` argument).
2084
+
2085
+ Returns:
2086
+ AdapterResult[str]: On success, ``data`` is a synthesised
2087
+ ``"Offset_<distance>_<direction>_<rand>"`` ID.
2088
+
2089
+ Raises:
2090
+ Exception: Propagated through ``_handle_com_operation`` when
2091
+ inputs are invalid, an entity isn't registered, or the COM
2092
+ call returns ``False``.
2093
+
2094
+ Example::
2095
+
2096
+ result = pywin32_sketch_ops.sketch_offset(
2097
+ adapter, ["Line_1"], 5.0, reverse_direction=False
2098
+ )
2099
+ print(result.data) # "Offset_5.0_outward_9876"
2100
+ """
2101
+ if not adapter.currentSketchManager:
2102
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active sketch")
2103
+
2104
+ def _offset_operation() -> str:
2105
+ if not entities:
2106
+ raise Exception("sketch_offset requires at least one entity")
2107
+ if offset_distance <= 0:
2108
+ raise Exception(
2109
+ "sketch_offset requires offset_distance > 0 — use "
2110
+ "reverse_direction to flip the side"
2111
+ )
2112
+
2113
+ # Validate every entity ID up front so an unknown ID does not
2114
+ # leave SW with a half-built selection.
2115
+ for ent_id in entities:
2116
+ if ent_id not in adapter._sketch_entities:
2117
+ raise Exception(
2118
+ f"Unknown sketch entity '{ent_id}'. Use IDs returned by "
2119
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
2120
+ )
2121
+
2122
+ adapter.currentModel.ClearSelection2(True)
2123
+ try:
2124
+ _select_sketch_entities(adapter, entities, mark=0)
2125
+
2126
+ # Negative Offset flips the side per SketchOffset2 docs.
2127
+ offset_m = (
2128
+ -offset_distance / 1000.0
2129
+ if reverse_direction
2130
+ else offset_distance / 1000.0
2131
+ )
2132
+
2133
+ adapter._attempt(
2134
+ lambda: _sw_type_info.flag_methods(
2135
+ adapter.currentSketchManager, "ISketchManager"
2136
+ ),
2137
+ default=0,
2138
+ )
2139
+ ok = adapter.currentSketchManager.SketchOffset2(
2140
+ offset_m, # Offset (metres)
2141
+ False, # BothDirections
2142
+ False, # Chain
2143
+ 0, # CapEnds (swSkOffsetCapEndType_e: 0 = no caps)
2144
+ 0, # MakeConstruction (swSkOffsetMakeConstructionType_e: 0 = none)
2145
+ False, # AddDimensions
2146
+ )
2147
+ if not ok:
2148
+ raise Exception("Failed to offset sketch entities")
2149
+ finally:
2150
+ adapter.currentModel.ClearSelection2(True)
2151
+
2152
+ direction = "inward" if reverse_direction else "outward"
2153
+ return f"Offset_{offset_distance}_{direction}_{int(time.time() * 1000) % 10000}"
2154
+
2155
+ return cast(
2156
+ AdapterResult[str],
2157
+ adapter._handle_com_operation("sketch_offset", _offset_operation),
2158
+ )
2159
+
2160
+
2161
+ def _exit_sketch_impl(adapter: Any) -> AdapterResult[None]:
2162
+ """Exit any sketch-edit mode the active model is in and reset adapter state.
2163
+
2164
+ The previous implementation trusted ``adapter.currentSketchManager`` —
2165
+ a Python-side handle populated only by ``create_sketch`` on **this**
2166
+ adapter instance. A fresh adapter pointing at a SolidWorks process
2167
+ that already has a sketch open (from a crashed prior run, an
2168
+ aborted automation, or a manual user edit) would report
2169
+ ``WARNING: "No active sketch to exit"`` while SW was still sitting
2170
+ in sketch-edit mode — and every subsequent ``create_sketch`` then
2171
+ failed with ``Failed to select plane: Front Plane`` because SW
2172
+ can't open a new sketch while one is already active.
2173
+
2174
+ Now queries ``IModelDoc2.GetActiveSketch2`` to find out what SW
2175
+ actually has open, and toggles ``SketchManager.InsertSketch(True)``
2176
+ when either SW or the adapter thinks a sketch is in edit mode.
2177
+ Adapter-side state is always cleared on success.
2178
+
2179
+ Args:
2180
+ adapter: A ``PyWin32Adapter`` with a non-``None`` ``currentModel``.
2181
+
2182
+ Returns:
2183
+ AdapterResult[None]: On success, ``status`` is ``SUCCESS``.
2184
+ When neither SW nor the adapter has an active sketch,
2185
+ ``status`` is ``WARNING`` (already-exited is not a failure).
2186
+ When ``currentModel`` is ``None``, also returns ``WARNING`` so
2187
+ defensive cleanup callers don't see spurious errors.
2188
+
2189
+ Raises:
2190
+ Exception: Propagated through ``_handle_com_operation`` when the
2191
+ ``InsertSketch`` call itself raises.
2192
+
2193
+ Example::
2194
+
2195
+ pywin32_sketch_ops.add_line(adapter, 0, 0, 50, 0)
2196
+ pywin32_sketch_ops.exit_sketch(adapter)
2197
+ # adapter.currentSketch is now None and SW is out of sketch-edit mode
2198
+ """
2199
+ if adapter.currentModel is None:
2200
+ # No document means nothing can be in sketch-edit mode either.
2201
+ # Match the legacy "already exited" semantics so cleanup callers
2202
+ # that fire exit_sketch defensively don't see spurious errors.
2203
+ return AdapterResult(
2204
+ status=AdapterResultStatus.WARNING,
2205
+ error="No active sketch to exit",
2206
+ )
2207
+
2208
+ def _exit_operation() -> str:
2209
+ # ``GetActiveSketch2`` is a real zero-arg method on IModelDoc2.
2210
+ # Without flagging, pywin32 late binding resolves it as a property
2211
+ # and SW returns ``Member not found`` — the same root cause as
2212
+ # the cross-thread bugs in runbook #5.
2213
+ adapter._attempt(
2214
+ lambda: _sw_type_info.flag_methods(adapter.currentModel, "IModelDoc2"),
2215
+ default=0,
2216
+ )
2217
+
2218
+ sw_active = adapter._attempt(lambda: adapter.currentModel.GetActiveSketch2())
2219
+ adapter_active = adapter.currentSketchManager
2220
+
2221
+ # Already out of sketch-edit mode — clean up adapter state so a
2222
+ # future create_sketch starts from a known-good baseline, then
2223
+ # warn. Using ``data`` to signal "no_op" lets callers tell the
2224
+ # difference between "I exited a sketch" and "nothing was open".
2225
+ if sw_active is None and adapter_active is None:
2226
+ return "no_active_sketch"
2227
+
2228
+ # Prefer the adapter's SketchManager handle when available (it was
2229
+ # captured at create_sketch time on the executor thread, so it's
2230
+ # apartment-safe); fall back to a fresh ``currentModel.SketchManager``
2231
+ # for the SW-only state case.
2232
+ sketch_manager = adapter_active or adapter.currentModel.SketchManager
2233
+ adapter._attempt(
2234
+ lambda: _sw_type_info.flag_methods(sketch_manager, "ISketchManager"),
2235
+ default=0,
2236
+ )
2237
+ # Hand the document back with SolidWorks' default (inference on) for
2238
+ # interactive sketching; create_sketch turned AddToDB on.
2239
+ adapter._attempt(lambda: setattr(sketch_manager, "AddToDB", False))
2240
+ sketch_manager.InsertSketch(True)
2241
+ adapter.currentSketch = None
2242
+ adapter.currentSketchManager = None
2243
+ adapter._reset_sketch_entity_registry()
2244
+ return "exited"
2245
+
2246
+ result = cast(
2247
+ AdapterResult[str],
2248
+ adapter._handle_com_operation("exit_sketch", _exit_operation),
2249
+ )
2250
+ # Translate "no sketch was open" into a WARNING so callers that branch
2251
+ # on ``is_error`` still treat already-exited as benign. ``data`` is
2252
+ # the operation tag; ``error`` carries the human message.
2253
+ if result.is_success and result.data == "no_active_sketch":
2254
+ return cast(
2255
+ AdapterResult[None],
2256
+ AdapterResult(
2257
+ status=AdapterResultStatus.WARNING,
2258
+ error="No active sketch to exit",
2259
+ ),
2260
+ )
2261
+ if result.is_success:
2262
+ return cast(
2263
+ AdapterResult[None],
2264
+ AdapterResult(status=AdapterResultStatus.SUCCESS, data=None),
2265
+ )
2266
+ return cast(AdapterResult[None], result)
2267
+
2268
+
2269
+ def _to_bool(value: Any) -> bool | None:
2270
+ """Normalise an arbitrary raw value to a Python bool.
2271
+
2272
+ Handles booleans, 0/1 integers and floats, truthy/falsy strings, and
2273
+ single-element sequences.
2274
+
2275
+ Args:
2276
+ value: Raw value from a SolidWorks COM attribute or method return.
2277
+
2278
+ Returns:
2279
+ bool | None: ``True``, ``False``, or ``None`` when the value is
2280
+ ambiguous or unrecognised.
2281
+ """
2282
+ if isinstance(value, bool):
2283
+ return value
2284
+ if isinstance(value, (int, float)) and int(value) in (0, 1):
2285
+ return bool(int(value))
2286
+ if isinstance(value, str):
2287
+ normalized = value.strip().lower()
2288
+ if normalized in {"true", "yes", "fully_defined", "fully defined"}:
2289
+ return True
2290
+ if normalized in {
2291
+ "false",
2292
+ "no",
2293
+ "under_defined",
2294
+ "under defined",
2295
+ "over_defined",
2296
+ "over defined",
2297
+ }:
2298
+ return False
2299
+ if isinstance(value, (list, tuple)) and value:
2300
+ return _to_bool(value[0])
2301
+ return None
2302
+
2303
+
2304
+ def _to_number(value: Any) -> float | None:
2305
+ """Normalise an arbitrary raw value to a float.
2306
+
2307
+ Args:
2308
+ value: Raw value from a COM attribute or method return.
2309
+
2310
+ Returns:
2311
+ float | None: Numeric value, or ``None`` when conversion fails.
2312
+ """
2313
+ if isinstance(value, (int, float)):
2314
+ return float(value)
2315
+ if isinstance(value, str):
2316
+ try:
2317
+ return float(value.strip())
2318
+ except ValueError:
2319
+ return None
2320
+ if isinstance(value, (list, tuple)) and value:
2321
+ return _to_number(value[0])
2322
+ return None
2323
+
2324
+
2325
+ def _probe_to_flag(source: str, raw: Any) -> bool | None:
2326
+ """Interpret a raw probe result according to the property-name semantics.
2327
+
2328
+ The SolidWorks API uses both positive and negative naming conventions
2329
+ (``IsFullyConstrained`` vs ``IsUnderDefined``) so the source name is
2330
+ used as a hint when interpreting the raw value.
2331
+
2332
+ Args:
2333
+ source: The name of the API attribute that produced ``raw``, e.g.
2334
+ ``"IsFullyConstrained"`` or ``"IsUnderDefined"``.
2335
+ raw: The raw return value from the COM attribute.
2336
+
2337
+ Returns:
2338
+ bool | None: ``True`` if the sketch is fully defined, ``False`` if
2339
+ not, or ``None`` when interpretation is impossible.
2340
+ """
2341
+ source_lower = source.lower()
2342
+ text_value = str(raw).strip().lower() if raw is not None else ""
2343
+ numeric_value = _to_number(raw)
2344
+
2345
+ if "underdefined" in source_lower or "under_defined" in source_lower:
2346
+ if numeric_value is not None:
2347
+ return numeric_value == 0
2348
+ bool_value = _to_bool(raw)
2349
+ if bool_value is not None:
2350
+ return not bool_value
2351
+
2352
+ if "overdefined" in source_lower or "over_defined" in source_lower:
2353
+ if numeric_value is not None and numeric_value > 0:
2354
+ return False
2355
+ bool_value = _to_bool(raw)
2356
+ if bool_value is True:
2357
+ return False
2358
+
2359
+ if "fullydefined" in source_lower or "fully_defined" in source_lower:
2360
+ bool_value = _to_bool(raw)
2361
+ if bool_value is not None:
2362
+ return bool_value
2363
+
2364
+ if "fully" in text_value and "under" not in text_value and "over" not in text_value:
2365
+ return True
2366
+ if "under" in text_value or "over" in text_value:
2367
+ return False
2368
+
2369
+ return _to_bool(raw)
2370
+
2371
+
2372
+ def _state_from_bool(flag: bool | None) -> str:
2373
+ """Convert a nullable bool flag to a descriptive state string.
2374
+
2375
+ Args:
2376
+ flag: The fully-defined flag value.
2377
+
2378
+ Returns:
2379
+ str: ``"fully_defined"``, ``"not_fully_defined"``, or
2380
+ ``"unknown"``.
2381
+ """
2382
+ if flag is True:
2383
+ return "fully_defined"
2384
+ if flag is False:
2385
+ return "not_fully_defined"
2386
+ return "unknown"
2387
+
2388
+
2389
+ def _check_sketch_fully_defined_impl(
2390
+ adapter: Any,
2391
+ sketch_name: str | None = None,
2392
+ ) -> AdapterResult[dict[str, Any]]:
2393
+ """Check whether a sketch is fully constrained (fully defined).
2394
+
2395
+ Queries the SolidWorks sketch object through multiple probe methods
2396
+ because different API versions expose this information differently:
2397
+
2398
+ * ``ISketch.IsFullyConstrained`` (SolidWorks 2018+)
2399
+ * ``ISketch.GetStatus`` returning a status integer
2400
+ * ``ISketch.FullyDefined`` property
2401
+
2402
+ All raw values are normalised through ``_probe_to_flag`` which handles
2403
+ bool, int, float, and string representations. When the targeted sketch
2404
+ cannot be found, the active model\'s current sketch is probed instead.
2405
+
2406
+ Args:
2407
+ adapter: A ``PyWin32Adapter`` with a non-``None`` ``currentModel``.
2408
+ sketch_name: Name of the sketch feature as it appears in the
2409
+ FeatureManager tree (e.g. ``"Sketch1"``). When ``None``, the
2410
+ adapter\'s ``_last_sketch_name`` is used, then the currently open
2411
+ sketch.
2412
+
2413
+ Returns:
2414
+ AdapterResult[dict[str, Any]]: On success, ``data`` is a dict:
2415
+
2416
+ .. code-block:: python
2417
+
2418
+ {
2419
+ "sketch_name": "Sketch1",
2420
+ "fully_defined": True, # bool or None
2421
+ "state": "fully_defined", # or "not_fully_defined" / "unknown"
2422
+ "source_api": "IsFullyConstrained", # probe that succeeded
2423
+ }
2424
+
2425
+ On failure, ``status`` is ``ERROR``.
2426
+
2427
+ Raises:
2428
+ Exception: Propagated through ``_handle_com_operation`` on unexpected
2429
+ COM errors.
2430
+
2431
+ Example::
2432
+
2433
+ result = pywin32_sketch_ops.check_sketch_fully_defined(adapter, "Sketch1")
2434
+ if result.data["fully_defined"]:
2435
+ print("Sketch is fully constrained")
2436
+ else:
2437
+ print("Sketch state:", result.data["state"])
2438
+ """
2439
+ if not adapter.currentModel:
2440
+ return AdapterResult(status=AdapterResultStatus.ERROR, error="No active model")
2441
+
2442
+ def _get_sketch_payload() -> dict[str, Any]:
2443
+ """Main inner closure that locates the sketch and probes its constraint status.
2444
+
2445
+ Resolves the sketch feature by name (falling back to the last-known
2446
+ name and then the currently open sketch). Iterates through multiple
2447
+ API probes in priority order, stops at the first conclusive result.
2448
+
2449
+ Returns:
2450
+ dict[str, Any]: Payload dict with keys ``sketch_name``,
2451
+ ``fully_defined``, ``state``, and ``source_api``.
2452
+
2453
+ Raises:
2454
+ Exception: On unexpected COM errors during feature lookup.
2455
+ """
2456
+ sketch_feature = None
2457
+ sketch_obj = None
2458
+ resolved_name = sketch_name
2459
+
2460
+ if sketch_name:
2461
+ sketch_feature = adapter._attempt(
2462
+ lambda: adapter.currentModel.FeatureByName(sketch_name), default=None
2463
+ )
2464
+ if not sketch_feature:
2465
+ raise Exception(f"Sketch not found: {sketch_name}")
2466
+ sketch_obj = adapter._attempt(
2467
+ lambda: sketch_feature.GetSpecificFeature2(), default=None
2468
+ )
2469
+ else:
2470
+ sketch_obj = adapter.currentSketch or adapter._attempt(
2471
+ lambda: adapter.currentModel.GetActiveSketch2(), default=None
2472
+ )
2473
+ if sketch_obj is None and adapter._last_sketch_name:
2474
+ sketch_feature = adapter._attempt(
2475
+ lambda: adapter.currentModel.FeatureByName(
2476
+ adapter._last_sketch_name
2477
+ ),
2478
+ default=None,
2479
+ )
2480
+ sketch_obj = adapter._attempt(
2481
+ lambda: sketch_feature.GetSpecificFeature2(), default=None
2482
+ )
2483
+ resolved_name = adapter._last_sketch_name
2484
+
2485
+ if sketch_obj is None and sketch_feature is None:
2486
+ raise Exception("No active sketch found")
2487
+
2488
+ probes: list[tuple[str, Any]] = []
2489
+
2490
+ for label, obj in (("sketch", sketch_obj), ("feature", sketch_feature)):
2491
+ if obj is None:
2492
+ continue
2493
+ for attr_name in (
2494
+ "GetFullyDefined",
2495
+ "IsFullyDefined",
2496
+ "FullyDefined",
2497
+ "GetFullyDefinedStatus",
2498
+ "GetSketchStatus",
2499
+ "GetStatus",
2500
+ "GetUnderDefined",
2501
+ "IsUnderDefined",
2502
+ "UnderDefined",
2503
+ "GetUnderDefinedCount",
2504
+ "UnderDefinedCount",
2505
+ "GetUnderDefinedEntitiesCount",
2506
+ "GetUnderDefinedSketchEntitiesCount",
2507
+ "GetOverDefinedCount",
2508
+ "OverDefinedCount",
2509
+ "GetFullyDefineStatus",
2510
+ ):
2511
+ value = adapter._attempt(
2512
+ lambda o=obj, a=attr_name: adapter._get_attr_or_call(o, a),
2513
+ default=None,
2514
+ )
2515
+ if value is None:
2516
+ continue
2517
+ probes.append((f"{label}.{attr_name}", value))
2518
+
2519
+ for source, raw in probes:
2520
+ flag = _probe_to_flag(source, raw)
2521
+ if flag is None:
2522
+ continue
2523
+
2524
+ return {
2525
+ "sketch_name": resolved_name,
2526
+ "is_fully_defined": flag,
2527
+ "definition_state": _state_from_bool(flag),
2528
+ "source": source,
2529
+ "raw_status": raw,
2530
+ }
2531
+
2532
+ return {
2533
+ "sketch_name": resolved_name,
2534
+ "is_fully_defined": None,
2535
+ "definition_state": "unknown",
2536
+ "source": "unavailable",
2537
+ "raw_status": None,
2538
+ }
2539
+
2540
+ return cast(
2541
+ AdapterResult[dict[str, Any]],
2542
+ adapter._handle_com_operation(
2543
+ "check_sketch_fully_defined", _get_sketch_payload
2544
+ ),
2545
+ )