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,4016 @@
1
+ """Mock SolidWorks adapter for testing and development.
2
+
3
+ This adapter simulates SolidWorks operations for testing on non-Windows platforms or
4
+ when SolidWorks is not available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import math
11
+ import os
12
+ import random
13
+ import uuid
14
+ from collections.abc import Callable
15
+ from datetime import datetime
16
+ from typing import Any
17
+
18
+ from ..exceptions import SolidWorksOperationError
19
+ from .base import (
20
+ AdapterHealth,
21
+ AdapterResult,
22
+ AdapterResultStatus,
23
+ ExtrusionParameters,
24
+ LoftParameters,
25
+ MassProperties,
26
+ RevolveParameters,
27
+ SolidWorksAdapter,
28
+ SolidWorksFeature,
29
+ SolidWorksModel,
30
+ SweepParameters,
31
+ )
32
+ from .solidworks.drawing_standard import gtol_problem
33
+ from .solidworks.features import _AXIS_PLANE_PAIRS
34
+ from .solidworks.io import _normalise_unit_system as _normalise_mock_unit_system
35
+ from .solidworks.sketch import RELATION_NAME_MAP
36
+
37
+ #: Orientations the mock accepts for a drawing view. Kept in step with
38
+ #: ``_NAMED_VIEWS`` in ``adapters/solidworks/io.py`` by a test — the two lists
39
+ #: are maintained separately, and an orientation accepted live but rejected
40
+ #: here could never be covered, since the suite runs against the mock.
41
+ _MOCK_NAMED_VIEWS: frozenset[str] = frozenset(
42
+ {
43
+ "front",
44
+ "back",
45
+ "left",
46
+ "right",
47
+ "top",
48
+ "bottom",
49
+ "isometric",
50
+ "iso",
51
+ "trimetric",
52
+ "dimetric",
53
+ "current",
54
+ }
55
+ )
56
+
57
+
58
+ #: Canned assembly component tree used by ``list_features`` when a mock
59
+ #: Assembly's ``_assembly_components`` hasn't been configured by a test.
60
+ #: Two top-level Part components plus one nested sub-assembly one level
61
+ #: deep, per ``docs/agents`` / OpenSpec change ``assembly-aware-list-features``.
62
+ _DEFAULT_ASSEMBLY_COMPONENTS: dict[str, dict[str, Any]] = {
63
+ "PartA": {
64
+ "type": "Part",
65
+ "path": "Mock://PartA.sldprt",
66
+ "features": [
67
+ {"name": "Boss-Extrude1", "type": "Extrusion", "suppressed": False},
68
+ {"name": "Fillet1", "type": "Fillet", "suppressed": False},
69
+ ],
70
+ },
71
+ "PartB": {
72
+ "type": "Part",
73
+ "path": "Mock://PartB.sldprt",
74
+ "features": [
75
+ {"name": "Cut-Extrude1", "type": "Cut", "suppressed": False},
76
+ ],
77
+ },
78
+ "SubAssembly1": {
79
+ "type": "Assembly",
80
+ "path": "Mock://SubAssembly1.sldasm",
81
+ "features": [
82
+ {"name": "Mate1", "type": "Mate", "suppressed": False},
83
+ ],
84
+ "components": {
85
+ "PartC": {
86
+ "type": "Part",
87
+ "path": "Mock://PartC.sldprt",
88
+ "features": [
89
+ {"name": "Boss-Extrude1", "type": "Extrusion", "suppressed": False},
90
+ ],
91
+ },
92
+ },
93
+ },
94
+ }
95
+
96
+
97
+ class _BoolCallable:
98
+ """Compatibility shim that behaves as both bool and callable.
99
+
100
+ Args:
101
+ getter (Callable[[], bool]): The getter value.
102
+
103
+ Attributes:
104
+ _getter (Any): The getter value.
105
+ """
106
+
107
+ def __init__(self, getter: Callable[[], bool]) -> None:
108
+ """Initialize the bool callable.
109
+
110
+ Args:
111
+ getter (Callable[[], bool]): The getter value.
112
+
113
+ Returns:
114
+ None: None.
115
+ """
116
+ self._getter = getter
117
+
118
+ def __call__(self) -> bool:
119
+ """Build internal call.
120
+
121
+ Returns:
122
+ bool: True if call, otherwise False.
123
+ """
124
+ return bool(self._getter())
125
+
126
+ def __bool__(self) -> bool:
127
+ """Build internal bool.
128
+
129
+ Returns:
130
+ bool: True if bool, otherwise False.
131
+ """
132
+ return bool(self._getter())
133
+
134
+
135
+ class MockSolidWorksAdapter(SolidWorksAdapter):
136
+ """Mock adapter that simulates SolidWorks operations.
137
+
138
+ Args:
139
+ config (object | None): Configuration values for the operation. Defaults to None.
140
+
141
+ Attributes:
142
+ _connected (Any): The connected value.
143
+ _delays (Any): The delays value.
144
+ _is_connected_proxy (Any): The is connected proxy value.
145
+ _operation_count (Any): The operation count value.
146
+ _simulate_errors (Any): The simulate errors value.
147
+ """
148
+
149
+ @property
150
+ def _current_model(self) -> SolidWorksModel | None:
151
+ """The active mock document."""
152
+ return self.__dict__.get("_active_model")
153
+
154
+ @_current_model.setter
155
+ def _current_model(self, model: SolidWorksModel | None) -> None:
156
+ # Switching documents drops the selection, as in real SolidWorks:
157
+ # edges selected on one part must never feed an empty-edge_names
158
+ # fillet/chamfer on another.
159
+ if model is not self.__dict__.get("_active_model"):
160
+ self.__dict__.get("_selection", []).clear()
161
+ self.__dict__["_active_model"] = model
162
+
163
+ def __init__(self, config: object | None = None) -> None:
164
+ """Initialize the mock solid works adapter.
165
+
166
+ Args:
167
+ config (object | None): Configuration values for the operation. Defaults to None.
168
+
169
+ Returns:
170
+ None: None.
171
+ """
172
+ super().__init__(config)
173
+ cfg: dict[str, Any] = dict(self.config_dict)
174
+ self._connected = False
175
+ self._current_model = None
176
+ self._models: dict[str, SolidWorksModel] = {}
177
+ self._features: dict[str, SolidWorksFeature] = {}
178
+ # Names suppressed via suppress_feature. Kept beside _features rather
179
+ # than on SolidWorksFeature, which has no suppression field.
180
+ self._suppressed_features: set[str] = set()
181
+ # Component tree for an Assembly-type current model, keyed by
182
+ # component name. See ``_flatten_assembly_components`` for shape.
183
+ # Empty means "use the canned default fixture" (see list_features).
184
+ self._assembly_components: dict[str, dict[str, Any]] = {}
185
+ self._sketches: dict[str, str] = {}
186
+ self._current_sketch: str | None = None
187
+ # Names currently "selected" via select_feature / select_face_edges,
188
+ # so append and selection_count behave like the real selection set.
189
+ self._selection: list[str] = []
190
+ # Test hook: RawRebuildIssue objects rebuild_model should report, so
191
+ # the error/warning paths can be exercised without real geometry.
192
+ self.injected_rebuild_issues: list[Any] = []
193
+ # Tracks IDs returned by add_line/add_arc/add_circle/add_centerline/
194
+ # add_rectangle/add_spline so add_sketch_constraint can validate
195
+ # entity1/entity2 the same way the real adapter validates against
196
+ # its sketch-entity registry.
197
+ self._sketch_entity_ids: set[str] = set()
198
+ self._dimensions: dict[str, float] = {}
199
+ # Assembly components inserted via insert_component, keyed by
200
+ # insertion order. See insert_component/list_components/add_mate.
201
+ self._components: list[str] = []
202
+ # Mates added via add_mate, in order, so the geometry summary can
203
+ # report a mate count without real geometry.
204
+ self._mates: list[dict[str, Any]] = []
205
+ # Drawing views placed via create_drawing_view / add_drawing_view /
206
+ # create_technical_drawing, in sheet order. See list_drawing_views.
207
+ self._drawing_views: list[str] = []
208
+ # Reference planes created via create_reference_plane, in creation
209
+ # order, used to invent sequential "PlaneN" names. Shared feature
210
+ # tree counter with create_axis mirrors the live adapter's
211
+ # features_before/features_after pair (see _feature_count in
212
+ # solidworks/features.py).
213
+ self._reference_planes: list[str] = []
214
+ # Axes created via create_axis, in creation order, used to invent
215
+ # sequential "AxisN" names the same way self._reference_planes
216
+ # invents "PlaneN" names, and to validate the axis name passed to
217
+ # pattern_circular the same way known_sources validates mirror
218
+ # sources.
219
+ self._axes: list[str] = []
220
+ # Reference points created via create_reference_point, in creation
221
+ # order, invented "PointN" names on the same pattern as _axes.
222
+ self._reference_points: list[str] = []
223
+ # Centre-mark count per drawing-view name, grown by auto_center_marks.
224
+ self._center_marks: dict[str, int] = {}
225
+ self._feature_tree_count = 0
226
+ # Volume tracked for mirror_feature, in mm^3. Seeded at the
227
+ # live-measured 1819569.1 mm^3 wing volume from
228
+ # _mirror_feature_impl in solidworks/features.py, then doubled on
229
+ # each successful mirror so volume_before/volume_after/volume_ratio
230
+ # stay self-consistent without a real geometry engine.
231
+ self._mirror_volume: float = 1819569.1
232
+ # Volume tracked for pattern_circular, in mm^3. Seeded at the
233
+ # live-measured 48431.5 mm^3 hub+blade volume from
234
+ # _pattern_circular_impl in solidworks/features.py, then grown by
235
+ # _PATTERN_BLADE_VOLUME per extra instance on each successful
236
+ # pattern so volume_before/volume_after/volume_ratio stay
237
+ # self-consistent without a real geometry engine.
238
+ self._pattern_volume: float = 48431.5
239
+ self._operation_count = 0
240
+
241
+ # Configurable simulation delays (in seconds)
242
+ self._delays = {
243
+ "connect": cfg.get("mock_connect_delay", 0.1),
244
+ "model_operation": cfg.get("mock_model_delay", 0.02),
245
+ "feature_operation": cfg.get("mock_feature_delay", 0.03) if cfg else 0.03,
246
+ "sketch_operation": cfg.get("mock_sketch_delay", 0.1),
247
+ }
248
+ self._is_connected_proxy = _BoolCallable(lambda: self._connected)
249
+ self._simulate_errors = bool(cfg.get("simulate_errors", False))
250
+
251
+ def __getattribute__(self, name: str) -> Any:
252
+ """Build internal getattribute.
253
+
254
+ Args:
255
+ name (str): The name value.
256
+
257
+ Returns:
258
+ Any: The result produced by the operation.
259
+ """
260
+ if name == "is_connected":
261
+ return object.__getattribute__(self, "_is_connected_proxy")
262
+ return object.__getattribute__(self, name)
263
+
264
+ async def connect(self) -> None:
265
+ """Mock connection to SolidWorks.
266
+
267
+ Returns:
268
+ None: None.
269
+ """
270
+ await asyncio.sleep(self._delays["connect"])
271
+ self._connected = True
272
+
273
+ # Initialize some sample data
274
+ self._dimensions = {
275
+ "D1@Sketch1": 10.0,
276
+ "D2@Sketch1": 20.0,
277
+ "D1@Boss-Extrude1": 50.0,
278
+ }
279
+
280
+ async def disconnect(self) -> None:
281
+ """Mock disconnection from SolidWorks.
282
+
283
+ Returns:
284
+ None: None.
285
+ """
286
+ self._connected = False
287
+ self._current_model = None
288
+ self._models.clear()
289
+ self._features.clear()
290
+ self._sketches.clear()
291
+ self._current_sketch = None
292
+ self._selection.clear()
293
+
294
+ def is_connected(self) -> bool:
295
+ """Check if mock connection is active.
296
+
297
+ Returns:
298
+ bool: True if connected, otherwise False.
299
+ """
300
+ return self._connected
301
+
302
+ async def health_check(self) -> AdapterHealth:
303
+ """Get mock health status.
304
+
305
+ Returns:
306
+ AdapterHealth: The result produced by the operation.
307
+ """
308
+ error_count = int(self._metrics["errors_count"])
309
+ success_count = int(
310
+ self._metrics["operations_count"] - self._metrics["errors_count"]
311
+ )
312
+ return AdapterHealth(
313
+ healthy=self._connected,
314
+ last_check=datetime.now(),
315
+ error_count=error_count,
316
+ success_count=success_count,
317
+ average_response_time=self._metrics["average_response_time"],
318
+ connection_status="connected" if self._connected else "disconnected",
319
+ metrics={
320
+ "adapter_type": "mock",
321
+ "operations_count": self._operation_count,
322
+ "models_count": len(self._models),
323
+ "features_count": len(self._features),
324
+ "sketches_count": len(self._sketches),
325
+ },
326
+ )
327
+
328
+ async def open_model(self, file_path: str) -> AdapterResult[SolidWorksModel]:
329
+ """Mock opening a SolidWorks model.
330
+
331
+ Args:
332
+ file_path (str): Path to the target file.
333
+
334
+ Returns:
335
+ AdapterResult[SolidWorksModel]: The result produced by the operation.
336
+
337
+ Raises:
338
+ SolidWorksOperationError: Simulated adapter failure.
339
+ """
340
+ if not self._connected:
341
+ return AdapterResult(
342
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
343
+ )
344
+
345
+ if self._simulate_errors:
346
+ raise SolidWorksOperationError("Simulated adapter failure")
347
+
348
+ await asyncio.sleep(self._delays["model_operation"])
349
+ self._operation_count += 1
350
+
351
+ # Determine model type from file extension
352
+ file_path_lower = file_path.lower()
353
+ if file_path_lower.endswith(".sldprt"):
354
+ model_type = "Part"
355
+ elif file_path_lower.endswith(".sldasm"):
356
+ model_type = "Assembly"
357
+ elif file_path_lower.endswith(".slddrw"):
358
+ model_type = "Drawing"
359
+ else:
360
+ return AdapterResult(
361
+ status=AdapterResultStatus.ERROR,
362
+ error=f"Unsupported file type: {file_path}",
363
+ )
364
+
365
+ # Create mock model
366
+ model_name = file_path.split("/")[-1].split("\\")[-1]
367
+ model = SolidWorksModel(
368
+ path=file_path,
369
+ name=model_name,
370
+ type=model_type,
371
+ is_active=True,
372
+ configuration="Default",
373
+ properties={
374
+ "created": datetime.now().isoformat(),
375
+ "model_id": model_name or f"Part{len(self._models) + 1}",
376
+ "file_size": random.randint(100000, 5000000),
377
+ },
378
+ )
379
+
380
+ self._current_model = model
381
+ self._models[file_path] = model
382
+
383
+ return AdapterResult(
384
+ status=AdapterResultStatus.SUCCESS,
385
+ data=model,
386
+ execution_time=self._delays["model_operation"],
387
+ )
388
+
389
+ async def close_model(self, save: bool = False) -> AdapterResult[None]:
390
+ """Mock closing the current model.
391
+
392
+ Args:
393
+ save (bool): The save value. Defaults to False.
394
+
395
+ Returns:
396
+ AdapterResult[None]: The result produced by the operation.
397
+ """
398
+ if not self._current_model:
399
+ return AdapterResult(
400
+ status=AdapterResultStatus.WARNING, error="No active model to close"
401
+ )
402
+
403
+ await asyncio.sleep(self._delays["model_operation"] / 2)
404
+ self._operation_count += 1
405
+
406
+ if save:
407
+ # Simulate save operation
408
+ await asyncio.sleep(self._delays["model_operation"])
409
+
410
+ self._current_model = None
411
+
412
+ return AdapterResult(
413
+ status=AdapterResultStatus.SUCCESS,
414
+ data=None,
415
+ execution_time=self._delays["model_operation"],
416
+ )
417
+
418
+ async def get_model_info(self) -> AdapterResult[dict[str, Any]]:
419
+ """Mock metadata for the currently active model.
420
+
421
+ Returns:
422
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
423
+ """
424
+ if not self._current_model:
425
+ return AdapterResult(
426
+ status=AdapterResultStatus.ERROR,
427
+ error="No active model",
428
+ )
429
+
430
+ await asyncio.sleep(self._delays["model_operation"] / 2)
431
+ self._operation_count += 1
432
+
433
+ model = self._current_model
434
+ feature_count = len(self._features)
435
+ info = {
436
+ "title": model.name,
437
+ "path": model.path,
438
+ "type": model.type,
439
+ "configuration": model.configuration or "Default",
440
+ "is_dirty": False,
441
+ "feature_count": feature_count,
442
+ "rebuild_status": 0,
443
+ }
444
+
445
+ return AdapterResult(
446
+ status=AdapterResultStatus.SUCCESS,
447
+ data=info,
448
+ execution_time=self._delays["model_operation"] / 2,
449
+ )
450
+
451
+ async def list_features(
452
+ self, include_suppressed: bool = False, max_assembly_depth: int = 2
453
+ ) -> AdapterResult[list[dict[str, Any]]]:
454
+ """Mock feature tree listing for the active model.
455
+
456
+ For an Assembly model, flattens in every configured component's
457
+ features (see ``_assembly_components``) alongside the document's own
458
+ features, tagged with ``component``/``component_path`` the same way
459
+ the real adapter does. Falls back to a small canned two-component
460
+ fixture (plus one nested sub-assembly) when no components have been
461
+ configured, mirroring the existing "seed realistic feature names"
462
+ behavior for an empty Part.
463
+
464
+ Args:
465
+ include_suppressed (bool): The include suppressed value. Defaults to False.
466
+ max_assembly_depth (int): Sub-assembly recursion budget. Ignored
467
+ for non-Assembly models. Defaults to 2.
468
+
469
+ Returns:
470
+ AdapterResult[list[dict[str, Any]]]: The result produced by the operation.
471
+ """
472
+ if not self._current_model:
473
+ return AdapterResult(
474
+ status=AdapterResultStatus.ERROR,
475
+ error="No active model",
476
+ )
477
+
478
+ await asyncio.sleep(self._delays["feature_operation"] / 2)
479
+ self._operation_count += 1
480
+
481
+ is_assembly = self._current_model.type == "Assembly"
482
+
483
+ # Seed realistic feature names for empty mock state.
484
+ if not self._features:
485
+ seeded: list[dict[str, Any]] = [
486
+ {
487
+ "name": "Origin",
488
+ "type": "OriginProfileFeature",
489
+ "suppressed": False,
490
+ "component": None,
491
+ "component_path": None,
492
+ "component_parent": None,
493
+ },
494
+ {
495
+ "name": "Front Plane",
496
+ "type": "RefPlane",
497
+ "suppressed": False,
498
+ "component": None,
499
+ "component_path": None,
500
+ "component_parent": None,
501
+ },
502
+ {
503
+ "name": "Right Plane",
504
+ "type": "RefPlane",
505
+ "suppressed": False,
506
+ "component": None,
507
+ "component_path": None,
508
+ "component_parent": None,
509
+ },
510
+ {
511
+ "name": "Top Plane",
512
+ "type": "RefPlane",
513
+ "suppressed": False,
514
+ "component": None,
515
+ "component_path": None,
516
+ "component_parent": None,
517
+ },
518
+ {
519
+ "name": "Sketch1",
520
+ "type": "ProfileFeature",
521
+ "suppressed": False,
522
+ "component": None,
523
+ "component_path": None,
524
+ "component_parent": None,
525
+ },
526
+ ]
527
+ if is_assembly:
528
+ components = self._assembly_components or _DEFAULT_ASSEMBLY_COMPONENTS
529
+ seeded.extend(
530
+ self._flatten_assembly_components(
531
+ components, include_suppressed, max_assembly_depth - 1, None
532
+ )
533
+ )
534
+ return AdapterResult(
535
+ status=AdapterResultStatus.SUCCESS,
536
+ data=seeded,
537
+ execution_time=self._delays["feature_operation"] / 2,
538
+ )
539
+
540
+ feature_rows: list[dict[str, Any]] = []
541
+ for i, feature in enumerate(self._features.values()):
542
+ row = {
543
+ "name": feature.name,
544
+ "type": feature.type,
545
+ "suppressed": bool((feature.properties or {}).get("suppressed", False)),
546
+ "position": i,
547
+ "component": None,
548
+ "component_path": None,
549
+ "component_parent": None,
550
+ }
551
+ if include_suppressed or not row["suppressed"]:
552
+ feature_rows.append(row)
553
+
554
+ if is_assembly:
555
+ components = self._assembly_components or _DEFAULT_ASSEMBLY_COMPONENTS
556
+ feature_rows.extend(
557
+ self._flatten_assembly_components(
558
+ components,
559
+ include_suppressed,
560
+ max_assembly_depth - 1,
561
+ None,
562
+ )
563
+ )
564
+
565
+ return AdapterResult(
566
+ status=AdapterResultStatus.SUCCESS,
567
+ data=feature_rows,
568
+ execution_time=self._delays["feature_operation"] / 2,
569
+ )
570
+
571
+ def _flatten_assembly_components(
572
+ self,
573
+ components: dict[str, dict[str, Any]],
574
+ include_suppressed: bool,
575
+ depth_remaining: int,
576
+ parent_component: str | None,
577
+ ) -> list[dict[str, Any]]:
578
+ """Flatten a mock component tree into tagged feature descriptors.
579
+
580
+ Mirrors the real adapter's recursion rules: a sub-assembly component
581
+ is expanded (its own features plus a recursive pass over its
582
+ components) only while ``depth_remaining > 0``; beyond that it is
583
+ represented by a single bare descriptor. An unresolvable component
584
+ (``type: "Unresolved"`` in the fixture) always produces one
585
+ ``UnresolvedComponent`` descriptor.
586
+
587
+ Args:
588
+ components: Mapping of component name to a fixture dict with
589
+ ``type`` ("Part", "Assembly", or "Unresolved"), ``path``,
590
+ ``features`` (list of ``{name, type, suppressed}``), and,
591
+ for "Assembly" components, a nested ``components`` mapping.
592
+ include_suppressed: Include suppressed entries when ``True``.
593
+ depth_remaining: Sub-assembly recursion budget remaining.
594
+ parent_component: Name of the component this level of
595
+ ``components`` is nested inside, or ``None`` for top-level.
596
+
597
+ Returns:
598
+ list[dict[str, Any]]: Flattened, component-tagged descriptors.
599
+ """
600
+ results: list[dict[str, Any]] = []
601
+ for name, comp in components.items():
602
+ comp_type = comp.get("type")
603
+ path = comp.get("path")
604
+
605
+ if comp_type == "Unresolved":
606
+ results.append(
607
+ {
608
+ "name": name,
609
+ "type": "UnresolvedComponent",
610
+ "suppressed": False,
611
+ "position": -1,
612
+ "component": name,
613
+ "component_path": None,
614
+ "component_parent": parent_component,
615
+ }
616
+ )
617
+ continue
618
+
619
+ if comp_type == "Assembly" and depth_remaining <= 0:
620
+ results.append(
621
+ {
622
+ "name": name,
623
+ "type": "Component",
624
+ "suppressed": False,
625
+ "position": -1,
626
+ "component": name,
627
+ "component_path": path,
628
+ "component_parent": parent_component,
629
+ }
630
+ )
631
+ continue
632
+
633
+ for i, feature in enumerate(comp.get("features", [])):
634
+ suppressed = bool(feature.get("suppressed", False))
635
+ if not include_suppressed and suppressed:
636
+ continue
637
+ results.append(
638
+ {
639
+ "name": feature["name"],
640
+ "type": feature["type"],
641
+ "suppressed": suppressed,
642
+ "position": i,
643
+ "component": name,
644
+ "component_path": path,
645
+ "component_parent": parent_component,
646
+ }
647
+ )
648
+
649
+ if comp_type == "Assembly":
650
+ results.extend(
651
+ self._flatten_assembly_components(
652
+ comp.get("components", {}),
653
+ include_suppressed,
654
+ depth_remaining - 1,
655
+ name,
656
+ )
657
+ )
658
+
659
+ return results
660
+
661
+ #: Default tree items every new part has, selectable like the real ones.
662
+ _DEFAULT_SELECTABLE = ("Front Plane", "Top Plane", "Right Plane", "Origin")
663
+
664
+ async def select_feature(
665
+ self, feature_name: str, append: bool = False
666
+ ) -> AdapterResult[dict[str, Any]]:
667
+ """Mock feature selection, mirroring the real adapter's contract.
668
+
669
+ Succeeds only for names the mock is tracking for the active model
670
+ (created features, sketches, default planes); anything else is an
671
+ ERROR naming the feature - never SUCCESS with ``selected: False``.
672
+
673
+ Args:
674
+ feature_name (str): The feature name, optionally ``@doc`` qualified.
675
+ append (bool): Add to the current selection. Defaults to False.
676
+
677
+ Returns:
678
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
679
+ """
680
+ if not self._current_model:
681
+ return AdapterResult(
682
+ status=AdapterResultStatus.ERROR,
683
+ error="No active model",
684
+ )
685
+ await asyncio.sleep(self._delays["feature_operation"] / 4)
686
+ self._operation_count += 1
687
+
688
+ bare = feature_name.split("@", 1)[0].strip()
689
+ known = (
690
+ {f.name for f in self._features.values()}
691
+ | set(self._sketches)
692
+ | set(self._DEFAULT_SELECTABLE)
693
+ )
694
+ if bare not in known:
695
+ return AdapterResult(
696
+ status=AdapterResultStatus.ERROR,
697
+ error=f"Feature not found or could not be selected: {feature_name}",
698
+ )
699
+ if not append:
700
+ self._selection.clear()
701
+ if bare not in self._selection:
702
+ self._selection.append(bare)
703
+ return AdapterResult(
704
+ status=AdapterResultStatus.SUCCESS,
705
+ data={
706
+ "selected": True,
707
+ "feature_name": feature_name,
708
+ "selected_name": bare,
709
+ "entity_type": "mock",
710
+ "selection_count": len(self._selection),
711
+ },
712
+ execution_time=self._delays["feature_operation"] / 4,
713
+ )
714
+
715
+ async def get_selected_entities(self) -> AdapterResult[list[dict[str, Any]]]:
716
+ """Mock UI selection — always reports one canned face and one canned edge.
717
+
718
+ There's no real UI to read a selection from in mock mode, so this
719
+ returns a stable, plausible-shaped fixture rather than an empty list,
720
+ so callers exercising this tool in tests get realistic field names.
721
+
722
+ Returns:
723
+ AdapterResult[list[dict[str, Any]]]: The result produced by the operation.
724
+ """
725
+ if not self._current_model:
726
+ return AdapterResult(
727
+ status=AdapterResultStatus.ERROR,
728
+ error="No active model",
729
+ )
730
+ await asyncio.sleep(self._delays["feature_operation"] / 4)
731
+ return AdapterResult(
732
+ status=AdapterResultStatus.SUCCESS,
733
+ data=[
734
+ {
735
+ "index": 1,
736
+ "type": "FACE",
737
+ "bounding_box_mm": {
738
+ "min": [0.0, 0.0, 0.0],
739
+ "max": [30.0, 30.0, 0.0],
740
+ },
741
+ "center_mm": [15.0, 15.0, 0.0],
742
+ "area_mm2": 900.0,
743
+ "normal": [0.0, 0.0, 1.0],
744
+ },
745
+ {
746
+ "index": 2,
747
+ "type": "EDGE",
748
+ "start_mm": [0.0, 0.0, -15.0],
749
+ "end_mm": [30.0, 0.0, -15.0],
750
+ "length_mm": 30.0,
751
+ },
752
+ ],
753
+ execution_time=self._delays["feature_operation"] / 4,
754
+ )
755
+
756
+ async def select_face_edges(
757
+ self,
758
+ point: list[float] | None = None,
759
+ side: str | None = None,
760
+ plane: str | None = None,
761
+ edge_type: str = "all",
762
+ append: bool = False,
763
+ ) -> AdapterResult[dict[str, Any]]:
764
+ """Mock face-edge multi-selection on a canned 10 mm block.
765
+
766
+ Mirrors the real adapter's contract: the same input validation, the
767
+ same payload shape, and the same failure cases. The block spans
768
+ 0..10 mm on every axis; the +Z/-Z faces carry a 2 mm radius hole rim
769
+ once any circle has been sketched, so ``edge_type="circular"``
770
+ behaves like a real drilled part. ``Front Plane`` (z = 0) is the only
771
+ reference plane with a face on it.
772
+ """
773
+ from .solidworks.face_selection import side_vector, validate_face_query
774
+
775
+ problem = validate_face_query(point, side, plane, edge_type)
776
+ if problem:
777
+ return AdapterResult(status=AdapterResultStatus.ERROR, error=problem)
778
+ if not self._current_model:
779
+ return AdapterResult(
780
+ status=AdapterResultStatus.ERROR, error="No active model"
781
+ )
782
+ if getattr(self._current_model, "type", "Part") != "Part":
783
+ return AdapterResult(
784
+ status=AdapterResultStatus.ERROR,
785
+ error="select_face_edges requires an active part document.",
786
+ )
787
+ await asyncio.sleep(self._delays["feature_operation"] / 4)
788
+
789
+ if point is not None:
790
+ normal = self._mock_face_at(point)
791
+ if normal is None:
792
+ return AdapterResult(
793
+ status=AdapterResultStatus.ERROR,
794
+ error=f"No face found at point {list(point)} mm.",
795
+ )
796
+ elif side is not None:
797
+ normal = side_vector(side)
798
+ elif plane == "Front Plane":
799
+ normal = (0.0, 0.0, -1.0)
800
+ else:
801
+ return AdapterResult(
802
+ status=AdapterResultStatus.ERROR,
803
+ error=f"No body face lies on {plane!r}.",
804
+ )
805
+
806
+ center = [5.0 + 5.0 * c for c in normal]
807
+ has_hole = any(e.startswith("Circle") for e in self._sketch_entity_ids)
808
+ edges: list[dict[str, Any]] = [
809
+ {"curve": "linear", "length_mm": 10.0, "face_index": 0} for _ in range(4)
810
+ ]
811
+ if has_hole and normal[2] != 0:
812
+ edges.append(
813
+ {
814
+ "curve": "circular",
815
+ "center_mm": [5.0, 5.0, center[2]],
816
+ "axis": [0.0, 0.0, 1.0],
817
+ "radius_mm": 2.0,
818
+ "face_index": 0,
819
+ }
820
+ )
821
+ edges = [e for e in edges if edge_type == "all" or e["curve"] == edge_type]
822
+ if not edges:
823
+ kind = "" if edge_type == "all" else f"{edge_type} "
824
+ return AdapterResult(
825
+ status=AdapterResultStatus.ERROR,
826
+ error=f"The selected face has no {kind}edges (face centre {center} mm).",
827
+ )
828
+
829
+ if not append:
830
+ self._selection.clear()
831
+ self._selection.extend(f"Edge{i}" for i in range(len(edges)))
832
+ face = {
833
+ "index": 0,
834
+ "center_mm": center,
835
+ "normal": [float(c) for c in normal],
836
+ "area_mm2": 100.0,
837
+ "planar": True,
838
+ }
839
+ return AdapterResult(
840
+ status=AdapterResultStatus.SUCCESS,
841
+ data={
842
+ "face": face,
843
+ "faces": [face],
844
+ "edge_type": edge_type,
845
+ "edges": edges,
846
+ "selected_edges": len(edges),
847
+ "selection_count": len(self._selection),
848
+ },
849
+ execution_time=self._delays["feature_operation"] / 4,
850
+ )
851
+
852
+ async def add_fillet(
853
+ self, radius: float, edge_names: list[str]
854
+ ) -> AdapterResult[SolidWorksFeature]:
855
+ """Mock constant-radius fillet (see :meth:`_mock_edge_feature`)."""
856
+ return await self._mock_edge_feature("Fillet", radius, edge_names)
857
+
858
+ async def add_chamfer(
859
+ self, distance: float, edge_names: list[str]
860
+ ) -> AdapterResult[SolidWorksFeature]:
861
+ """Mock equal-distance chamfer (see :meth:`_mock_edge_feature`)."""
862
+ return await self._mock_edge_feature("Chamfer", distance, edge_names)
863
+
864
+ async def _mock_edge_feature(
865
+ self, kind: str, size: float, edge_names: list[str]
866
+ ) -> AdapterResult[SolidWorksFeature]:
867
+ """Create a mock fillet/chamfer on named edges or the live selection.
868
+
869
+ Mirrors the real contract: an empty ``edge_names`` acts on whatever is
870
+ currently selected (e.g. after ``select_face_edges``), and is an error
871
+ when nothing is.
872
+ """
873
+ if not self._current_model:
874
+ return AdapterResult(
875
+ status=AdapterResultStatus.ERROR, error="No active model"
876
+ )
877
+ if size <= 0:
878
+ return AdapterResult(
879
+ status=AdapterResultStatus.ERROR,
880
+ error=f"{kind} size must be positive",
881
+ )
882
+ edges = list(edge_names) or list(self._selection)
883
+ if not edges:
884
+ return AdapterResult(
885
+ status=AdapterResultStatus.ERROR,
886
+ error=f"No edges given and nothing is selected for the {kind.lower()}",
887
+ )
888
+ await asyncio.sleep(self._delays["feature_operation"] / 4)
889
+ count = sum(1 for f in self._features.values() if f.type == kind) + 1
890
+ feature = SolidWorksFeature(
891
+ name=f"{kind}{count}",
892
+ type=kind,
893
+ id=f"{kind.lower()}_{uuid.uuid4().hex[:8]}",
894
+ parameters={"size": size, "edges": edges},
895
+ properties={"created": datetime.now().isoformat(), "mock": True},
896
+ )
897
+ self._features[feature.id or feature.name] = feature
898
+ self._selection.clear()
899
+ return AdapterResult(
900
+ status=AdapterResultStatus.SUCCESS,
901
+ data=feature,
902
+ execution_time=self._delays["feature_operation"] / 4,
903
+ )
904
+
905
+ async def rebuild_model(
906
+ self, top_level_only: bool = False
907
+ ) -> AdapterResult[dict[str, Any]]:
908
+ """Mock rebuild with the real adapter's diagnostics contract.
909
+
910
+ Clean by default; issues placed in ``injected_rebuild_issues`` are
911
+ explained with the same ``rebuild_diagnostics`` logic the real
912
+ adapter uses, so ERROR-with-data / warnings-only behave identically.
913
+ """
914
+ from .solidworks.rebuild_diagnostics import explain, summarize
915
+
916
+ if not self._current_model:
917
+ return AdapterResult(
918
+ status=AdapterResultStatus.ERROR, error="No active model"
919
+ )
920
+ await asyncio.sleep(self._delays["model_operation"] / 4)
921
+ entries = [explain(i) for i in self.injected_rebuild_issues]
922
+ error_count, warning_count, summary = summarize(entries)
923
+ data = {
924
+ "rebuilt": True,
925
+ "top_level_only": top_level_only,
926
+ "errors": entries,
927
+ "error_count": error_count,
928
+ "warning_count": warning_count,
929
+ "summary": summary,
930
+ }
931
+ return AdapterResult(
932
+ status=(
933
+ AdapterResultStatus.ERROR
934
+ if error_count
935
+ else AdapterResultStatus.SUCCESS
936
+ ),
937
+ data=data,
938
+ error=summary if error_count else None,
939
+ execution_time=self._delays["model_operation"] / 4,
940
+ )
941
+
942
+ @staticmethod
943
+ def _mock_face_at(point: list[float]) -> tuple[float, float, float] | None:
944
+ """Return the outward normal of the mock block face containing ``point``."""
945
+ if not all(-1e-6 <= c <= 10.0 + 1e-6 for c in point):
946
+ return None
947
+ for axis in range(3):
948
+ for value, sign in ((0.0, -1.0), (10.0, 1.0)):
949
+ if abs(point[axis] - value) <= 1e-6:
950
+ normal = [0.0, 0.0, 0.0]
951
+ normal[axis] = sign
952
+ return (normal[0], normal[1], normal[2])
953
+ return None
954
+
955
+ async def list_configurations(self) -> AdapterResult[list[str]]:
956
+ """Mock configuration listing for the active model.
957
+
958
+ Returns:
959
+ AdapterResult[list[str]]: The result produced by the operation.
960
+ """
961
+ if not self._current_model:
962
+ return AdapterResult(
963
+ status=AdapterResultStatus.ERROR,
964
+ error="No active model",
965
+ )
966
+
967
+ await asyncio.sleep(self._delays["model_operation"] / 2)
968
+ self._operation_count += 1
969
+
970
+ active = self._current_model.configuration or "Default"
971
+ configs = [active] if active == "Default" else ["Default", active]
972
+
973
+ return AdapterResult(
974
+ status=AdapterResultStatus.SUCCESS,
975
+ data=configs,
976
+ execution_time=self._delays["model_operation"] / 2,
977
+ )
978
+
979
+ async def create_part(
980
+ self,
981
+ name: str | None = None,
982
+ units: str | None = None,
983
+ template: str | None = None,
984
+ ) -> AdapterResult[SolidWorksModel]:
985
+ """Mock creating a new part.
986
+
987
+ Args:
988
+ name (str | None): The name value. Defaults to None.
989
+ units (str | None): The units value. Defaults to None.
990
+ template (str | None): Template file path. Defaults to None.
991
+
992
+ Returns:
993
+ AdapterResult[SolidWorksModel]: The result produced by the operation.
994
+ """
995
+ if not self._connected:
996
+ return AdapterResult(
997
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
998
+ )
999
+
1000
+ token = "mm"
1001
+ if units is not None and str(units).strip():
1002
+ token = _normalise_mock_unit_system(str(units))
1003
+ if token is None:
1004
+ return AdapterResult(
1005
+ status=AdapterResultStatus.ERROR,
1006
+ error=(
1007
+ f"Unrecognised units {units!r}. "
1008
+ "Expected one of: mm, cm, m, in, ft."
1009
+ ),
1010
+ )
1011
+
1012
+ await asyncio.sleep(self._delays["model_operation"])
1013
+ self._operation_count += 1
1014
+
1015
+ model_id = name or f"Part{len(self._models) + 1}"
1016
+ model = SolidWorksModel(
1017
+ path=f"Mock://{model_id}.sldprt",
1018
+ name=f"{model_id}",
1019
+ type="Part",
1020
+ is_active=True,
1021
+ configuration="Default",
1022
+ properties={
1023
+ "created": datetime.now().isoformat(),
1024
+ "mock": True,
1025
+ "units": token,
1026
+ "template": template,
1027
+ },
1028
+ )
1029
+
1030
+ self._current_model = model
1031
+ self._models[model.path] = model
1032
+
1033
+ return AdapterResult(
1034
+ status=AdapterResultStatus.SUCCESS,
1035
+ data=model,
1036
+ execution_time=self._delays["model_operation"],
1037
+ )
1038
+
1039
+ async def create_assembly(
1040
+ self, name: str | None = None, template: str | None = None
1041
+ ) -> AdapterResult[SolidWorksModel]:
1042
+ """Mock creating a new assembly.
1043
+
1044
+ Args:
1045
+ name (str | None): The name value. Defaults to None.
1046
+ template (str | None): Template file path. Defaults to None.
1047
+
1048
+ Returns:
1049
+ AdapterResult[SolidWorksModel]: The result produced by the operation.
1050
+ """
1051
+ if not self._connected:
1052
+ return AdapterResult(
1053
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
1054
+ )
1055
+
1056
+ await asyncio.sleep(self._delays["model_operation"])
1057
+ self._operation_count += 1
1058
+
1059
+ model_id = name or f"Assembly{len(self._models) + 1}"
1060
+ model = SolidWorksModel(
1061
+ path=f"Mock://{model_id}.sldasm",
1062
+ name=f"{model_id}",
1063
+ type="Assembly",
1064
+ is_active=True,
1065
+ configuration="Default",
1066
+ properties={
1067
+ "created": datetime.now().isoformat(),
1068
+ "mock": True,
1069
+ "template": template,
1070
+ },
1071
+ )
1072
+
1073
+ self._current_model = model
1074
+ self._models[model.path] = model
1075
+
1076
+ return AdapterResult(
1077
+ status=AdapterResultStatus.SUCCESS,
1078
+ data=model,
1079
+ execution_time=self._delays["model_operation"],
1080
+ )
1081
+
1082
+ async def create_drawing(
1083
+ self, name: str | None = None, template: str | None = None
1084
+ ) -> AdapterResult[SolidWorksModel]:
1085
+ """Mock creating a new drawing.
1086
+
1087
+ Args:
1088
+ name (str | None): The name value. Defaults to None.
1089
+ template (str | None): Template file path. Defaults to None.
1090
+
1091
+ Returns:
1092
+ AdapterResult[SolidWorksModel]: The result produced by the operation.
1093
+ """
1094
+ if not self._connected:
1095
+ return AdapterResult(
1096
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
1097
+ )
1098
+
1099
+ await asyncio.sleep(self._delays["model_operation"])
1100
+ self._operation_count += 1
1101
+
1102
+ model_id = name or f"Drawing{len(self._models) + 1}"
1103
+ model = SolidWorksModel(
1104
+ path=f"Mock://{model_id}.slddrw",
1105
+ name=f"{model_id}",
1106
+ type="Drawing",
1107
+ is_active=True,
1108
+ configuration="Default",
1109
+ properties={
1110
+ "created": datetime.now().isoformat(),
1111
+ "mock": True,
1112
+ "template": template,
1113
+ },
1114
+ )
1115
+
1116
+ self._current_model = model
1117
+ self._models[model.path] = model
1118
+
1119
+ return AdapterResult(
1120
+ status=AdapterResultStatus.SUCCESS,
1121
+ data=model,
1122
+ execution_time=self._delays["model_operation"],
1123
+ )
1124
+
1125
+ async def create_extrusion(
1126
+ self,
1127
+ params: ExtrusionParameters | str,
1128
+ depth: float | None = None,
1129
+ direction: str | None = None,
1130
+ ) -> AdapterResult[SolidWorksFeature]:
1131
+ """Mock creating an extrusion feature.
1132
+
1133
+ Args:
1134
+ params (ExtrusionParameters | str): The params value.
1135
+ depth (float | None): The depth value. Defaults to None.
1136
+ direction (str | None): The direction value. Defaults to None.
1137
+
1138
+ Returns:
1139
+ AdapterResult[SolidWorksFeature]: The result produced by the operation.
1140
+ """
1141
+ if not self._current_model:
1142
+ return AdapterResult(
1143
+ status=AdapterResultStatus.ERROR, error="No active model"
1144
+ )
1145
+
1146
+ if isinstance(params, str):
1147
+ params = ExtrusionParameters(depth=depth or 0.0)
1148
+
1149
+ await asyncio.sleep(self._delays["feature_operation"])
1150
+ self._operation_count += 1
1151
+
1152
+ feature_id = f"Boss-Extrude{len(self._features) + 1}"
1153
+ feature = SolidWorksFeature(
1154
+ name=feature_id,
1155
+ type="Extrusion",
1156
+ id=str(uuid.uuid4()),
1157
+ parameters={
1158
+ "depth": params.depth,
1159
+ "direction": direction or "blind",
1160
+ "draft_angle": params.draft_angle,
1161
+ "reverse_direction": params.reverse_direction,
1162
+ "both_directions": params.both_directions,
1163
+ "thin_feature": params.thin_feature,
1164
+ "thin_thickness": params.thin_thickness,
1165
+ },
1166
+ properties={"created": datetime.now().isoformat(), "mock": True},
1167
+ )
1168
+
1169
+ feature_key = feature.id or feature.name
1170
+ self._features[feature_key] = feature
1171
+
1172
+ return AdapterResult(
1173
+ status=AdapterResultStatus.SUCCESS,
1174
+ data=feature,
1175
+ execution_time=self._delays["feature_operation"],
1176
+ )
1177
+
1178
+ async def create_revolve(
1179
+ self, params: RevolveParameters
1180
+ ) -> AdapterResult[SolidWorksFeature]:
1181
+ """Mock creating a revolve feature.
1182
+
1183
+ Args:
1184
+ params (RevolveParameters): The params value.
1185
+
1186
+ Returns:
1187
+ AdapterResult[SolidWorksFeature]: The result produced by the operation.
1188
+ """
1189
+ if not self._current_model:
1190
+ return AdapterResult(
1191
+ status=AdapterResultStatus.ERROR, error="No active model"
1192
+ )
1193
+
1194
+ await asyncio.sleep(self._delays["feature_operation"])
1195
+ self._operation_count += 1
1196
+
1197
+ feature_id = f"Boss-Revolve{len(self._features) + 1}"
1198
+ feature = SolidWorksFeature(
1199
+ name=feature_id,
1200
+ type="Revolve",
1201
+ id=str(uuid.uuid4()),
1202
+ parameters={
1203
+ "angle": params.angle,
1204
+ "reverse_direction": params.reverse_direction,
1205
+ "both_directions": params.both_directions,
1206
+ "thin_feature": params.thin_feature,
1207
+ "thin_thickness": params.thin_thickness,
1208
+ },
1209
+ properties={"created": datetime.now().isoformat(), "mock": True},
1210
+ )
1211
+
1212
+ feature_key = feature.id or feature.name
1213
+ self._features[feature_key] = feature
1214
+
1215
+ return AdapterResult(
1216
+ status=AdapterResultStatus.SUCCESS,
1217
+ data=feature,
1218
+ execution_time=self._delays["feature_operation"],
1219
+ )
1220
+
1221
+ async def create_sweep(
1222
+ self, params: SweepParameters
1223
+ ) -> AdapterResult[SolidWorksFeature]:
1224
+ """Mock creating a sweep feature.
1225
+
1226
+ Args:
1227
+ params (SweepParameters): The params value.
1228
+
1229
+ Returns:
1230
+ AdapterResult[SolidWorksFeature]: The result produced by the operation.
1231
+ """
1232
+ if not self._current_model:
1233
+ return AdapterResult(
1234
+ status=AdapterResultStatus.ERROR, error="No active model"
1235
+ )
1236
+
1237
+ await asyncio.sleep(self._delays["feature_operation"])
1238
+ self._operation_count += 1
1239
+
1240
+ feature_id = f"Boss-Sweep{len(self._features) + 1}"
1241
+ feature = SolidWorksFeature(
1242
+ name=feature_id,
1243
+ type="Sweep",
1244
+ id=str(uuid.uuid4()),
1245
+ parameters={
1246
+ "path": params.path,
1247
+ "twist_along_path": params.twist_along_path,
1248
+ "twist_angle": params.twist_angle,
1249
+ },
1250
+ properties={"created": datetime.now().isoformat(), "mock": True},
1251
+ )
1252
+
1253
+ feature_key = feature.id or feature.name
1254
+ self._features[feature_key] = feature
1255
+
1256
+ return AdapterResult(
1257
+ status=AdapterResultStatus.SUCCESS,
1258
+ data=feature,
1259
+ execution_time=self._delays["feature_operation"],
1260
+ )
1261
+
1262
+ async def create_loft(
1263
+ self, params: LoftParameters
1264
+ ) -> AdapterResult[SolidWorksFeature]:
1265
+ """Mock creating a loft feature.
1266
+
1267
+ Args:
1268
+ params (LoftParameters): The params value.
1269
+
1270
+ Returns:
1271
+ AdapterResult[SolidWorksFeature]: The result produced by the operation.
1272
+ """
1273
+ if not self._current_model:
1274
+ return AdapterResult(
1275
+ status=AdapterResultStatus.ERROR, error="No active model"
1276
+ )
1277
+
1278
+ await asyncio.sleep(self._delays["feature_operation"])
1279
+ self._operation_count += 1
1280
+
1281
+ feature_id = f"Boss-Loft{len(self._features) + 1}"
1282
+ feature = SolidWorksFeature(
1283
+ name=feature_id,
1284
+ type="Loft",
1285
+ id=str(uuid.uuid4()),
1286
+ parameters={
1287
+ "profiles": params.profiles,
1288
+ "guide_curves": params.guide_curves,
1289
+ "start_tangent": params.start_tangent,
1290
+ "end_tangent": params.end_tangent,
1291
+ },
1292
+ properties={"created": datetime.now().isoformat(), "mock": True},
1293
+ )
1294
+
1295
+ feature_key = feature.id or feature.name
1296
+ self._features[feature_key] = feature
1297
+
1298
+ return AdapterResult(
1299
+ status=AdapterResultStatus.SUCCESS,
1300
+ data=feature,
1301
+ execution_time=self._delays["feature_operation"],
1302
+ )
1303
+
1304
+ async def create_sketch(self, plane: str) -> AdapterResult[dict[str, Any]]: # type: ignore[override]
1305
+ """Mock creating a sketch.
1306
+
1307
+ Args:
1308
+ plane (str): The plane value.
1309
+
1310
+ Returns:
1311
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
1312
+ """
1313
+ if not self._current_model:
1314
+ # Legacy tests start sketching immediately after connect.
1315
+ await self.create_part()
1316
+
1317
+ await asyncio.sleep(self._delays["sketch_operation"])
1318
+ self._operation_count += 1
1319
+
1320
+ sketch_id = f"Sketch{len(self._sketches) + 1}"
1321
+ self._sketches[sketch_id] = plane
1322
+ self._current_sketch = sketch_id
1323
+
1324
+ return AdapterResult(
1325
+ status=AdapterResultStatus.SUCCESS,
1326
+ data={
1327
+ "id": sketch_id,
1328
+ "name": sketch_id,
1329
+ "sketch_name": sketch_id,
1330
+ "plane": plane,
1331
+ },
1332
+ execution_time=self._delays["sketch_operation"],
1333
+ )
1334
+
1335
+ async def add_sketch_line(
1336
+ self,
1337
+ x1: float,
1338
+ y1: float,
1339
+ x2: float,
1340
+ y2: float,
1341
+ construction: bool = False,
1342
+ ) -> AdapterResult[dict[str, Any]]:
1343
+ """Legacy compatibility wrapper around add_line.
1344
+
1345
+ Args:
1346
+ x1 (float): The x1 value.
1347
+ y1 (float): The y1 value.
1348
+ x2 (float): The x2 value.
1349
+ y2 (float): The y2 value.
1350
+ construction (bool): The construction value. Defaults to False.
1351
+
1352
+ Returns:
1353
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
1354
+ """
1355
+ result = await self.add_line(x1, y1, x2, y2)
1356
+ if not result.is_success:
1357
+ return AdapterResult(
1358
+ status=result.status,
1359
+ error=result.error,
1360
+ execution_time=result.execution_time,
1361
+ )
1362
+ return AdapterResult(
1363
+ status=AdapterResultStatus.SUCCESS,
1364
+ data={
1365
+ "id": result.data,
1366
+ "start": {"x": x1, "y": y1},
1367
+ "end": {"x": x2, "y": y2},
1368
+ "construction": construction,
1369
+ },
1370
+ execution_time=result.execution_time,
1371
+ )
1372
+
1373
+ async def save_file(
1374
+ self, file_path: str | None = None
1375
+ ) -> AdapterResult[dict[str, Any]]:
1376
+ """Legacy compatibility save operation for tests.
1377
+
1378
+ Args:
1379
+ file_path (str | None): Path to the target file. Defaults to None.
1380
+
1381
+ Returns:
1382
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
1383
+ """
1384
+ if not self._current_model:
1385
+ return AdapterResult(
1386
+ status=AdapterResultStatus.ERROR,
1387
+ error="No active model",
1388
+ )
1389
+ await asyncio.sleep(0.02)
1390
+ self._operation_count += 1
1391
+ resolved_path = file_path or self._current_model.path
1392
+ return AdapterResult(
1393
+ status=AdapterResultStatus.SUCCESS,
1394
+ data={"file_path": resolved_path, "saved": True},
1395
+ execution_time=0.02,
1396
+ )
1397
+
1398
+ async def add_line(
1399
+ self, x1: float, y1: float, x2: float, y2: float
1400
+ ) -> AdapterResult[str]:
1401
+ """Mock adding a line to sketch.
1402
+
1403
+ Args:
1404
+ x1 (float): The x1 value.
1405
+ y1 (float): The y1 value.
1406
+ x2 (float): The x2 value.
1407
+ y2 (float): The y2 value.
1408
+
1409
+ Returns:
1410
+ AdapterResult[str]: The result produced by the operation.
1411
+ """
1412
+ if not self._current_sketch:
1413
+ return AdapterResult(
1414
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1415
+ )
1416
+
1417
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1418
+ self._operation_count += 1
1419
+
1420
+ line_id = f"Line{random.randint(1000, 9999)}"
1421
+ self._sketch_entity_ids.add(line_id)
1422
+
1423
+ return AdapterResult(
1424
+ status=AdapterResultStatus.SUCCESS,
1425
+ data=line_id,
1426
+ execution_time=self._delays["sketch_operation"] / 2,
1427
+ )
1428
+
1429
+ async def add_centerline(
1430
+ self, x1: float, y1: float, x2: float, y2: float
1431
+ ) -> AdapterResult[str]:
1432
+ """Mock adding a construction centerline to sketch.
1433
+
1434
+ Args:
1435
+ x1 (float): The x1 value.
1436
+ y1 (float): The y1 value.
1437
+ x2 (float): The x2 value.
1438
+ y2 (float): The y2 value.
1439
+
1440
+ Returns:
1441
+ AdapterResult[str]: The result produced by the operation.
1442
+ """
1443
+ if not self._current_sketch:
1444
+ return AdapterResult(
1445
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1446
+ )
1447
+
1448
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1449
+ self._operation_count += 1
1450
+
1451
+ centerline_id = f"Centerline_{random.randint(1000, 9999)}"
1452
+ self._sketch_entity_ids.add(centerline_id)
1453
+
1454
+ return AdapterResult(
1455
+ status=AdapterResultStatus.SUCCESS,
1456
+ data=centerline_id,
1457
+ execution_time=self._delays["sketch_operation"] / 2,
1458
+ )
1459
+
1460
+ async def add_spline(self, points: list[dict[str, float]]) -> AdapterResult[str]:
1461
+ """Mock adding a NURBS spline through the supplied points.
1462
+
1463
+ Args:
1464
+ points (list[dict[str, float]]): Ordered control points with
1465
+ ``"x"`` / ``"y"`` keys. Minimum 2 points.
1466
+
1467
+ Returns:
1468
+ AdapterResult[str]: The result produced by the operation.
1469
+ """
1470
+ if not self._current_sketch:
1471
+ return AdapterResult(
1472
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1473
+ )
1474
+ if len(points) < 2:
1475
+ return AdapterResult(
1476
+ status=AdapterResultStatus.ERROR,
1477
+ error="add_spline requires at least 2 points",
1478
+ )
1479
+ # Match the real adapter's point contract: each dict must carry
1480
+ # both ``x`` and ``y`` keys, or the live impl raises KeyError on
1481
+ # ``point["x"]``. Validating here keeps mock/live parity so tests
1482
+ # that pass against the mock won't fail live on malformed input.
1483
+ for index, point in enumerate(points):
1484
+ if not isinstance(point, dict) or "x" not in point or "y" not in point:
1485
+ return AdapterResult(
1486
+ status=AdapterResultStatus.ERROR,
1487
+ error=(
1488
+ f"add_spline point at index {index} is missing "
1489
+ "required 'x' and/or 'y' keys"
1490
+ ),
1491
+ )
1492
+
1493
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1494
+ self._operation_count += 1
1495
+
1496
+ spline_id = f"Spline_{random.randint(1000, 9999)}"
1497
+ self._sketch_entity_ids.add(spline_id)
1498
+
1499
+ return AdapterResult(
1500
+ status=AdapterResultStatus.SUCCESS,
1501
+ data=spline_id,
1502
+ execution_time=self._delays["sketch_operation"] / 2,
1503
+ )
1504
+
1505
+ async def add_circle(
1506
+ self, center_x: float, center_y: float, radius: float
1507
+ ) -> AdapterResult[str]:
1508
+ """Mock adding a circle to sketch.
1509
+
1510
+ Args:
1511
+ center_x (float): The center x value.
1512
+ center_y (float): The center y value.
1513
+ radius (float): The radius value.
1514
+
1515
+ Returns:
1516
+ AdapterResult[str]: The result produced by the operation.
1517
+ """
1518
+ if not self._current_sketch:
1519
+ return AdapterResult(
1520
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1521
+ )
1522
+
1523
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1524
+ self._operation_count += 1
1525
+
1526
+ circle_id = f"Circle{random.randint(1000, 9999)}"
1527
+ self._sketch_entity_ids.add(circle_id)
1528
+
1529
+ return AdapterResult(
1530
+ status=AdapterResultStatus.SUCCESS,
1531
+ data=circle_id,
1532
+ execution_time=self._delays["sketch_operation"] / 2,
1533
+ )
1534
+
1535
+ async def add_arc(
1536
+ self,
1537
+ center_x: float,
1538
+ center_y: float,
1539
+ start_x: float,
1540
+ start_y: float,
1541
+ end_x: float,
1542
+ end_y: float,
1543
+ ) -> AdapterResult[str]:
1544
+ """Mock adding a circular arc to sketch.
1545
+
1546
+ Args:
1547
+ center_x (float): Arc centre X.
1548
+ center_y (float): Arc centre Y.
1549
+ start_x (float): Arc start point X.
1550
+ start_y (float): Arc start point Y.
1551
+ end_x (float): Arc end point X.
1552
+ end_y (float): Arc end point Y.
1553
+
1554
+ Returns:
1555
+ AdapterResult[str]: The result produced by the operation.
1556
+ """
1557
+ if not self._current_sketch:
1558
+ return AdapterResult(
1559
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1560
+ )
1561
+
1562
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1563
+ self._operation_count += 1
1564
+
1565
+ arc_id = f"Arc_{random.randint(1000, 9999)}"
1566
+ self._sketch_entity_ids.add(arc_id)
1567
+
1568
+ return AdapterResult(
1569
+ status=AdapterResultStatus.SUCCESS,
1570
+ data=arc_id,
1571
+ execution_time=self._delays["sketch_operation"] / 2,
1572
+ )
1573
+
1574
+ async def add_rectangle(
1575
+ self, x1: float, y1: float, x2: float, y2: float
1576
+ ) -> AdapterResult[str]:
1577
+ """Mock adding a rectangle to sketch.
1578
+
1579
+ Args:
1580
+ x1 (float): The x1 value.
1581
+ y1 (float): The y1 value.
1582
+ x2 (float): The x2 value.
1583
+ y2 (float): The y2 value.
1584
+
1585
+ Returns:
1586
+ AdapterResult[str]: The result produced by the operation.
1587
+ """
1588
+ if not self._current_sketch:
1589
+ return AdapterResult(
1590
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1591
+ )
1592
+
1593
+ await asyncio.sleep(self._delays["sketch_operation"])
1594
+ self._operation_count += 1
1595
+
1596
+ rect_id = f"Rectangle{random.randint(1000, 9999)}"
1597
+ self._sketch_entity_ids.add(rect_id)
1598
+
1599
+ return AdapterResult(
1600
+ status=AdapterResultStatus.SUCCESS,
1601
+ data=rect_id,
1602
+ execution_time=self._delays["sketch_operation"],
1603
+ )
1604
+
1605
+ async def add_ellipse(
1606
+ self,
1607
+ center_x: float,
1608
+ center_y: float,
1609
+ major_axis: float,
1610
+ minor_axis: float,
1611
+ ) -> AdapterResult[str]:
1612
+ """Mock adding an axis-aligned ellipse to sketch.
1613
+
1614
+ Args:
1615
+ center_x (float): Ellipse centre X in millimetres.
1616
+ center_y (float): Ellipse centre Y in millimetres.
1617
+ major_axis (float): Full major-axis length in millimetres.
1618
+ minor_axis (float): Full minor-axis length in millimetres.
1619
+
1620
+ Returns:
1621
+ AdapterResult[str]: The result produced by the operation.
1622
+ """
1623
+ if not self._current_sketch:
1624
+ return AdapterResult(
1625
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1626
+ )
1627
+
1628
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1629
+ self._operation_count += 1
1630
+
1631
+ ellipse_id = f"Ellipse_{random.randint(1000, 9999)}"
1632
+ self._sketch_entity_ids.add(ellipse_id)
1633
+
1634
+ return AdapterResult(
1635
+ status=AdapterResultStatus.SUCCESS,
1636
+ data=ellipse_id,
1637
+ execution_time=self._delays["sketch_operation"] / 2,
1638
+ )
1639
+
1640
+ async def add_polygon(
1641
+ self, center_x: float, center_y: float, radius: float, sides: int
1642
+ ) -> AdapterResult[str]:
1643
+ """Mock adding a regular polygon to sketch.
1644
+
1645
+ Args:
1646
+ center_x (float): Polygon centre X in millimetres.
1647
+ center_y (float): Polygon centre Y in millimetres.
1648
+ radius (float): Circumradius in millimetres (distance from centre
1649
+ to each vertex; matches the real adapter's
1650
+ ``CreatePolygon(..., Inscribed=True)`` semantics).
1651
+ sides (int): Number of polygon sides.
1652
+
1653
+ Returns:
1654
+ AdapterResult[str]: The result produced by the operation.
1655
+ """
1656
+ if not self._current_sketch:
1657
+ return AdapterResult(
1658
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1659
+ )
1660
+
1661
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1662
+ self._operation_count += 1
1663
+
1664
+ # ID format mirrors the real PyWin32 adapter, which uses
1665
+ # ``_register_sketch_entity("Polygon", ...)`` (counter-based) so the
1666
+ # returned ID is a valid input to downstream sketch_linear_pattern /
1667
+ # sketch_circular_pattern / sketch_mirror / sketch_offset calls.
1668
+ polygon_id = f"Polygon_{len(self._sketch_entity_ids) + 1}"
1669
+ self._sketch_entity_ids.add(polygon_id)
1670
+ _ = sides # informational only; the sides count is no longer part of the ID
1671
+
1672
+ return AdapterResult(
1673
+ status=AdapterResultStatus.SUCCESS,
1674
+ data=polygon_id,
1675
+ execution_time=self._delays["sketch_operation"] / 2,
1676
+ )
1677
+
1678
+ async def sketch_linear_pattern(
1679
+ self,
1680
+ entities: list[str],
1681
+ direction_x: float,
1682
+ direction_y: float,
1683
+ spacing: float,
1684
+ count: int,
1685
+ ) -> AdapterResult[str]:
1686
+ """Mock creating a linear sketch pattern.
1687
+
1688
+ Mirrors the real adapter's validation rules — empty entities,
1689
+ count < 2, non-positive spacing, and a zero direction vector each
1690
+ produce a clear error without "creating" a pattern.
1691
+
1692
+ Args:
1693
+ entities (list[str]): Seed entity IDs.
1694
+ direction_x (float): Pattern direction X component.
1695
+ direction_y (float): Pattern direction Y component.
1696
+ spacing (float): Distance between instances in millimetres.
1697
+ count (int): Total number of instances (including the seed).
1698
+
1699
+ Returns:
1700
+ AdapterResult[str]: The result produced by the operation.
1701
+ """
1702
+ if not self._current_sketch:
1703
+ return AdapterResult(
1704
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1705
+ )
1706
+ if not entities:
1707
+ return AdapterResult(
1708
+ status=AdapterResultStatus.ERROR,
1709
+ error="sketch_linear_pattern requires at least one entity",
1710
+ )
1711
+ if count < 2:
1712
+ return AdapterResult(
1713
+ status=AdapterResultStatus.ERROR,
1714
+ error="sketch_linear_pattern requires count >= 2",
1715
+ )
1716
+ if spacing <= 0:
1717
+ return AdapterResult(
1718
+ status=AdapterResultStatus.ERROR,
1719
+ error="sketch_linear_pattern requires spacing > 0",
1720
+ )
1721
+ if math.hypot(direction_x, direction_y) < 1e-9:
1722
+ return AdapterResult(
1723
+ status=AdapterResultStatus.ERROR,
1724
+ error=("sketch_linear_pattern requires a non-zero direction vector"),
1725
+ )
1726
+ for ent in entities:
1727
+ if ent not in self._sketch_entity_ids:
1728
+ return AdapterResult(
1729
+ status=AdapterResultStatus.ERROR,
1730
+ error=(
1731
+ f"Unknown sketch entity '{ent}'. Use IDs returned by "
1732
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1733
+ ),
1734
+ )
1735
+
1736
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1737
+ self._operation_count += 1
1738
+
1739
+ pattern_id = f"LinearPattern_{count}x{spacing}_{random.randint(1000, 9999)}"
1740
+ return AdapterResult(
1741
+ status=AdapterResultStatus.SUCCESS,
1742
+ data=pattern_id,
1743
+ execution_time=self._delays["sketch_operation"] / 2,
1744
+ )
1745
+
1746
+ async def sketch_circular_pattern(
1747
+ self,
1748
+ entities: list[str],
1749
+ angle: float,
1750
+ count: int,
1751
+ ) -> AdapterResult[str]:
1752
+ """Mock creating a circular sketch pattern around the sketch origin.
1753
+
1754
+ Args:
1755
+ entities (list[str]): Seed entity IDs.
1756
+ angle (float): Total swept angle in degrees.
1757
+ count (int): Total number of instances (including the seed).
1758
+
1759
+ Returns:
1760
+ AdapterResult[str]: The result produced by the operation.
1761
+ """
1762
+ if not self._current_sketch:
1763
+ return AdapterResult(
1764
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1765
+ )
1766
+ if not entities:
1767
+ return AdapterResult(
1768
+ status=AdapterResultStatus.ERROR,
1769
+ error="sketch_circular_pattern requires at least one entity",
1770
+ )
1771
+ if count < 2:
1772
+ return AdapterResult(
1773
+ status=AdapterResultStatus.ERROR,
1774
+ error="sketch_circular_pattern requires count >= 2",
1775
+ )
1776
+ if angle <= 0:
1777
+ return AdapterResult(
1778
+ status=AdapterResultStatus.ERROR,
1779
+ error="sketch_circular_pattern requires angle > 0",
1780
+ )
1781
+ for ent in entities:
1782
+ if ent not in self._sketch_entity_ids:
1783
+ return AdapterResult(
1784
+ status=AdapterResultStatus.ERROR,
1785
+ error=(
1786
+ f"Unknown sketch entity '{ent}'. Use IDs returned by "
1787
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1788
+ ),
1789
+ )
1790
+
1791
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1792
+ self._operation_count += 1
1793
+
1794
+ pattern_id = f"CircularPattern_{count}x{angle}deg_{random.randint(1000, 9999)}"
1795
+ return AdapterResult(
1796
+ status=AdapterResultStatus.SUCCESS,
1797
+ data=pattern_id,
1798
+ execution_time=self._delays["sketch_operation"] / 2,
1799
+ )
1800
+
1801
+ async def sketch_mirror(
1802
+ self, entities: list[str], mirror_line: str
1803
+ ) -> AdapterResult[str]:
1804
+ """Mock mirroring sketch entities about a centerline.
1805
+
1806
+ Args:
1807
+ entities (list[str]): IDs of segments to mirror.
1808
+ mirror_line (str): ID of the centerline to mirror across.
1809
+
1810
+ Returns:
1811
+ AdapterResult[str]: The result produced by the operation.
1812
+ """
1813
+ if not self._current_sketch:
1814
+ return AdapterResult(
1815
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1816
+ )
1817
+ if not entities:
1818
+ return AdapterResult(
1819
+ status=AdapterResultStatus.ERROR,
1820
+ error="sketch_mirror requires at least one entity",
1821
+ )
1822
+ if not mirror_line:
1823
+ return AdapterResult(
1824
+ status=AdapterResultStatus.ERROR,
1825
+ error=(
1826
+ "sketch_mirror requires a mirror_line entity ID (add_centerline)"
1827
+ ),
1828
+ )
1829
+ if mirror_line not in self._sketch_entity_ids:
1830
+ return AdapterResult(
1831
+ status=AdapterResultStatus.ERROR,
1832
+ error=(
1833
+ f"Unknown mirror_line entity '{mirror_line}'. Use the "
1834
+ "ID returned by add_centerline."
1835
+ ),
1836
+ )
1837
+ # Mirror the real adapter — IModelDoc2::SketchMirror needs a
1838
+ # centreline as the mirror axis; other segments silently no-op.
1839
+ if not mirror_line.startswith("Centerline_"):
1840
+ return AdapterResult(
1841
+ status=AdapterResultStatus.ERROR,
1842
+ error=(
1843
+ f"mirror_line must be a centerline (from add_centerline), "
1844
+ f"got '{mirror_line}'"
1845
+ ),
1846
+ )
1847
+ for ent in entities:
1848
+ if ent not in self._sketch_entity_ids:
1849
+ return AdapterResult(
1850
+ status=AdapterResultStatus.ERROR,
1851
+ error=(
1852
+ f"Unknown sketch entity '{ent}'. Use IDs returned by "
1853
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1854
+ ),
1855
+ )
1856
+
1857
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1858
+ self._operation_count += 1
1859
+
1860
+ mirror_id = f"Mirror_{mirror_line}_{random.randint(1000, 9999)}"
1861
+ return AdapterResult(
1862
+ status=AdapterResultStatus.SUCCESS,
1863
+ data=mirror_id,
1864
+ execution_time=self._delays["sketch_operation"] / 2,
1865
+ )
1866
+
1867
+ async def sketch_offset(
1868
+ self,
1869
+ entities: list[str],
1870
+ offset_distance: float,
1871
+ reverse_direction: bool,
1872
+ ) -> AdapterResult[str]:
1873
+ """Mock offsetting sketch entities by a fixed distance.
1874
+
1875
+ Args:
1876
+ entities (list[str]): IDs of segments to offset.
1877
+ offset_distance (float): Distance in millimetres. Must be > 0.
1878
+ reverse_direction (bool): Flip the offset direction.
1879
+
1880
+ Returns:
1881
+ AdapterResult[str]: The result produced by the operation.
1882
+ """
1883
+ if not self._current_sketch:
1884
+ return AdapterResult(
1885
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1886
+ )
1887
+ if not entities:
1888
+ return AdapterResult(
1889
+ status=AdapterResultStatus.ERROR,
1890
+ error="sketch_offset requires at least one entity",
1891
+ )
1892
+ if offset_distance <= 0:
1893
+ return AdapterResult(
1894
+ status=AdapterResultStatus.ERROR,
1895
+ error=(
1896
+ "sketch_offset requires offset_distance > 0 — use "
1897
+ "reverse_direction to flip the side"
1898
+ ),
1899
+ )
1900
+ for ent in entities:
1901
+ if ent not in self._sketch_entity_ids:
1902
+ return AdapterResult(
1903
+ status=AdapterResultStatus.ERROR,
1904
+ error=(
1905
+ f"Unknown sketch entity '{ent}'. Use IDs returned by "
1906
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1907
+ ),
1908
+ )
1909
+
1910
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1911
+ self._operation_count += 1
1912
+
1913
+ direction = "inward" if reverse_direction else "outward"
1914
+ offset_id = f"Offset_{offset_distance}_{direction}_{random.randint(1000, 9999)}"
1915
+ return AdapterResult(
1916
+ status=AdapterResultStatus.SUCCESS,
1917
+ data=offset_id,
1918
+ execution_time=self._delays["sketch_operation"] / 2,
1919
+ )
1920
+
1921
+ async def add_sketch_constraint(
1922
+ self,
1923
+ entity1: str,
1924
+ entity2: str | None,
1925
+ relation_type: str,
1926
+ entity3: str | None = None,
1927
+ ) -> AdapterResult[str]:
1928
+ """Mock adding a geometric constraint between sketch entities.
1929
+
1930
+ Args:
1931
+ entity1 (str): Primary sketch-entity ID.
1932
+ entity2 (str | None): Secondary sketch-entity ID, or None for
1933
+ single-entity relations (horizontal, vertical, fix).
1934
+ relation_type (str): Constraint name (see RELATION_NAME_MAP).
1935
+ entity3 (str | None): Third entity ID — only used by the
1936
+ ``symmetric`` relation (the centerline of symmetry). All
1937
+ other relation types reject a non-null ``entity3``.
1938
+
1939
+ Returns:
1940
+ AdapterResult[str]: SUCCESS with a placeholder constraint ID, or
1941
+ ERROR if no active sketch, the relation_type is unsupported,
1942
+ arity is wrong, or an entity ID is unknown.
1943
+ """
1944
+ if not self._current_sketch:
1945
+ return AdapterResult(
1946
+ status=AdapterResultStatus.ERROR, error="No active sketch"
1947
+ )
1948
+
1949
+ rt_norm = (relation_type or "").strip().lower()
1950
+ if rt_norm not in RELATION_NAME_MAP:
1951
+ supported = ", ".join(sorted(RELATION_NAME_MAP))
1952
+ return AdapterResult(
1953
+ status=AdapterResultStatus.ERROR,
1954
+ error=(
1955
+ f"Unsupported relation type '{relation_type}'. "
1956
+ f"Supported: {supported}"
1957
+ ),
1958
+ )
1959
+
1960
+ # Arity validation — mirror the real adapter
1961
+ if rt_norm == "symmetric":
1962
+ if entity2 is None or entity3 is None:
1963
+ return AdapterResult(
1964
+ status=AdapterResultStatus.ERROR,
1965
+ error=(
1966
+ f"Relation '{relation_type}' requires entity1, "
1967
+ "entity2, and entity3 (the centerline of symmetry)"
1968
+ ),
1969
+ )
1970
+ elif entity3 is not None:
1971
+ return AdapterResult(
1972
+ status=AdapterResultStatus.ERROR,
1973
+ error=(
1974
+ f"Relation '{relation_type}' does not accept entity3 — "
1975
+ "only 'symmetric' takes a third entity (the centerline)"
1976
+ ),
1977
+ )
1978
+
1979
+ # Validate entity IDs against the in-process sketch-entity registry
1980
+ # so the mock surfaces the same "Unknown sketch entity" error as the
1981
+ # real adapter (which checks adapter._sketch_entities).
1982
+ for ent in (entity1, entity2, entity3):
1983
+ if ent is not None and ent not in self._sketch_entity_ids:
1984
+ return AdapterResult(
1985
+ status=AdapterResultStatus.ERROR,
1986
+ error=(
1987
+ f"Unknown sketch entity '{ent}'. Use IDs returned by "
1988
+ "add_line/add_arc/add_circle/add_spline/add_centerline."
1989
+ ),
1990
+ )
1991
+
1992
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
1993
+ self._operation_count += 1
1994
+
1995
+ constraint_id = f"Constraint_{relation_type}_{random.randint(1000, 9999)}"
1996
+
1997
+ return AdapterResult(
1998
+ status=AdapterResultStatus.SUCCESS,
1999
+ data=constraint_id,
2000
+ execution_time=self._delays["sketch_operation"] / 2,
2001
+ )
2002
+
2003
+ async def exit_sketch(self) -> AdapterResult[None]:
2004
+ """Mock exiting sketch mode.
2005
+
2006
+ Returns:
2007
+ AdapterResult[None]: The result produced by the operation.
2008
+ """
2009
+ if not self._current_sketch:
2010
+ return AdapterResult(
2011
+ status=AdapterResultStatus.WARNING, error="No active sketch to exit"
2012
+ )
2013
+
2014
+ await asyncio.sleep(self._delays["sketch_operation"] / 2)
2015
+ self._current_sketch = None
2016
+
2017
+ return AdapterResult(
2018
+ status=AdapterResultStatus.SUCCESS,
2019
+ data=None,
2020
+ execution_time=self._delays["sketch_operation"] / 2,
2021
+ )
2022
+
2023
+ async def check_sketch_fully_defined(
2024
+ self, sketch_name: str | None = None
2025
+ ) -> AdapterResult[dict[str, Any]]:
2026
+ """Mock check for sketch definition status.
2027
+
2028
+ Args:
2029
+ sketch_name (str | None): Optional sketch name to inspect. Defaults to None.
2030
+
2031
+ Returns:
2032
+ AdapterResult[dict[str, Any]]: Definition status payload.
2033
+ """
2034
+ if not self._current_model:
2035
+ return AdapterResult(
2036
+ status=AdapterResultStatus.ERROR,
2037
+ error="No active model",
2038
+ )
2039
+
2040
+ await asyncio.sleep(self._delays["sketch_operation"] / 3)
2041
+ self._operation_count += 1
2042
+
2043
+ resolved_name = sketch_name or self._current_sketch or "Sketch1"
2044
+ if sketch_name and sketch_name not in self._sketches:
2045
+ return AdapterResult(
2046
+ status=AdapterResultStatus.ERROR,
2047
+ error=f"Sketch not found: {sketch_name}",
2048
+ )
2049
+
2050
+ return AdapterResult(
2051
+ status=AdapterResultStatus.SUCCESS,
2052
+ data={
2053
+ "sketch_name": resolved_name,
2054
+ "is_fully_defined": True,
2055
+ "definition_state": "fully_defined",
2056
+ "source": "mock",
2057
+ "raw_status": True,
2058
+ },
2059
+ execution_time=self._delays["sketch_operation"] / 3,
2060
+ )
2061
+
2062
+ async def get_mass_properties(self) -> AdapterResult[MassProperties]:
2063
+ """Mock getting mass properties.
2064
+
2065
+ Returns:
2066
+ AdapterResult[MassProperties]: The result produced by the operation.
2067
+ """
2068
+ if not self._current_model:
2069
+ return AdapterResult(
2070
+ status=AdapterResultStatus.ERROR, error="No active model"
2071
+ )
2072
+
2073
+ await asyncio.sleep(self._delays["feature_operation"])
2074
+ self._operation_count += 1
2075
+
2076
+ # Generate realistic mock data
2077
+ properties = MassProperties(
2078
+ volume=random.uniform(1000, 100000), # mm³
2079
+ surface_area=random.uniform(1000, 50000), # mm²
2080
+ mass=random.uniform(0.1, 50.0), # kg (assuming typical density)
2081
+ center_of_mass=[
2082
+ random.uniform(-100, 100),
2083
+ random.uniform(-100, 100),
2084
+ random.uniform(-100, 100),
2085
+ ],
2086
+ moments_of_inertia={
2087
+ "Ixx": random.uniform(1e6, 1e9),
2088
+ "Iyy": random.uniform(1e6, 1e9),
2089
+ "Izz": random.uniform(1e6, 1e9),
2090
+ "Ixy": random.uniform(-1e8, 1e8),
2091
+ "Ixz": random.uniform(-1e8, 1e8),
2092
+ "Iyz": random.uniform(-1e8, 1e8),
2093
+ },
2094
+ )
2095
+
2096
+ return AdapterResult(
2097
+ status=AdapterResultStatus.SUCCESS,
2098
+ data=properties,
2099
+ execution_time=self._delays["feature_operation"],
2100
+ )
2101
+
2102
+ async def export_image(self, payload: dict) -> AdapterResult[dict]:
2103
+ """Mock export of a PNG/JPG viewport screenshot.
2104
+
2105
+ Args:
2106
+ payload (dict): The payload value.
2107
+
2108
+ Returns:
2109
+ AdapterResult[dict]: The result produced by the operation.
2110
+ """
2111
+ if not self._current_model:
2112
+ return AdapterResult(
2113
+ status=AdapterResultStatus.ERROR, error="No active model"
2114
+ )
2115
+
2116
+ import pathlib
2117
+
2118
+ file_path = payload.get("file_path", "")
2119
+ width = int(payload.get("width", 1280))
2120
+ height = int(payload.get("height", 720))
2121
+ orientation = str(payload.get("view_orientation", "current"))
2122
+
2123
+ await asyncio.sleep(0.3)
2124
+ self._operation_count += 1
2125
+
2126
+ out = pathlib.Path(file_path)
2127
+ out.parent.mkdir(parents=True, exist_ok=True)
2128
+
2129
+ # Write a valid 1×1 white PNG binary so UI image tags don't break
2130
+ _PNG_1X1_WHITE = (
2131
+ b"\x89PNG\r\n\x1a\n" # PNG signature
2132
+ b"\x00\x00\x00\rIHDR" # IHDR chunk length+type
2133
+ b"\x00\x00\x00\x01\x00\x00\x00\x01" # 1x1
2134
+ b"\x08\x02\x00\x00\x00\x90wS\xde" # 8-bit RGB + CRC
2135
+ b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xff\xff\xff\x00\x05\xfe\x02\xfe\xdc\xcaY\xe7" # IDAT
2136
+ b"\x00\x00\x00\x00IEND\xaeB`\x82" # IEND
2137
+ )
2138
+ out.write_bytes(_PNG_1X1_WHITE)
2139
+
2140
+ return AdapterResult(
2141
+ status=AdapterResultStatus.SUCCESS,
2142
+ data={
2143
+ "file_path": file_path,
2144
+ "format": "PNG",
2145
+ "dimensions": f"{width}x{height}",
2146
+ "view": orientation,
2147
+ },
2148
+ execution_time=0.3,
2149
+ metadata={"mock": True, "orientation": orientation},
2150
+ )
2151
+
2152
+ async def pack_and_go_assembly(
2153
+ self,
2154
+ source_path: str,
2155
+ target_dir: str,
2156
+ ) -> AdapterResult[dict]:
2157
+ """Mock Pack-and-Go: simulate copying an assembly to a target directory.
2158
+
2159
+ Args:
2160
+ source_path (str): Path to the source .sldasm file.
2161
+ target_dir (str): Directory to copy files into.
2162
+
2163
+ Returns:
2164
+ AdapterResult[dict]: Simulated pack-and-go result.
2165
+ """
2166
+ import pathlib
2167
+
2168
+ await asyncio.sleep(0.05)
2169
+ self._operation_count += 1
2170
+
2171
+ source = pathlib.Path(source_path)
2172
+ out_dir = pathlib.Path(target_dir)
2173
+ mock_parts = [f"MockPart{i}.SLDPRT" for i in range(1, 3)]
2174
+ source_files = [str(source)] + [str(source.parent / p) for p in mock_parts]
2175
+ copied_files = [str(out_dir / source.name)] + [
2176
+ str(out_dir / p) for p in mock_parts
2177
+ ]
2178
+ return AdapterResult(
2179
+ status=AdapterResultStatus.SUCCESS,
2180
+ data={
2181
+ "source_assembly": str(source),
2182
+ "target_dir": str(out_dir),
2183
+ "copied_files": copied_files,
2184
+ "source_files": source_files,
2185
+ "save_statuses": [0] * (len(mock_parts) + 1),
2186
+ "all_files_saved": True,
2187
+ },
2188
+ execution_time=0.05,
2189
+ metadata={"mock": True},
2190
+ )
2191
+
2192
+ async def export_file(
2193
+ self, file_path: str, format_type: str
2194
+ ) -> AdapterResult[None]:
2195
+ """Mock exporting a file.
2196
+
2197
+ Args:
2198
+ file_path (str): Path to the target file.
2199
+ format_type (str): The format type value.
2200
+
2201
+ Returns:
2202
+ AdapterResult[None]: The result produced by the operation.
2203
+ """
2204
+ if not self._current_model:
2205
+ return AdapterResult(
2206
+ status=AdapterResultStatus.ERROR, error="No active model"
2207
+ )
2208
+
2209
+ # Simulate export time based on format
2210
+ export_times = {
2211
+ "step": 2.0,
2212
+ "iges": 1.5,
2213
+ "stl": 1.0,
2214
+ "pdf": 0.5,
2215
+ "jpg": 0.3,
2216
+ "glb": 1.2,
2217
+ "gltf": 1.2,
2218
+ }
2219
+
2220
+ format_lower = format_type.lower()
2221
+ delay = export_times.get(format_lower, 1.0)
2222
+
2223
+ await asyncio.sleep(delay)
2224
+ self._operation_count += 1
2225
+
2226
+ # Write a minimal placeholder file so callers that check existence get a valid result
2227
+ if format_lower == "stl":
2228
+ import pathlib
2229
+
2230
+ out = pathlib.Path(file_path)
2231
+ out.parent.mkdir(parents=True, exist_ok=True)
2232
+ # Minimal ASCII STL — a single unit triangle (mock geometry placeholder)
2233
+ out.write_text(
2234
+ "solid mock\n"
2235
+ " facet normal 0 0 1\n"
2236
+ " outer loop\n"
2237
+ " vertex 0 0 0\n"
2238
+ " vertex 10 0 0\n"
2239
+ " vertex 5 10 0\n"
2240
+ " endloop\n"
2241
+ " endfacet\n"
2242
+ " facet normal 0 0 1\n"
2243
+ " outer loop\n"
2244
+ " vertex 0 0 0\n"
2245
+ " vertex 10 0 0\n"
2246
+ " vertex 5 0 10\n"
2247
+ " endloop\n"
2248
+ " endfacet\n"
2249
+ "endsolid mock\n",
2250
+ encoding="utf-8",
2251
+ )
2252
+
2253
+ return AdapterResult(
2254
+ status=AdapterResultStatus.SUCCESS,
2255
+ data=None,
2256
+ execution_time=delay,
2257
+ metadata={"exported_format": format_type, "file_path": file_path},
2258
+ )
2259
+
2260
+ async def get_dimension(self, name: str) -> AdapterResult[float]:
2261
+ """Mock getting a dimension value.
2262
+
2263
+ Args:
2264
+ name (str): The name value.
2265
+
2266
+ Returns:
2267
+ AdapterResult[float]: The result produced by the operation.
2268
+ """
2269
+ await asyncio.sleep(0.05) # Very fast operation
2270
+ self._operation_count += 1
2271
+
2272
+ if name in self._dimensions:
2273
+ return AdapterResult(
2274
+ status=AdapterResultStatus.SUCCESS,
2275
+ data=self._dimensions[name],
2276
+ execution_time=0.05,
2277
+ )
2278
+ else:
2279
+ return AdapterResult(
2280
+ status=AdapterResultStatus.ERROR, error=f"Dimension '{name}' not found"
2281
+ )
2282
+
2283
+ async def set_dimension(self, name: str, value: float) -> AdapterResult[None]:
2284
+ """Mock setting a dimension value.
2285
+
2286
+ Args:
2287
+ name (str): The name value.
2288
+ value (float): The value value.
2289
+
2290
+ Returns:
2291
+ AdapterResult[None]: The result produced by the operation.
2292
+ """
2293
+ await asyncio.sleep(0.1) # Fast operation
2294
+ self._operation_count += 1
2295
+
2296
+ self._dimensions[name] = value
2297
+
2298
+ return AdapterResult(
2299
+ status=AdapterResultStatus.SUCCESS, data=None, execution_time=0.1
2300
+ )
2301
+
2302
+ async def set_units(self, unit_system: str) -> AdapterResult[dict[str, Any]]:
2303
+ """Mock setting the active document's linear unit system.
2304
+
2305
+ Mirrors the real adapter: an unrecognised token is an error, and the
2306
+ applied token is recorded on the current model's ``properties``.
2307
+
2308
+ Args:
2309
+ unit_system (str): One of ``mm``, ``cm``, ``m``, ``in``, ``ft``
2310
+ (aliases such as ``inch`` / ``millimeters`` accepted).
2311
+
2312
+ Returns:
2313
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2314
+ """
2315
+ await asyncio.sleep(0.1)
2316
+
2317
+ token = _normalise_mock_unit_system(unit_system)
2318
+ if token is None:
2319
+ return AdapterResult(
2320
+ status=AdapterResultStatus.ERROR,
2321
+ error=(
2322
+ f"Unrecognised unit system {unit_system!r}. "
2323
+ "Expected one of: mm, cm, m, in, ft."
2324
+ ),
2325
+ )
2326
+ if self._current_model is None:
2327
+ return AdapterResult(
2328
+ status=AdapterResultStatus.ERROR, error="No active model"
2329
+ )
2330
+
2331
+ self._operation_count += 1
2332
+ props = dict(self._current_model.properties or {})
2333
+ props["units"] = token
2334
+ self._current_model.properties = props
2335
+
2336
+ return AdapterResult(
2337
+ status=AdapterResultStatus.SUCCESS,
2338
+ data={"unit_system": token, "verified": True},
2339
+ execution_time=0.1,
2340
+ )
2341
+
2342
+ async def list_open_documents(self) -> AdapterResult[list[dict[str, Any]]]:
2343
+ """Mock enumerating the currently open documents.
2344
+
2345
+ Returns every model the mock has created or opened this session, with
2346
+ ``is_active`` reflecting ``self._current_model``.
2347
+
2348
+ Returns:
2349
+ AdapterResult[list[dict[str, Any]]]: The result produced by the
2350
+ operation.
2351
+ """
2352
+ await asyncio.sleep(0.05)
2353
+ if not self._connected:
2354
+ return AdapterResult(
2355
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
2356
+ )
2357
+
2358
+ active_path = (
2359
+ self._current_model.path if self._current_model is not None else None
2360
+ )
2361
+ docs = [
2362
+ {
2363
+ "title": model.name,
2364
+ "path": model.path,
2365
+ "type": model.type,
2366
+ "is_active": model.path == active_path,
2367
+ }
2368
+ for model in self._models.values()
2369
+ ]
2370
+ return AdapterResult(
2371
+ status=AdapterResultStatus.SUCCESS, data=docs, execution_time=0.05
2372
+ )
2373
+
2374
+ async def activate_document(
2375
+ self, title_or_path: str
2376
+ ) -> AdapterResult[dict[str, Any]]:
2377
+ """Mock making an already-open document active.
2378
+
2379
+ Args:
2380
+ title_or_path (str): Title, full path, or file name of an open
2381
+ document.
2382
+
2383
+ Returns:
2384
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2385
+ """
2386
+ await asyncio.sleep(0.05)
2387
+ if not self._connected:
2388
+ return AdapterResult(
2389
+ status=AdapterResultStatus.ERROR, error="Not connected to SolidWorks"
2390
+ )
2391
+ target = (title_or_path or "").strip()
2392
+ if not target:
2393
+ return AdapterResult(
2394
+ status=AdapterResultStatus.ERROR, error="title_or_path is required"
2395
+ )
2396
+
2397
+ needle = target.lower()
2398
+ match = None
2399
+ for model in self._models.values():
2400
+ names = {
2401
+ model.name.lower(),
2402
+ model.path.lower(),
2403
+ model.path.rsplit("/", 1)[-1].lower(),
2404
+ }
2405
+ if needle in names:
2406
+ match = model
2407
+ break
2408
+ if match is None:
2409
+ open_titles = ", ".join(m.name for m in self._models.values()) or "none"
2410
+ return AdapterResult(
2411
+ status=AdapterResultStatus.ERROR,
2412
+ error=f"No open document matches {target!r}. Open: {open_titles}",
2413
+ )
2414
+
2415
+ self._current_model = match
2416
+ self._operation_count += 1
2417
+ return AdapterResult(
2418
+ status=AdapterResultStatus.SUCCESS,
2419
+ data={"activated": match.name, "verified": True},
2420
+ execution_time=0.05,
2421
+ )
2422
+
2423
+ async def insert_component(
2424
+ self, file_path: str, x: float = 0.0, y: float = 0.0, z: float = 0.0
2425
+ ) -> AdapterResult[dict[str, Any]]:
2426
+ """Mock inserting a component into the active assembly.
2427
+
2428
+ Records the component in ``self._components`` so repeated inserts
2429
+ accumulate and ``list_components`` reflects them.
2430
+
2431
+ Args:
2432
+ file_path (str): Path to the part or sub-assembly to insert.
2433
+ x (float): X position in millimetres. Defaults to 0.0.
2434
+ y (float): Y position in millimetres. Defaults to 0.0.
2435
+ z (float): Z position in millimetres. Defaults to 0.0.
2436
+
2437
+ Returns:
2438
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2439
+ """
2440
+ await asyncio.sleep(self._delays["model_operation"])
2441
+ self._operation_count += 1
2442
+
2443
+ self._components.append(f"component-{len(self._components) + 1}")
2444
+
2445
+ return AdapterResult(
2446
+ status=AdapterResultStatus.SUCCESS,
2447
+ data={
2448
+ "component": self._components[-1],
2449
+ "file_path": file_path,
2450
+ "position": {"x": x, "y": y, "z": z},
2451
+ "components_before": len(self._components) - 1,
2452
+ "components_after": len(self._components),
2453
+ },
2454
+ execution_time=self._delays["model_operation"],
2455
+ )
2456
+
2457
+ async def list_components(self) -> AdapterResult[list[str]]:
2458
+ """Mock listing the top-level components of the active assembly.
2459
+
2460
+ Returns:
2461
+ AdapterResult[list[str]]: The result produced by the operation.
2462
+ """
2463
+ await asyncio.sleep(self._delays["model_operation"] / 2)
2464
+ self._operation_count += 1
2465
+
2466
+ return AdapterResult(
2467
+ status=AdapterResultStatus.SUCCESS,
2468
+ data=list(self._components),
2469
+ execution_time=self._delays["model_operation"] / 2,
2470
+ )
2471
+
2472
+ async def get_model_geometry_summary(self) -> AdapterResult[dict[str, Any]]:
2473
+ """Mock summary of the active part or assembly.
2474
+
2475
+ The mock has no geometry, so volume, bounding box and body count are
2476
+ ``None`` ("not determinable") rather than invented numbers a caller
2477
+ could mistake for a measurement. Component and mate counts are real:
2478
+ they come from ``insert_component`` and ``add_mate`` this session.
2479
+
2480
+ Returns:
2481
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2482
+ """
2483
+ guard = self._mock_doc_error({"Part", "Assembly"}, "get_model_geometry_summary")
2484
+ if guard is not None:
2485
+ return guard
2486
+ await asyncio.sleep(self._delays["model_operation"] / 2)
2487
+ self._operation_count += 1
2488
+
2489
+ is_assembly = self._current_model.type == "Assembly"
2490
+ summary: dict[str, Any] = {
2491
+ "document_type": "assembly" if is_assembly else "part",
2492
+ "volume_mm3": None,
2493
+ "bounding_box_mm": None,
2494
+ "bounding_box_method": None,
2495
+ "solid_body_count": None,
2496
+ }
2497
+ if is_assembly:
2498
+ summary["component_count"] = len(self._components)
2499
+ summary["mate_count"] = len(self._mates)
2500
+ return AdapterResult(
2501
+ status=AdapterResultStatus.SUCCESS,
2502
+ data=summary,
2503
+ execution_time=self._delays["model_operation"] / 2,
2504
+ )
2505
+
2506
+ # Drawing Operations
2507
+ def _mock_place_view(self, payload: Any) -> AdapterResult[dict[str, Any]]:
2508
+ """Record a drawing view so ``list_drawing_views`` reflects it.
2509
+
2510
+ Args:
2511
+ payload (Any): Tool payload, as the real adapter receives it.
2512
+
2513
+ Returns:
2514
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2515
+ """
2516
+ data = payload if isinstance(payload, dict) else {}
2517
+ model_path = data.get("model_path") or data.get("model_file")
2518
+ if not model_path:
2519
+ return AdapterResult(
2520
+ status=AdapterResultStatus.ERROR,
2521
+ error="A model path is required (model_path or model_file)",
2522
+ )
2523
+
2524
+ orientation = str(data.get("orientation") or data.get("view_type") or "front")
2525
+ if orientation.strip().lower() not in _MOCK_NAMED_VIEWS:
2526
+ return AdapterResult(
2527
+ status=AdapterResultStatus.ERROR,
2528
+ error=(
2529
+ f"Unknown orientation '{orientation}'. Use one of: "
2530
+ f"{', '.join(sorted(_MOCK_NAMED_VIEWS))}."
2531
+ ),
2532
+ )
2533
+
2534
+ before = len(self._drawing_views)
2535
+ self._drawing_views.append(f"Drawing View{before + 1}")
2536
+ self._operation_count += 1
2537
+
2538
+ return AdapterResult(
2539
+ status=AdapterResultStatus.SUCCESS,
2540
+ data={
2541
+ "name": self._drawing_views[-1],
2542
+ "model_path": model_path,
2543
+ "orientation": orientation,
2544
+ "position": {
2545
+ "x": float(data.get("position_x", 100.0)),
2546
+ "y": float(data.get("position_y", 150.0)),
2547
+ },
2548
+ "views_before": before,
2549
+ "views_after": len(self._drawing_views),
2550
+ },
2551
+ execution_time=self._delays["model_operation"],
2552
+ )
2553
+
2554
+ async def create_drawing_view(
2555
+ self, payload: Any = None
2556
+ ) -> AdapterResult[dict[str, Any]]:
2557
+ """Mock placing a view of a model on the active drawing sheet.
2558
+
2559
+ Args:
2560
+ payload (Any): Tool payload carrying the model path and orientation.
2561
+
2562
+ Returns:
2563
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2564
+ """
2565
+ await asyncio.sleep(self._delays["model_operation"])
2566
+ return self._mock_place_view(payload)
2567
+
2568
+ async def add_drawing_view(
2569
+ self, payload: Any = None
2570
+ ) -> AdapterResult[dict[str, Any]]:
2571
+ """Mock adding a view of a model to the active drawing sheet.
2572
+
2573
+ Args:
2574
+ payload (Any): Tool payload carrying the model path and orientation.
2575
+
2576
+ Returns:
2577
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2578
+ """
2579
+ await asyncio.sleep(self._delays["model_operation"])
2580
+ return self._mock_place_view(payload)
2581
+
2582
+ async def create_technical_drawing(
2583
+ self, payload: Any = None
2584
+ ) -> AdapterResult[dict[str, Any]]:
2585
+ """Mock laying out the three standard views of a model.
2586
+
2587
+ Args:
2588
+ payload (Any): Tool payload carrying the model path and projection.
2589
+
2590
+ Returns:
2591
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2592
+ """
2593
+ await asyncio.sleep(self._delays["model_operation"])
2594
+ data = payload if isinstance(payload, dict) else {}
2595
+ model_path = data.get("model_path") or data.get("model_file")
2596
+ if not model_path:
2597
+ return AdapterResult(
2598
+ status=AdapterResultStatus.ERROR,
2599
+ error="A model path is required (model_path or model_file)",
2600
+ )
2601
+
2602
+ third_angle = bool(data.get("third_angle", True))
2603
+ if str(data.get("projection", "")).lower() in {"first", "first_angle"}:
2604
+ third_angle = False
2605
+
2606
+ before = len(self._drawing_views)
2607
+ added = [f"Drawing View{before + offset}" for offset in (1, 2, 3)]
2608
+ self._drawing_views.extend(added)
2609
+ self._operation_count += 1
2610
+
2611
+ return AdapterResult(
2612
+ status=AdapterResultStatus.SUCCESS,
2613
+ data={
2614
+ "views": added,
2615
+ "model_path": model_path,
2616
+ "projection": "third_angle" if third_angle else "first_angle",
2617
+ "views_before": before,
2618
+ "views_after": len(self._drawing_views),
2619
+ },
2620
+ execution_time=self._delays["model_operation"],
2621
+ )
2622
+
2623
+ async def add_note(self, payload: Any = None) -> AdapterResult[dict[str, Any]]:
2624
+ """Mock placing a text note on the active drawing sheet.
2625
+
2626
+ Args:
2627
+ payload (Any): Tool payload carrying the text and position.
2628
+
2629
+ Returns:
2630
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2631
+ """
2632
+ await asyncio.sleep(self._delays["model_operation"])
2633
+ data = payload if isinstance(payload, dict) else {}
2634
+ text = data.get("text")
2635
+ if not text:
2636
+ return AdapterResult(
2637
+ status=AdapterResultStatus.ERROR, error="add_note requires text"
2638
+ )
2639
+
2640
+ self._operation_count += 1
2641
+ font_points = float(data.get("font_size") or 0.0)
2642
+ return AdapterResult(
2643
+ status=AdapterResultStatus.SUCCESS,
2644
+ data={
2645
+ "text": text,
2646
+ "position": {
2647
+ "x": float(data.get("position_x", 100.0)),
2648
+ "y": float(data.get("position_y", 50.0)),
2649
+ },
2650
+ # The real adapter sets this from IAnnotation::SetPosition's
2651
+ # return. Mock mode has no sheet to place anything on, so it
2652
+ # reports "not determinable" rather than claiming success at a
2653
+ # check it never performed.
2654
+ "positioned": None,
2655
+ "font_size_points": font_points or None,
2656
+ },
2657
+ execution_time=self._delays["model_operation"],
2658
+ )
2659
+
2660
+ async def list_drawing_views(self) -> AdapterResult[list[str]]:
2661
+ """Mock listing the views on the active drawing.
2662
+
2663
+ Returns:
2664
+ AdapterResult[list[str]]: The result produced by the operation.
2665
+ """
2666
+ await asyncio.sleep(self._delays["model_operation"] / 2)
2667
+ self._operation_count += 1
2668
+
2669
+ return AdapterResult(
2670
+ status=AdapterResultStatus.SUCCESS,
2671
+ data=list(self._drawing_views),
2672
+ execution_time=self._delays["model_operation"] / 2,
2673
+ )
2674
+
2675
+ async def auto_center_marks(
2676
+ self,
2677
+ view_name: str,
2678
+ mark_holes: bool = True,
2679
+ mark_fillets: bool = False,
2680
+ mark_slots: bool = True,
2681
+ ) -> AdapterResult[dict[str, Any]]:
2682
+ """Mock auto-inserting centre marks on a drawing view.
2683
+
2684
+ The mock has no geometry, so it cannot know how many circular
2685
+ features a view holds - it mirrors the live adapter's refusals (not
2686
+ a drawing, no feature type selected, unknown view) and, on a valid
2687
+ request, records a nominal centre-mark increase per view so
2688
+ before/after deltas stay monotonic across calls.
2689
+
2690
+ Args:
2691
+ view_name (str): Name of a view on the active drawing.
2692
+ mark_holes (bool): Mark holes / bores.
2693
+ mark_fillets (bool): Mark fillets.
2694
+ mark_slots (bool): Mark slots.
2695
+
2696
+ Returns:
2697
+ AdapterResult[dict[str, Any]]: The view, the feature types acted
2698
+ on, and before/after centre-mark counts, or an error.
2699
+ """
2700
+ await asyncio.sleep(self._delays["model_operation"] / 2)
2701
+
2702
+ model = self._current_model
2703
+ if model is None or getattr(model, "type", None) != "Drawing":
2704
+ return AdapterResult(
2705
+ status=AdapterResultStatus.ERROR,
2706
+ error="auto_center_marks requires an active drawing document",
2707
+ )
2708
+ if not (mark_holes or mark_fillets or mark_slots):
2709
+ return AdapterResult(
2710
+ status=AdapterResultStatus.ERROR,
2711
+ error=(
2712
+ "Select at least one feature type: mark_holes, "
2713
+ "mark_fillets or mark_slots."
2714
+ ),
2715
+ )
2716
+ if view_name not in self._drawing_views:
2717
+ return AdapterResult(
2718
+ status=AdapterResultStatus.ERROR,
2719
+ error=(
2720
+ f"No view named {view_name!r} on the active drawing. "
2721
+ f"Views: {', '.join(self._drawing_views) or 'none'}"
2722
+ ),
2723
+ )
2724
+
2725
+ self._operation_count += 1
2726
+ before = self._center_marks.get(view_name, 0)
2727
+ # Nominal: two marks per selected feature type, added once.
2728
+ added = 0 if before else 2 * sum((mark_holes, mark_fillets, mark_slots))
2729
+ after = before + added
2730
+ self._center_marks[view_name] = after
2731
+
2732
+ return AdapterResult(
2733
+ status=AdapterResultStatus.SUCCESS,
2734
+ data={
2735
+ "view": view_name,
2736
+ "feature_types": [
2737
+ label
2738
+ for label, on in (
2739
+ ("holes", mark_holes),
2740
+ ("fillets", mark_fillets),
2741
+ ("slots", mark_slots),
2742
+ )
2743
+ if on
2744
+ ],
2745
+ "center_marks_before": before,
2746
+ "center_marks_after": after,
2747
+ "center_marks_added": added,
2748
+ },
2749
+ execution_time=self._delays["model_operation"] / 2,
2750
+ )
2751
+
2752
+ async def save_body_as_part(
2753
+ self, body_name: str, file_path: str
2754
+ ) -> AdapterResult[dict[str, Any]]:
2755
+ """Mock extracting a solid body from the active part to a new file.
2756
+
2757
+ The mock has no bodies, so it cannot validate ``body_name`` against
2758
+ real geometry - it mirrors the live adapter's other refusals (no
2759
+ model, not a part, blank arguments, missing parent directory) and, on
2760
+ a valid request, writes an empty placeholder file so callers and
2761
+ tests can observe the side effect.
2762
+
2763
+ Args:
2764
+ body_name (str): Name of a solid body in the active part.
2765
+ file_path (str): Absolute path for the new ``.sldprt``.
2766
+
2767
+ Returns:
2768
+ AdapterResult[dict[str, Any]]: The body, the written path, and a
2769
+ single-element body list, or an error.
2770
+ """
2771
+ import os as _os
2772
+
2773
+ await asyncio.sleep(self._delays["model_operation"])
2774
+
2775
+ model = self._current_model
2776
+ if model is None:
2777
+ return AdapterResult(
2778
+ status=AdapterResultStatus.ERROR, error="No active model"
2779
+ )
2780
+ if getattr(model, "type", None) != "Part":
2781
+ return AdapterResult(
2782
+ status=AdapterResultStatus.ERROR,
2783
+ error="save_body_as_part requires an active part document",
2784
+ )
2785
+ if not str(body_name or "").strip():
2786
+ return AdapterResult(
2787
+ status=AdapterResultStatus.ERROR, error="body_name is required"
2788
+ )
2789
+ target = str(file_path or "").strip()
2790
+ if not target:
2791
+ return AdapterResult(
2792
+ status=AdapterResultStatus.ERROR, error="file_path is required"
2793
+ )
2794
+ target = _os.path.abspath(target)
2795
+ parent = _os.path.dirname(target)
2796
+ if not _os.path.isdir(parent):
2797
+ return AdapterResult(
2798
+ status=AdapterResultStatus.ERROR,
2799
+ error=f"Parent directory does not exist: {parent}",
2800
+ )
2801
+
2802
+ with open(target, "w", encoding="utf-8"):
2803
+ pass
2804
+ self._operation_count += 1
2805
+
2806
+ return AdapterResult(
2807
+ status=AdapterResultStatus.SUCCESS,
2808
+ data={
2809
+ "body": body_name,
2810
+ "file_path": target,
2811
+ "feature": f"Save Bodies{self._operation_count}",
2812
+ "solid_bodies": [body_name],
2813
+ },
2814
+ execution_time=self._delays["model_operation"],
2815
+ )
2816
+
2817
+ async def check_interference(
2818
+ self, params: Any = None
2819
+ ) -> AdapterResult[dict[str, Any]]:
2820
+ """Mock checking the active assembly for interfering components.
2821
+
2822
+ Args:
2823
+ params (Any): Optional settings; ``coincident``, ``components``.
2824
+
2825
+ Returns:
2826
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2827
+ """
2828
+ await asyncio.sleep(self._delays["model_operation"])
2829
+ options = params if isinstance(params, dict) else {}
2830
+
2831
+ if len(self._components) < 2:
2832
+ return AdapterResult(
2833
+ status=AdapterResultStatus.ERROR,
2834
+ error=(
2835
+ "Interference detection needs at least two components; the "
2836
+ f"assembly has {len(self._components)}."
2837
+ ),
2838
+ )
2839
+
2840
+ self._operation_count += 1
2841
+ return AdapterResult(
2842
+ status=AdapterResultStatus.SUCCESS,
2843
+ data={
2844
+ # The real adapter gets this from SolidWorks' interference
2845
+ # engine. The mock has no geometry, so it cannot know whether
2846
+ # anything overlaps and says so rather than reporting a clean
2847
+ # assembly it never checked - a mock that always answered
2848
+ # False would make this tool useless wherever mock mode runs.
2849
+ "interference_found": None,
2850
+ "interference_count": None,
2851
+ "interferences": [],
2852
+ "coincident_treated_as_interference": bool(
2853
+ options.get("coincident", False)
2854
+ ),
2855
+ "scope": "whole assembly",
2856
+ "tolerance_applied": None,
2857
+ },
2858
+ execution_time=self._delays["model_operation"],
2859
+ )
2860
+
2861
+ # Feature Editing
2862
+ async def delete_feature(self, name: str) -> AdapterResult[dict[str, Any]]:
2863
+ """Mock deleting a named feature from the active model.
2864
+
2865
+ Args:
2866
+ name (str): Feature or sketch name.
2867
+
2868
+ Returns:
2869
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2870
+ """
2871
+ await asyncio.sleep(self._delays["model_operation"])
2872
+
2873
+ # _features is keyed by an internal id, so resolve by feature name.
2874
+ before = [f.name for f in self._features.values()]
2875
+ key = next((k for k, f in self._features.items() if f.name == name), None)
2876
+ if key is None:
2877
+ return AdapterResult(
2878
+ status=AdapterResultStatus.ERROR,
2879
+ error=(
2880
+ f"Feature not found: {name}. "
2881
+ f"Available: {', '.join(before) or 'none'}"
2882
+ ),
2883
+ )
2884
+
2885
+ del self._features[key]
2886
+ self._suppressed_features.discard(name)
2887
+ self._operation_count += 1
2888
+ after = [f.name for f in self._features.values()]
2889
+
2890
+ return AdapterResult(
2891
+ status=AdapterResultStatus.SUCCESS,
2892
+ data={
2893
+ "deleted": name,
2894
+ "removed_features": [n for n in before if n not in after],
2895
+ "features_before": len(before),
2896
+ "features_after": len(after),
2897
+ },
2898
+ execution_time=self._delays["model_operation"],
2899
+ )
2900
+
2901
+ async def suppress_feature(
2902
+ self, name: str, suppress: bool = True
2903
+ ) -> AdapterResult[dict[str, Any]]:
2904
+ """Mock suppressing or unsuppressing a named feature.
2905
+
2906
+ Args:
2907
+ name (str): Feature name.
2908
+ suppress (bool): True to suppress, False to unsuppress.
2909
+
2910
+ Returns:
2911
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2912
+ """
2913
+ await asyncio.sleep(self._delays["model_operation"])
2914
+
2915
+ known = any(f.name == name for f in self._features.values())
2916
+ if not known:
2917
+ return AdapterResult(
2918
+ status=AdapterResultStatus.ERROR, error=f"Feature not found: {name}"
2919
+ )
2920
+
2921
+ was = name in self._suppressed_features
2922
+ if suppress:
2923
+ self._suppressed_features.add(name)
2924
+ else:
2925
+ self._suppressed_features.discard(name)
2926
+ self._operation_count += 1
2927
+
2928
+ return AdapterResult(
2929
+ status=AdapterResultStatus.SUCCESS,
2930
+ data={
2931
+ "feature": name,
2932
+ "requested": suppress,
2933
+ "suppressed": bool(suppress),
2934
+ "was_suppressed": was,
2935
+ },
2936
+ execution_time=self._delays["model_operation"],
2937
+ )
2938
+
2939
+ async def rename_feature(
2940
+ self, old_name: str, new_name: str
2941
+ ) -> AdapterResult[dict[str, Any]]:
2942
+ """Mock renaming a named feature in the active model.
2943
+
2944
+ Mirrors the real adapter: an empty target name, an unknown feature, or
2945
+ a target name already in use are all errors; renaming to the current
2946
+ name is a no-op success.
2947
+
2948
+ Args:
2949
+ old_name (str): Current feature name.
2950
+ new_name (str): New feature name.
2951
+
2952
+ Returns:
2953
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
2954
+ """
2955
+ await asyncio.sleep(self._delays["model_operation"])
2956
+
2957
+ new = new_name.split("@", 1)[0].strip()
2958
+ if not new:
2959
+ return AdapterResult(
2960
+ status=AdapterResultStatus.ERROR, error="new_name must not be empty"
2961
+ )
2962
+
2963
+ key = next((k for k, f in self._features.items() if f.name == old_name), None)
2964
+ if key is None:
2965
+ return AdapterResult(
2966
+ status=AdapterResultStatus.ERROR,
2967
+ error=f"Feature not found: {old_name}",
2968
+ )
2969
+ if new == old_name:
2970
+ return AdapterResult(
2971
+ status=AdapterResultStatus.SUCCESS,
2972
+ data={
2973
+ "renamed": False,
2974
+ "old_name": old_name,
2975
+ "new_name": new,
2976
+ "reason": "old and new names are the same",
2977
+ },
2978
+ execution_time=self._delays["model_operation"],
2979
+ )
2980
+ if any(f.name == new for f in self._features.values()):
2981
+ return AdapterResult(
2982
+ status=AdapterResultStatus.ERROR,
2983
+ error=(
2984
+ f"Cannot rename to '{new}': a feature with that name already exists"
2985
+ ),
2986
+ )
2987
+
2988
+ self._features[key].name = new
2989
+ if old_name in self._suppressed_features:
2990
+ self._suppressed_features.discard(old_name)
2991
+ self._suppressed_features.add(new)
2992
+ self._operation_count += 1
2993
+
2994
+ return AdapterResult(
2995
+ status=AdapterResultStatus.SUCCESS,
2996
+ data={"renamed": True, "old_name": old_name, "new_name": new},
2997
+ execution_time=self._delays["model_operation"],
2998
+ )
2999
+
3000
+ async def undo(self, count: int = 1) -> AdapterResult[dict[str, Any]]:
3001
+ """Mock undoing the last operations in the active model.
3002
+
3003
+ Removes the most recently added features, mirroring what an undo of
3004
+ feature-creation steps does to the tree. ``tree_changed`` is ``False``
3005
+ when there was nothing left to undo, which is what the real adapter
3006
+ reports too - SolidWorks accepts an undo on an empty stack quietly.
3007
+
3008
+ Args:
3009
+ count (int): Number of steps to undo.
3010
+
3011
+ Returns:
3012
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
3013
+ """
3014
+ await asyncio.sleep(self._delays["model_operation"])
3015
+ steps = max(1, int(count))
3016
+
3017
+ keys = list(self._features)
3018
+ before = [self._features[k].name for k in keys]
3019
+ doomed = keys[-steps:] if steps <= len(keys) else list(keys)
3020
+ removed = [self._features[k].name for k in doomed]
3021
+ for key in doomed:
3022
+ self._suppressed_features.discard(self._features[key].name)
3023
+ del self._features[key]
3024
+ self._operation_count += 1
3025
+ after = [f.name for f in self._features.values()]
3026
+
3027
+ return AdapterResult(
3028
+ status=AdapterResultStatus.SUCCESS,
3029
+ data={
3030
+ "requested_steps": steps,
3031
+ "features_before": len(before),
3032
+ "features_after": len(after),
3033
+ "removed_features": removed,
3034
+ "tree_changed": before != after,
3035
+ },
3036
+ execution_time=self._delays["model_operation"],
3037
+ )
3038
+
3039
+ async def add_mate(
3040
+ self,
3041
+ component_a: str,
3042
+ component_b: str,
3043
+ entity_a: str = "Front Plane",
3044
+ entity_b: str = "Front Plane",
3045
+ mate_type: str = "coincident",
3046
+ alignment: str = "aligned",
3047
+ distance: float = 0.0,
3048
+ angle: float = 0.0,
3049
+ ) -> AdapterResult[dict[str, Any]]:
3050
+ """Mock mating two components in the active assembly.
3051
+
3052
+ Args:
3053
+ component_a (str): First component instance name.
3054
+ component_b (str): Second component instance name.
3055
+ entity_a (str): Named feature on the first component. Defaults to
3056
+ "Front Plane".
3057
+ entity_b (str): Named feature on the second component. Defaults to
3058
+ "Front Plane".
3059
+ mate_type (str): Mate type. Defaults to "coincident".
3060
+ alignment (str): Mate alignment. Defaults to "aligned".
3061
+ distance (float): Distance in millimetres, for a distance mate.
3062
+ Defaults to 0.0.
3063
+ angle (float): Angle in degrees, for an angle mate. Defaults to 0.0.
3064
+
3065
+ Returns:
3066
+ AdapterResult[dict[str, Any]]: The result produced by the operation.
3067
+ """
3068
+ known_mate_types = {
3069
+ "coincident",
3070
+ "concentric",
3071
+ "perpendicular",
3072
+ "parallel",
3073
+ "tangent",
3074
+ "distance",
3075
+ "angle",
3076
+ }
3077
+ if mate_type not in known_mate_types:
3078
+ return AdapterResult(
3079
+ status=AdapterResultStatus.ERROR,
3080
+ error=(
3081
+ f"Unknown mate type '{mate_type}'. "
3082
+ f"Use one of: {', '.join(sorted(known_mate_types))}."
3083
+ ),
3084
+ )
3085
+
3086
+ await asyncio.sleep(self._delays["model_operation"])
3087
+ self._operation_count += 1
3088
+ self._mates.append(
3089
+ {"mate_type": mate_type, "components": [component_a, component_b]}
3090
+ )
3091
+
3092
+ return AdapterResult(
3093
+ status=AdapterResultStatus.SUCCESS,
3094
+ data={
3095
+ "mate_type": mate_type,
3096
+ "alignment": alignment,
3097
+ "components": [component_a, component_b],
3098
+ "entities": [entity_a, entity_b],
3099
+ "distance": distance or None,
3100
+ "angle": angle or None,
3101
+ # The real adapter fills these by comparing every component's
3102
+ # Transform2 before and after the mate. The mock has no
3103
+ # geometry to move, so it reports "not determinable" rather
3104
+ # than True: this field exists precisely so a caller can tell
3105
+ # a mate that positioned something from one SolidWorks
3106
+ # accepted and ignored, and a mock that always answers True
3107
+ # would make that check useless wherever mock mode is used.
3108
+ "moved_components": [],
3109
+ "geometry_moved": None,
3110
+ },
3111
+ execution_time=self._delays["model_operation"],
3112
+ )
3113
+
3114
+ async def create_reference_plane(
3115
+ self,
3116
+ reference: str,
3117
+ offset: float = 0.0,
3118
+ angle: float = 0.0,
3119
+ flip: bool = False,
3120
+ ) -> AdapterResult[dict[str, Any]]:
3121
+ """Mock creating a reference plane offset from an existing plane.
3122
+
3123
+ Mirrors the live adapter's refusals rather than fabricating a
3124
+ result it cannot know: an angled plane needs a second reference this
3125
+ signature cannot supply, and an offset of zero would be coincident
3126
+ with its reference. See ``_create_reference_plane_impl`` in
3127
+ ``solidworks/features.py`` for the live contract this mirrors.
3128
+
3129
+ On success, invents a sequential ``PlaneN`` name tracked on
3130
+ ``self._reference_planes`` and advances the shared mock feature-tree
3131
+ counter so ``features_before``/``features_after`` differ by exactly
3132
+ one, the same signal the live adapter derives from
3133
+ ``IFeatureManager::GetFeatureCount``.
3134
+
3135
+ Args:
3136
+ reference (str): Name of the reference plane or planar face.
3137
+ offset (float): Offset distance in millimetres. Defaults to 0.0.
3138
+ angle (float): Angle in degrees; unsupported (non-zero errors).
3139
+ Defaults to 0.0.
3140
+ flip (bool): Reverse the offset direction. Defaults to False.
3141
+
3142
+ Returns:
3143
+ AdapterResult[dict[str, Any]]: The new plane's name and
3144
+ parameters, or error.
3145
+ """
3146
+ if angle:
3147
+ return AdapterResult(
3148
+ status=AdapterResultStatus.ERROR,
3149
+ error=(
3150
+ "create_reference_plane does not support 'angle' yet: an "
3151
+ "angled plane needs a second reference (an axis or edge "
3152
+ "to rotate about) which this signature cannot take. Use "
3153
+ "'offset' for parallel planes."
3154
+ ),
3155
+ )
3156
+
3157
+ if not offset:
3158
+ return AdapterResult(
3159
+ status=AdapterResultStatus.ERROR,
3160
+ error=(
3161
+ "create_reference_plane requires a non-zero offset - a "
3162
+ "plane coincident with its reference is not useful"
3163
+ ),
3164
+ )
3165
+
3166
+ await asyncio.sleep(self._delays["feature_operation"])
3167
+ self._operation_count += 1
3168
+
3169
+ before = self._feature_tree_count
3170
+ self._feature_tree_count += 1
3171
+ after = self._feature_tree_count
3172
+
3173
+ self._reference_planes.append(f"Plane{len(self._reference_planes) + 1}")
3174
+
3175
+ return AdapterResult(
3176
+ status=AdapterResultStatus.SUCCESS,
3177
+ data={
3178
+ "name": self._reference_planes[-1],
3179
+ "reference": reference,
3180
+ "offset": offset,
3181
+ "angle": None,
3182
+ "flip": flip,
3183
+ "features_before": before,
3184
+ "features_after": after,
3185
+ },
3186
+ execution_time=self._delays["feature_operation"],
3187
+ )
3188
+
3189
+ async def create_axis(self, reference: str) -> AdapterResult[dict[str, Any]]:
3190
+ """Mock creating a reference axis along a principal direction.
3191
+
3192
+ Mirrors the live adapter's plane-pair mapping (``_AXIS_PLANE_PAIRS``
3193
+ in ``solidworks/features.py``, imported here directly so the two can
3194
+ never drift apart) and validation: only ``x``/``y``/``z``
3195
+ (case-insensitive, leading +/- stripped) are accepted.
3196
+
3197
+ Args:
3198
+ reference (str): "x", "y" or "z" (case-insensitive; leading +/-
3199
+ stripped).
3200
+
3201
+ Returns:
3202
+ AdapterResult[dict[str, Any]]: The plane pair used and the
3203
+ feature counter before/after, or error for an unknown reference.
3204
+ """
3205
+ key = str(reference or "").strip().lower().lstrip("+-")
3206
+ if key not in _AXIS_PLANE_PAIRS:
3207
+ return AdapterResult(
3208
+ status=AdapterResultStatus.ERROR,
3209
+ error=(
3210
+ f"Unknown axis reference '{reference}'. "
3211
+ f"Use one of: {', '.join(sorted(_AXIS_PLANE_PAIRS))}."
3212
+ ),
3213
+ )
3214
+
3215
+ await asyncio.sleep(self._delays["feature_operation"])
3216
+ self._operation_count += 1
3217
+
3218
+ before = self._feature_tree_count
3219
+ self._feature_tree_count += 1
3220
+ after = self._feature_tree_count
3221
+
3222
+ plane_a, plane_b = _AXIS_PLANE_PAIRS[key]
3223
+
3224
+ # Invent a sequential "AxisN" name, tracked on self._axes, the same
3225
+ # way create_reference_plane invents "PlaneN" names on
3226
+ # self._reference_planes. InsertAxis2 returns only a bool, so the
3227
+ # live adapter cannot report a name either - but pattern_circular
3228
+ # needs some name to validate the axis argument against, and this
3229
+ # keeps the mock's bookkeeping consistent with the plane convention.
3230
+ self._axes.append(f"Axis{len(self._axes) + 1}")
3231
+
3232
+ return AdapterResult(
3233
+ status=AdapterResultStatus.SUCCESS,
3234
+ data={
3235
+ "name": self._axes[-1],
3236
+ "reference": key,
3237
+ "planes": [plane_a, plane_b],
3238
+ "features_before": before,
3239
+ "features_after": after,
3240
+ },
3241
+ execution_time=self._delays["feature_operation"],
3242
+ )
3243
+
3244
+ async def create_reference_point(
3245
+ self,
3246
+ mode: str,
3247
+ x: float,
3248
+ y: float,
3249
+ z: float,
3250
+ distance: float | None = None,
3251
+ percent: float | None = None,
3252
+ ) -> AdapterResult[dict[str, Any]]:
3253
+ """Mock creating a reference point on the active part.
3254
+
3255
+ The mock has no geometry, so it cannot resolve a coordinate to an
3256
+ edge/face - it mirrors the live adapter's *validation* (unknown mode,
3257
+ the distance/percent XOR, range checks) and, on a valid request,
3258
+ bumps the feature counter the way ``InsertReferencePoint`` would.
3259
+
3260
+ Args:
3261
+ mode (str): "along_curve" or "face_center".
3262
+ x (float): X of a point on the target entity, in millimetres.
3263
+ y (float): Y of a point on the target entity, in millimetres.
3264
+ z (float): Z of a point on the target entity, in millimetres.
3265
+ distance (float | None): For "along_curve", offset from the edge
3266
+ start in millimetres.
3267
+ percent (float | None): For "along_curve", 0-100 of edge length.
3268
+
3269
+ Returns:
3270
+ AdapterResult[dict[str, Any]]: The mode and feature counts, or an
3271
+ error matching the live adapter's refusals.
3272
+ """
3273
+ key = str(mode or "").strip().lower()
3274
+ if key not in ("along_curve", "face_center"):
3275
+ return AdapterResult(
3276
+ status=AdapterResultStatus.ERROR,
3277
+ error=(f"Unknown mode {mode!r}. Use 'along_curve' or 'face_center'."),
3278
+ )
3279
+ if key == "along_curve":
3280
+ if (distance is None) == (percent is None):
3281
+ return AdapterResult(
3282
+ status=AdapterResultStatus.ERROR,
3283
+ error="along_curve needs exactly one of distance or percent",
3284
+ )
3285
+ if distance is not None and float(distance) <= 0.0:
3286
+ return AdapterResult(
3287
+ status=AdapterResultStatus.ERROR,
3288
+ error="distance must be positive",
3289
+ )
3290
+ if percent is not None and not 0.0 < float(percent) < 100.0:
3291
+ return AdapterResult(
3292
+ status=AdapterResultStatus.ERROR,
3293
+ error="percent must be between 0 and 100 (exclusive)",
3294
+ )
3295
+
3296
+ await asyncio.sleep(self._delays["feature_operation"])
3297
+ self._operation_count += 1
3298
+ before = self._feature_tree_count
3299
+ self._feature_tree_count += 1
3300
+ self._reference_points.append(f"Point{len(self._reference_points) + 1}")
3301
+
3302
+ return AdapterResult(
3303
+ status=AdapterResultStatus.SUCCESS,
3304
+ data={
3305
+ "name": self._reference_points[-1],
3306
+ "mode": key,
3307
+ "at_mm": [x, y, z],
3308
+ "distance_mm": distance if key == "along_curve" else None,
3309
+ "percent": percent if key == "along_curve" else None,
3310
+ "features_before": before,
3311
+ "features_after": self._feature_tree_count,
3312
+ },
3313
+ execution_time=self._delays["feature_operation"],
3314
+ )
3315
+
3316
+ #: Built-in planes every SolidWorks part starts with. Combined with
3317
+ #: ``self._reference_planes`` (planes created via create_reference_plane)
3318
+ #: this is the set of plane names ``mirror_feature`` will accept.
3319
+ _BUILTIN_PLANES = frozenset({"Front Plane", "Top Plane", "Right Plane"})
3320
+
3321
+ async def mirror_feature(
3322
+ self,
3323
+ features: list[str],
3324
+ mirror_plane: str,
3325
+ merge: bool = True,
3326
+ mirror_bodies: bool = True,
3327
+ ) -> AdapterResult[dict[str, Any]]:
3328
+ """Mock mirroring solid bodies or features about a plane.
3329
+
3330
+ The mock has no geometry engine, so it cannot measure a real volume
3331
+ change - it mirrors the live adapter's refusals instead of
3332
+ fabricating a result it cannot know. An empty source list or plane
3333
+ name is rejected outright (matching ``_mirror_feature_impl`` in
3334
+ ``solidworks/features.py`` verbatim), and a source or plane name
3335
+ that was never created in this session is rejected the same way
3336
+ ``_select_named_feature`` / ``_select_reference_entity`` would fail
3337
+ to select it there.
3338
+
3339
+ On success, the tracked ``self._mirror_volume`` figure (seeded at
3340
+ the live-measured 1819569.1 mm^3 wing volume) is doubled, matching
3341
+ the ~2.0 ratio a correct mirror produces (measured live: 1.998528
3342
+ for a lofted wing, exactly 2.0 for a boss whose sketch sits on the
3343
+ plane).
3344
+
3345
+ Args:
3346
+ features (list[str]): Body or feature names to mirror.
3347
+ mirror_plane (str): Plane name to mirror about.
3348
+ merge (bool): Merge the mirrored result with the original. Defaults to True.
3349
+ mirror_bodies (bool): Mirror whole bodies rather than features. Defaults to True.
3350
+
3351
+ Returns:
3352
+ AdapterResult[dict[str, Any]]: The mirror feature's name, inputs
3353
+ and volume before/after, or error.
3354
+ """
3355
+ names = [n for n in (features or []) if n]
3356
+ if not names:
3357
+ return AdapterResult(
3358
+ status=AdapterResultStatus.ERROR,
3359
+ error="mirror_feature requires at least one body or feature name",
3360
+ )
3361
+ if not mirror_plane:
3362
+ return AdapterResult(
3363
+ status=AdapterResultStatus.ERROR,
3364
+ error="mirror_feature requires a mirror plane name",
3365
+ )
3366
+
3367
+ known_sources = {feature.name for feature in self._features.values()} | set(
3368
+ self._components
3369
+ )
3370
+ for name in names:
3371
+ if name not in known_sources:
3372
+ kind = "body" if mirror_bodies else "feature"
3373
+ return AdapterResult(
3374
+ status=AdapterResultStatus.ERROR,
3375
+ error=f"Failed to select {kind} to mirror: {name}",
3376
+ )
3377
+
3378
+ known_planes = self._BUILTIN_PLANES | set(self._reference_planes)
3379
+ if mirror_plane not in known_planes:
3380
+ return AdapterResult(
3381
+ status=AdapterResultStatus.ERROR,
3382
+ error=f"Failed to select mirror plane: {mirror_plane}",
3383
+ )
3384
+
3385
+ await asyncio.sleep(self._delays["feature_operation"])
3386
+ self._operation_count += 1
3387
+
3388
+ volume_before = self._mirror_volume
3389
+ volume_after = volume_before * 2
3390
+ self._mirror_volume = volume_after
3391
+
3392
+ self._feature_tree_count += 1
3393
+ feature_name = f"Mirror{self._feature_tree_count}"
3394
+
3395
+ return AdapterResult(
3396
+ status=AdapterResultStatus.SUCCESS,
3397
+ data={
3398
+ "name": feature_name,
3399
+ "mirrored": names,
3400
+ "mirror_plane": mirror_plane,
3401
+ "merge": bool(merge),
3402
+ "mirror_bodies": bool(mirror_bodies),
3403
+ "volume_before": volume_before,
3404
+ "volume_after": volume_after,
3405
+ "volume_ratio": round(volume_after / volume_before, 6),
3406
+ },
3407
+ execution_time=self._delays["feature_operation"],
3408
+ )
3409
+
3410
+ #: Live-measured "volume added per extra circular-pattern instance", in
3411
+ #: mm^3: a hub+blade part measured 48431.5 mm3 for hub + 1 blade, and
3412
+ #: 75916.4 mm3 once patterned to 4 blades (3 extra instances) - see
3413
+ #: ``_pattern_circular_impl`` in ``solidworks/features.py``. Dividing the
3414
+ #: growth by the 3 extra instances gives the per-instance figure used
3415
+ #: below to scale ``self._pattern_volume`` by roughly ``count`` without a
3416
+ #: real geometry engine.
3417
+ _PATTERN_BLADE_VOLUME = (75916.4 - 48431.5) / 3
3418
+
3419
+ async def pattern_circular(
3420
+ self,
3421
+ features: list[str],
3422
+ axis: str,
3423
+ count: int,
3424
+ angle: float = 360.0,
3425
+ equal_spacing: bool = True,
3426
+ ) -> AdapterResult[dict[str, Any]]:
3427
+ """Mock patterning features around an axis.
3428
+
3429
+ The mock has no geometry engine, so it cannot measure a real volume
3430
+ change - it mirrors the live adapter's refusals instead of
3431
+ fabricating a result it cannot know (matching
3432
+ ``_pattern_circular_impl`` in ``solidworks/features.py`` verbatim
3433
+ for the three argument checks below). An axis name that was never
3434
+ created via ``create_axis`` in this session is rejected the same
3435
+ way ``SelectByID2`` would fail to select it there.
3436
+
3437
+ On success, the tracked ``self._pattern_volume`` figure (seeded at
3438
+ the live-measured 48431.5 mm^3 hub+blade volume) grows by
3439
+ ``_PATTERN_BLADE_VOLUME * (count - 1)``, the live-derived
3440
+ per-instance volume, so volume_before/volume_after/volume_ratio
3441
+ stay self-consistent and scale with ``count`` roughly the way
3442
+ adding more blades to the hub would - reproducing the live
3443
+ 48431.5 -> 75916.4 mm^3 measurement exactly for ``count=4``.
3444
+
3445
+ Args:
3446
+ features (list[str]): Names of the features to pattern.
3447
+ axis (str): Name of the axis to rotate about.
3448
+ count (int): Total number of instances including the original.
3449
+ angle (float): Total angle in degrees to spread them over. Defaults to 360.0.
3450
+ equal_spacing (bool): Space instances evenly across ``angle``. Defaults to True.
3451
+
3452
+ Returns:
3453
+ AdapterResult[dict[str, Any]]: The pattern's name, inputs and
3454
+ volume before/after, or error.
3455
+ """
3456
+ names = [n for n in (features or []) if n]
3457
+ if not names:
3458
+ return AdapterResult(
3459
+ status=AdapterResultStatus.ERROR,
3460
+ error="pattern_circular requires at least one feature name",
3461
+ )
3462
+ if not axis:
3463
+ return AdapterResult(
3464
+ status=AdapterResultStatus.ERROR,
3465
+ error="pattern_circular requires an axis name to rotate about",
3466
+ )
3467
+ if int(count) < 2:
3468
+ return AdapterResult(
3469
+ status=AdapterResultStatus.ERROR,
3470
+ error=(
3471
+ f"pattern_circular needs a count of at least 2 (got {count}); "
3472
+ "a pattern of one is just the original feature"
3473
+ ),
3474
+ )
3475
+
3476
+ known_axes = set(self._axes)
3477
+ if axis not in known_axes:
3478
+ return AdapterResult(
3479
+ status=AdapterResultStatus.ERROR,
3480
+ error=(
3481
+ f"Failed to select axis '{axis}'. Create one with "
3482
+ "create_axis first, and pass the name it returns."
3483
+ ),
3484
+ )
3485
+
3486
+ await asyncio.sleep(self._delays["feature_operation"])
3487
+ self._operation_count += 1
3488
+
3489
+ volume_before = self._pattern_volume
3490
+ volume_after = volume_before + self._PATTERN_BLADE_VOLUME * (int(count) - 1)
3491
+ self._pattern_volume = volume_after
3492
+
3493
+ self._feature_tree_count += 1
3494
+ pattern_name = f"CirPattern{self._feature_tree_count}"
3495
+
3496
+ return AdapterResult(
3497
+ status=AdapterResultStatus.SUCCESS,
3498
+ data={
3499
+ "name": pattern_name,
3500
+ "patterned": names,
3501
+ "axis": axis,
3502
+ "count": int(count),
3503
+ "angle": float(angle),
3504
+ "equal_spacing": bool(equal_spacing),
3505
+ "volume_before": volume_before,
3506
+ "volume_after": volume_after,
3507
+ "volume_ratio": round(volume_after / volume_before, 6),
3508
+ },
3509
+ execution_time=self._delays["feature_operation"],
3510
+ )
3511
+
3512
+ # --- Engineering-data and drawing-documentation methods ---
3513
+ # Mirrors SolidWorksDocumentationMixin (solidworks/documentation.py): the
3514
+ # same refusals for wrong document types and unknown views, with
3515
+ # self-consistent state so set/get pairs round-trip.
3516
+
3517
+ _MOCK_MATERIALS: tuple[dict[str, str], ...] = (
3518
+ {
3519
+ "name": "AISI 1020",
3520
+ "classification": "Steel",
3521
+ "database": "solidworks materials",
3522
+ },
3523
+ {
3524
+ "name": "AISI 304",
3525
+ "classification": "Steel",
3526
+ "database": "solidworks materials",
3527
+ },
3528
+ {
3529
+ "name": "6061-T6 (SS)",
3530
+ "classification": "Aluminium Alloys",
3531
+ "database": "solidworks materials",
3532
+ },
3533
+ {
3534
+ "name": "ABS",
3535
+ "classification": "Plastics",
3536
+ "database": "solidworks materials",
3537
+ },
3538
+ )
3539
+ _MOCK_MATERIAL_FOLDER = "mock://sldmaterials"
3540
+
3541
+ def _mock_doc_error(
3542
+ self, allowed: set[str], operation: str
3543
+ ) -> AdapterResult[Any] | None:
3544
+ """Return an error unless the active mock document type is allowed."""
3545
+ model = self._current_model
3546
+ doc_type = getattr(model, "type", None)
3547
+ if model is None or doc_type not in allowed:
3548
+ wanted = " or ".join(sorted(t.lower() for t in allowed))
3549
+ return AdapterResult(
3550
+ status=AdapterResultStatus.ERROR,
3551
+ error=f"{operation} needs an active {wanted} document",
3552
+ )
3553
+ return None
3554
+
3555
+ def _mock_doc_key(self) -> str:
3556
+ """Key per-document mock state by path, falling back to name."""
3557
+ model = self._current_model
3558
+ return str(
3559
+ getattr(model, "path", "") or getattr(model, "name", "") or "untitled"
3560
+ )
3561
+
3562
+ async def list_model_dimensions(
3563
+ self, feature_name: str | None = None
3564
+ ) -> AdapterResult[list[dict[str, Any]]]:
3565
+ """Mock listing model dimensions from the tracked dimension table."""
3566
+ await asyncio.sleep(self._delays["model_operation"] / 2)
3567
+ guard = self._mock_doc_error({"Part", "Assembly"}, "list_model_dimensions")
3568
+ if guard is not None:
3569
+ return guard
3570
+ self._operation_count += 1
3571
+ dims = []
3572
+ for full_name, value in self._dimensions.items():
3573
+ feature = full_name.split("@", 1)[1] if "@" in full_name else ""
3574
+ if feature_name and feature.lower() != feature_name.lower():
3575
+ continue
3576
+ dims.append(
3577
+ {
3578
+ "full_name": full_name,
3579
+ "feature": feature,
3580
+ "value": value,
3581
+ "system_value": value / 1000.0,
3582
+ "read_only": False,
3583
+ }
3584
+ )
3585
+ if feature_name and not dims:
3586
+ return AdapterResult(
3587
+ status=AdapterResultStatus.ERROR,
3588
+ error=f"No dimensions found on feature {feature_name!r}",
3589
+ )
3590
+ return AdapterResult(status=AdapterResultStatus.SUCCESS, data=dims)
3591
+
3592
+ async def measure_entities(
3593
+ self, entities: list[str]
3594
+ ) -> AdapterResult[dict[str, Any]]:
3595
+ """Mock measuring: nominal values with the live adapter's validation."""
3596
+ await asyncio.sleep(self._delays["model_operation"] / 2)
3597
+ guard = self._mock_doc_error({"Part", "Assembly"}, "measure_entities")
3598
+ if guard is not None:
3599
+ return guard
3600
+ if not entities or len(entities) > 2:
3601
+ return AdapterResult(
3602
+ status=AdapterResultStatus.ERROR,
3603
+ error="measure_entities takes one or two entity specs",
3604
+ )
3605
+ self._operation_count += 1
3606
+ data: dict[str, Any] = {"selected": list(entities)}
3607
+ if len(entities) == 1:
3608
+ data.update({"area_m2": 0.0025, "perimeter_m": 0.2})
3609
+ else:
3610
+ data.update({"distance_m": 0.05, "angle_rad": 0.0, "is_parallel": True})
3611
+ return AdapterResult(status=AdapterResultStatus.SUCCESS, data=data)
3612
+
3613
+ async def get_material(
3614
+ self, configuration: str | None = None
3615
+ ) -> AdapterResult[dict[str, Any]]:
3616
+ """Mock reading the active part material."""
3617
+ guard = self._mock_doc_error({"Part"}, "get_material")
3618
+ if guard is not None:
3619
+ return guard
3620
+ assigned = getattr(self, "_materials", {}).get(self._mock_doc_key())
3621
+ return AdapterResult(
3622
+ status=AdapterResultStatus.SUCCESS,
3623
+ data={
3624
+ "material": assigned["name"] if assigned else None,
3625
+ "database": assigned["database"] if assigned else None,
3626
+ "configuration": configuration,
3627
+ },
3628
+ )
3629
+
3630
+ async def set_material(
3631
+ self,
3632
+ material_name: str,
3633
+ database: str | None = None,
3634
+ configuration: str | None = None,
3635
+ ) -> AdapterResult[dict[str, Any]]:
3636
+ """Mock assigning a material; unknown names fail as they do live."""
3637
+ guard = self._mock_doc_error({"Part"}, "set_material")
3638
+ if guard is not None:
3639
+ return guard
3640
+ wanted = material_name.strip().lower()
3641
+ match = next(
3642
+ (m for m in self._MOCK_MATERIALS if m["name"].lower() == wanted), None
3643
+ )
3644
+ if match is None:
3645
+ return AdapterResult(
3646
+ status=AdapterResultStatus.ERROR,
3647
+ error=(
3648
+ f"Material {material_name!r} not found in "
3649
+ f"{database or 'SOLIDWORKS Materials'!r}; use "
3650
+ "list_available_materials for the exact name and library."
3651
+ ),
3652
+ )
3653
+ if not hasattr(self, "_materials"):
3654
+ self._materials: dict[str, dict[str, str]] = {}
3655
+ self._materials[self._mock_doc_key()] = match
3656
+ self._operation_count += 1
3657
+ return AdapterResult(
3658
+ status=AdapterResultStatus.SUCCESS,
3659
+ data={
3660
+ "material": match["name"],
3661
+ "database": database or "SOLIDWORKS Materials",
3662
+ "configuration": configuration,
3663
+ },
3664
+ )
3665
+
3666
+ async def list_available_materials(
3667
+ self, search: str | None = None
3668
+ ) -> AdapterResult[list[dict[str, str]]]:
3669
+ """Mock listing the material library, with the live ``metadata``."""
3670
+ needle = (search or "").strip().lower()
3671
+ items = [
3672
+ dict(m)
3673
+ for m in self._MOCK_MATERIALS
3674
+ if not needle
3675
+ or needle in m["name"].lower()
3676
+ or needle in m["classification"].lower()
3677
+ ]
3678
+ return AdapterResult(
3679
+ status=AdapterResultStatus.SUCCESS,
3680
+ data=items,
3681
+ metadata={
3682
+ "folders": [self._MOCK_MATERIAL_FOLDER],
3683
+ "total": len(self._MOCK_MATERIALS),
3684
+ },
3685
+ )
3686
+
3687
+ async def get_custom_properties(
3688
+ self, configuration: str | None = None
3689
+ ) -> AdapterResult[dict[str, Any]]:
3690
+ """Mock reading custom properties."""
3691
+ if self._current_model is None:
3692
+ return AdapterResult(
3693
+ status=AdapterResultStatus.ERROR, error="No active model"
3694
+ )
3695
+ store = getattr(self, "_custom_properties", {})
3696
+ props = store.get((self._mock_doc_key(), configuration or ""), {})
3697
+ return AdapterResult(
3698
+ status=AdapterResultStatus.SUCCESS,
3699
+ data={
3700
+ "configuration": configuration or "",
3701
+ "properties": {
3702
+ k: {"value": v, "resolved": v} for k, v in props.items()
3703
+ },
3704
+ },
3705
+ )
3706
+
3707
+ async def set_custom_property(
3708
+ self, name: str, value: str, configuration: str | None = None
3709
+ ) -> AdapterResult[dict[str, Any]]:
3710
+ """Mock writing one custom property."""
3711
+ if self._current_model is None:
3712
+ return AdapterResult(
3713
+ status=AdapterResultStatus.ERROR, error="No active model"
3714
+ )
3715
+ if not name.strip():
3716
+ return AdapterResult(
3717
+ status=AdapterResultStatus.ERROR, error="name is required"
3718
+ )
3719
+ if not hasattr(self, "_custom_properties"):
3720
+ self._custom_properties: dict[tuple[str, str], dict[str, str]] = {}
3721
+ key = (self._mock_doc_key(), configuration or "")
3722
+ self._custom_properties.setdefault(key, {})[name] = str(value)
3723
+ self._operation_count += 1
3724
+ return AdapterResult(
3725
+ status=AdapterResultStatus.SUCCESS,
3726
+ data={
3727
+ "name": name,
3728
+ "value": str(value),
3729
+ "resolved": str(value),
3730
+ "configuration": configuration or "",
3731
+ },
3732
+ )
3733
+
3734
+ async def export_flat_pattern(
3735
+ self, file_path: str
3736
+ ) -> AdapterResult[dict[str, Any]]:
3737
+ """Mock flat-pattern export: validates the target, writes nothing."""
3738
+ guard = self._mock_doc_error({"Part"}, "export_flat_pattern")
3739
+ if guard is not None:
3740
+ return guard
3741
+ if not file_path.lower().endswith((".dxf", ".dwg")):
3742
+ return AdapterResult(
3743
+ status=AdapterResultStatus.ERROR,
3744
+ error="file_path must end in .dxf or .dwg",
3745
+ )
3746
+ return AdapterResult(
3747
+ status=AdapterResultStatus.SUCCESS,
3748
+ data={"file_path": file_path, "size_bytes": 0, "mock": True},
3749
+ )
3750
+
3751
+ async def insert_model_dimensions(
3752
+ self, view_name: str | None = None, include_hole_wizard: bool = True
3753
+ ) -> AdapterResult[dict[str, Any]]:
3754
+ """Mock importing model dimensions: three per view, never twice.
3755
+
3756
+ Mirrors the live result shape (``views``, ``dimensions_added``,
3757
+ ``unique_dimensions``, ``skipped_pictorial_views``, ``method``,
3758
+ ``autodimension_status``) and its refusals.
3759
+ """
3760
+ await asyncio.sleep(self._delays["model_operation"] / 2)
3761
+ guard = self._mock_doc_error({"Drawing"}, "insert_model_dimensions")
3762
+ if guard is not None:
3763
+ return guard
3764
+ names = [view_name] if view_name else list(self._drawing_views)
3765
+ unknown = [n for n in names if n not in self._drawing_views]
3766
+ if unknown or not names:
3767
+ return AdapterResult(
3768
+ status=AdapterResultStatus.ERROR,
3769
+ error=(
3770
+ f"No view named {unknown[0]!r}. Views: "
3771
+ f"{', '.join(self._drawing_views) or 'none'}"
3772
+ if unknown
3773
+ else "No drawing views found to dimension; add views with "
3774
+ "create_technical_drawing or create_drawing_view"
3775
+ ),
3776
+ )
3777
+ if not hasattr(self, "_view_dimensions"):
3778
+ self._view_dimensions: dict[str, int] = {}
3779
+ per_view = []
3780
+ for name in names:
3781
+ before = self._view_dimensions.get(name, 0)
3782
+ after = before or 3
3783
+ self._view_dimensions[name] = after
3784
+ per_view.append(
3785
+ {
3786
+ "view": name,
3787
+ "dimensions_before": before,
3788
+ "dimensions_after": after,
3789
+ "dimensions_added": after - before,
3790
+ "duplicates_removed": 0,
3791
+ }
3792
+ )
3793
+ self._operation_count += 1
3794
+ return AdapterResult(
3795
+ status=AdapterResultStatus.SUCCESS,
3796
+ data={
3797
+ "views": per_view,
3798
+ "dimensions_added": sum(v["dimensions_added"] for v in per_view),
3799
+ "unique_dimensions": sum(self._view_dimensions.values()),
3800
+ "skipped_pictorial_views": [],
3801
+ "method": "insert_model_annotations",
3802
+ "autodimension_status": None,
3803
+ },
3804
+ )
3805
+
3806
+ async def arrange_dimensions(
3807
+ self, view_name: str | None = None
3808
+ ) -> AdapterResult[dict[str, Any]]:
3809
+ """Mock arranging drawing dimensions: reports a clean layout."""
3810
+ guard = self._mock_doc_error({"Drawing"}, "arrange_dimensions")
3811
+ if guard is not None:
3812
+ return guard
3813
+ if view_name and view_name not in self._drawing_views:
3814
+ return AdapterResult(
3815
+ status=AdapterResultStatus.ERROR,
3816
+ error=(
3817
+ f"No view named {view_name!r}. Views: "
3818
+ f"{', '.join(self._drawing_views) or 'none'}"
3819
+ ),
3820
+ )
3821
+ counts = getattr(self, "_view_dimensions", {})
3822
+ total = sum(n for v, n in counts.items() if not view_name or v == view_name)
3823
+ return AdapterResult(
3824
+ status=AdapterResultStatus.SUCCESS,
3825
+ data={
3826
+ "dimensions": total,
3827
+ "moved": total,
3828
+ "unplaced": [],
3829
+ "overlaps_before": total,
3830
+ "overlaps_after": 0,
3831
+ "remaining_overlaps": [],
3832
+ "sheet_size_m": [0.42, 0.297],
3833
+ },
3834
+ )
3835
+
3836
+ async def insert_bom_table(
3837
+ self,
3838
+ view_name: str,
3839
+ template_path: str | None = None,
3840
+ bom_type: str = "top_level_only",
3841
+ x_mm: float | None = None,
3842
+ y_mm: float | None = None,
3843
+ ) -> AdapterResult[dict[str, Any]]:
3844
+ """Mock inserting a BOM table."""
3845
+ guard = self._mock_doc_error({"Drawing"}, "insert_bom_table")
3846
+ if guard is not None:
3847
+ return guard
3848
+ if bom_type not in {"top_level_only", "parts_only", "indented"}:
3849
+ return AdapterResult(
3850
+ status=AdapterResultStatus.ERROR,
3851
+ error="bom_type must be top_level_only, parts_only or indented",
3852
+ )
3853
+ if view_name not in self._drawing_views:
3854
+ return AdapterResult(
3855
+ status=AdapterResultStatus.ERROR,
3856
+ error=(
3857
+ f"No view named {view_name!r}. Views: "
3858
+ f"{', '.join(self._drawing_views) or 'none'}"
3859
+ ),
3860
+ )
3861
+ self._operation_count += 1
3862
+ return AdapterResult(
3863
+ status=AdapterResultStatus.SUCCESS,
3864
+ data={
3865
+ "view": view_name,
3866
+ "bom_type": bom_type,
3867
+ "template": template_path,
3868
+ "row_count": 2,
3869
+ "table_box_mm": [0.0, 0.0, 60.0, 15.0],
3870
+ "moved_onto_sheet": False,
3871
+ },
3872
+ )
3873
+
3874
+ async def add_gtol(
3875
+ self,
3876
+ characteristic: str,
3877
+ tolerance: float,
3878
+ x_mm: float,
3879
+ y_mm: float,
3880
+ diameter_zone: bool = False,
3881
+ material_condition: str = "none",
3882
+ datums: list[str] | None = None,
3883
+ attach_at_mm: tuple[float, float] | None = None,
3884
+ dimension: tuple[str, str] | None = None,
3885
+ ) -> AdapterResult[dict[str, Any]]:
3886
+ """Mock adding a feature control frame; refuses what the real one refuses."""
3887
+ guard = self._mock_doc_error({"Drawing"}, "add_gtol")
3888
+ if guard is not None:
3889
+ return guard
3890
+ problem = gtol_problem(characteristic, tolerance, material_condition, datums)
3891
+ if problem is not None:
3892
+ return AdapterResult(status=AdapterResultStatus.ERROR, error=problem)
3893
+ self._operation_count += 1
3894
+ return AdapterResult(
3895
+ status=AdapterResultStatus.SUCCESS,
3896
+ data={
3897
+ "characteristic": characteristic,
3898
+ "tolerance": f"{tolerance:g}",
3899
+ "diameter_zone": diameter_zone,
3900
+ "material_condition": material_condition,
3901
+ "datums": [d.strip().upper() for d in datums or []],
3902
+ "attached": (
3903
+ dimension[1] if dimension is not None else attach_at_mm is not None
3904
+ ),
3905
+ "position_mm": None if dimension is not None else [x_mm, y_mm],
3906
+ "layout": None,
3907
+ },
3908
+ )
3909
+
3910
+ async def add_datum_tag(
3911
+ self,
3912
+ label: str,
3913
+ attach_at_mm: tuple[float, float],
3914
+ x_mm: float,
3915
+ y_mm: float,
3916
+ ) -> AdapterResult[dict[str, Any]]:
3917
+ """Mock adding a datum tag; refuses what the real one refuses."""
3918
+ guard = self._mock_doc_error({"Drawing"}, "add_datum_tag")
3919
+ if guard is not None:
3920
+ return guard
3921
+ letter = label.strip().upper()
3922
+ if len(letter) != 1 or not letter.isalpha():
3923
+ return AdapterResult(
3924
+ status=AdapterResultStatus.ERROR,
3925
+ error="label must be a single letter, e.g. 'A'",
3926
+ )
3927
+ self._operation_count += 1
3928
+ return AdapterResult(
3929
+ status=AdapterResultStatus.SUCCESS,
3930
+ data={"label": letter, "position_mm": [x_mm, y_mm]},
3931
+ )
3932
+
3933
+ async def set_sheet_scale(
3934
+ self, numerator: float, denominator: float
3935
+ ) -> AdapterResult[dict[str, Any]]:
3936
+ """Mock setting the sheet scale."""
3937
+ guard = self._mock_doc_error({"Drawing"}, "set_sheet_scale")
3938
+ if guard is not None:
3939
+ return guard
3940
+ if numerator <= 0 or denominator <= 0:
3941
+ return AdapterResult(
3942
+ status=AdapterResultStatus.ERROR, error="Scale values must be positive"
3943
+ )
3944
+ return AdapterResult(
3945
+ status=AdapterResultStatus.SUCCESS,
3946
+ data={
3947
+ "sheet": "Sheet1",
3948
+ "scale": f"{numerator:g}:{denominator:g}",
3949
+ "requested": f"{numerator:g}:{denominator:g}",
3950
+ },
3951
+ )
3952
+
3953
+ async def create_standard_drawing(
3954
+ self,
3955
+ model_path: str,
3956
+ output_dir: str | None = None,
3957
+ title: str | None = None,
3958
+ subtitle: str | None = None,
3959
+ drawing_number: str | None = None,
3960
+ author: str = "",
3961
+ revision: str = "A",
3962
+ company: str = "",
3963
+ drawn_date: str | None = None,
3964
+ notes: list[str] | None = None,
3965
+ include_isometric: bool = True,
3966
+ write_model_properties: bool = True,
3967
+ title_block_text_height_mm: float | None = None,
3968
+ include_bom: bool = False,
3969
+ ) -> AdapterResult[dict[str, Any]]:
3970
+ """Mock standard A3 drawing: validates inputs, writes nothing to disk."""
3971
+ if not os.path.isfile(model_path):
3972
+ return AdapterResult(
3973
+ status=AdapterResultStatus.ERROR,
3974
+ error=f"Model file not found: {model_path}",
3975
+ )
3976
+ stem = os.path.splitext(os.path.basename(model_path))[0]
3977
+ number = drawing_number or f"{stem.upper().replace(' ', '-')}-Rev{revision}-01"
3978
+ out_dir = output_dir or os.path.dirname(model_path)
3979
+ views = {
3980
+ "Drawing View1": {"role": "front", "caption": "FRONT VIEW"},
3981
+ "Drawing View2": {"role": "plan", "caption": "TOP VIEW"},
3982
+ "Drawing View3": {"role": "side", "caption": "SIDE VIEW"},
3983
+ }
3984
+ if include_isometric:
3985
+ views["Drawing View4"] = {"role": "iso", "caption": "ISOMETRIC VIEW"}
3986
+ self._operation_count += 1
3987
+ return AdapterResult(
3988
+ status=AdapterResultStatus.SUCCESS,
3989
+ data={
3990
+ "scale": "1:1",
3991
+ "isometric_scale": "1:1" if include_isometric else None,
3992
+ "views": views,
3993
+ "hidden_lines_visible": {
3994
+ n: True for n, v in views.items() if v["role"] != "iso"
3995
+ },
3996
+ "bom": {"bom_type": "top_level_only", "table_box_mm": None}
3997
+ if include_bom
3998
+ else None,
3999
+ "overlap_check": {"overlaps_after": 0, "annotation_overlaps": []},
4000
+ "drawing_number": number,
4001
+ "drawing_path": os.path.join(out_dir, f"{number}.SLDDRW"),
4002
+ "pdf_path": os.path.join(out_dir, f"{number}.pdf"),
4003
+ "properties_written": {
4004
+ "model": {"Description": title or stem}
4005
+ if write_model_properties
4006
+ else {},
4007
+ "drawing": {"DrawnBy": author, "Revision": revision},
4008
+ },
4009
+ "notes": ["NOTES", *[n.upper() for n in (notes or [])]],
4010
+ "title_block": {
4011
+ "drawing number": {"found": True, "fits": True},
4012
+ "title": {"found": True, "fits": True},
4013
+ },
4014
+ "mock": True,
4015
+ },
4016
+ )