solidworks-mcp-python 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solidworks_mcp/__init__.py +57 -0
- solidworks_mcp/adapters/__init__.py +40 -0
- solidworks_mcp/adapters/base.py +1882 -0
- solidworks_mcp/adapters/circuit_breaker.py +2052 -0
- solidworks_mcp/adapters/com_executor.py +252 -0
- solidworks_mcp/adapters/complexity_analyzer.py +272 -0
- solidworks_mcp/adapters/connection_pool.py +1884 -0
- solidworks_mcp/adapters/factory.py +366 -0
- solidworks_mcp/adapters/intelligent_router.py +218 -0
- solidworks_mcp/adapters/mock_adapter.py +4016 -0
- solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
- solidworks_mcp/adapters/solidworks/__init__.py +17 -0
- solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
- solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
- solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
- solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
- solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
- solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
- solidworks_mcp/adapters/solidworks/features.py +3362 -0
- solidworks_mcp/adapters/solidworks/io.py +3649 -0
- solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
- solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
- solidworks_mcp/adapters/solidworks/selection.py +308 -0
- solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
- solidworks_mcp/adapters/sw_feature_errors.py +66 -0
- solidworks_mcp/adapters/sw_type_info.py +317 -0
- solidworks_mcp/adapters/vba_adapter.py +318 -0
- solidworks_mcp/adapters/vba_macro_executor.py +215 -0
- solidworks_mcp/agents/__init__.py +68 -0
- solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
- solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
- solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
- solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
- solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
- solidworks_mcp/agents/harness.py +216 -0
- solidworks_mcp/agents/history_db.py +1508 -0
- solidworks_mcp/agents/retrieval_index.py +164 -0
- solidworks_mcp/agents/schemas.py +165 -0
- solidworks_mcp/agents/smoke_test.py +285 -0
- solidworks_mcp/agents/soc_exporter.py +650 -0
- solidworks_mcp/agents/soc_pickup.py +332 -0
- solidworks_mcp/agents/soc_rewind.py +221 -0
- solidworks_mcp/agents/vector_rag.py +665 -0
- solidworks_mcp/cache/__init__.py +5 -0
- solidworks_mcp/cache/response_cache.py +164 -0
- solidworks_mcp/config.py +729 -0
- solidworks_mcp/exceptions.py +37 -0
- solidworks_mcp/security/__init__.py +68 -0
- solidworks_mcp/security/auth.py +128 -0
- solidworks_mcp/security/cors.py +27 -0
- solidworks_mcp/security/rate_limiting.py +90 -0
- solidworks_mcp/security/runtime.py +155 -0
- solidworks_mcp/server.py +777 -0
- solidworks_mcp/server_cli_fixed.py +120 -0
- solidworks_mcp/setup/__init__.py +1 -0
- solidworks_mcp/setup/cli.py +399 -0
- solidworks_mcp/setup/clients.py +157 -0
- solidworks_mcp/setup/config_file.py +230 -0
- solidworks_mcp/setup/handshake.py +202 -0
- solidworks_mcp/setup/launcher.py +102 -0
- solidworks_mcp/setup/legacy.py +100 -0
- solidworks_mcp/setup/registration.py +193 -0
- solidworks_mcp/setup/solidworks.py +49 -0
- solidworks_mcp/tools/__init__.py +64 -0
- solidworks_mcp/tools/analysis.py +723 -0
- solidworks_mcp/tools/automation.py +578 -0
- solidworks_mcp/tools/docs_discovery.py +1476 -0
- solidworks_mcp/tools/drawing.py +1015 -0
- solidworks_mcp/tools/drawing_analysis.py +456 -0
- solidworks_mcp/tools/drawing_automation.py +627 -0
- solidworks_mcp/tools/engineering_data.py +466 -0
- solidworks_mcp/tools/error_classifier.py +232 -0
- solidworks_mcp/tools/export.py +833 -0
- solidworks_mcp/tools/file_facts.py +40 -0
- solidworks_mcp/tools/file_management.py +1473 -0
- solidworks_mcp/tools/input_compat.py +17 -0
- solidworks_mcp/tools/macro_recording.py +464 -0
- solidworks_mcp/tools/modeling.py +2360 -0
- solidworks_mcp/tools/sketching.py +1422 -0
- solidworks_mcp/tools/template_management.py +367 -0
- solidworks_mcp/tools/tool_boundary.py +280 -0
- solidworks_mcp/tools/vba_generation.py +1145 -0
- solidworks_mcp/ui/__init__.py +9 -0
- solidworks_mcp/ui/local_llm.py +725 -0
- solidworks_mcp/ui/routers/__init__.py +10 -0
- solidworks_mcp/ui/routers/checkpoint.py +24 -0
- solidworks_mcp/ui/routers/docs.py +47 -0
- solidworks_mcp/ui/routers/llm.py +83 -0
- solidworks_mcp/ui/routers/local_model.py +69 -0
- solidworks_mcp/ui/routers/model.py +75 -0
- solidworks_mcp/ui/routers/preview.py +38 -0
- solidworks_mcp/ui/routers/session.py +161 -0
- solidworks_mcp/ui/routers/viewer.py +154 -0
- solidworks_mcp/ui/schemas.py +502 -0
- solidworks_mcp/ui/server.py +298 -0
- solidworks_mcp/ui/service.py +481 -0
- solidworks_mcp/ui/services/__init__.py +136 -0
- solidworks_mcp/ui/services/_utils.py +958 -0
- solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
- solidworks_mcp/ui/services/docs_service.py +282 -0
- solidworks_mcp/ui/services/llm_service.py +956 -0
- solidworks_mcp/ui/services/model_service.py +459 -0
- solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
- solidworks_mcp/ui/services/preview_service.py +489 -0
- solidworks_mcp/ui/services/session_service.py +1389 -0
- solidworks_mcp/utils/__init__.py +9 -0
- solidworks_mcp/utils/feature_tree_classifier.py +342 -0
- solidworks_mcp/utils/logging.py +76 -0
- solidworks_mcp/utils/validation.py +77 -0
- solidworks_mcp/version.py +8 -0
- solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
- solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
- solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
- solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
- solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""Resolve a face by point / side / plane and multi-select its edges (#106).
|
|
2
|
+
|
|
3
|
+
Split into three single-purpose pieces so each can be tested on its own:
|
|
4
|
+
|
|
5
|
+
* **Pure geometry** (no COM): side-token parsing, picking the furthest
|
|
6
|
+
planar face facing a direction, matching faces to a reference plane, and
|
|
7
|
+
input validation shared by the real and mock adapters.
|
|
8
|
+
* **Edge description** (:func:`describe_edge`): one place that turns an
|
|
9
|
+
``IEdge`` into a JSON-friendly dict - also used by ``get_selected_entities``.
|
|
10
|
+
* **COM access** (:class:`FaceEdgeSelector`): reads faces/edges off the
|
|
11
|
+
active part and performs the multi-selection.
|
|
12
|
+
|
|
13
|
+
Live behaviour this relies on (R2026x, 2026-10-13) is recorded in
|
|
14
|
+
``openspec/changes/archive/2026-10-30-wire-unwired-adapter-tools/design.md``: every dispatch must
|
|
15
|
+
be method-flagged, ``IFace2.Normal`` is ``[0,0,0]`` for non-planar faces,
|
|
16
|
+
``IFace2.GetClosestPointOn`` returns the closest point in its first three
|
|
17
|
+
values, a full-circle edge has no vertices, ``IRefPlane.Transform.ArrayData``
|
|
18
|
+
holds the plane normal at ``[6:9]`` and origin at ``[9:12]``, and
|
|
19
|
+
``IEntity.Select4`` needs a real ``SelectData`` (``None`` is a type mismatch).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .com_utils import as_list, selection_count
|
|
28
|
+
from .com_utils import attempt as _attempt
|
|
29
|
+
from .com_utils import flag as _flag
|
|
30
|
+
|
|
31
|
+
#: Outward direction for each accepted ``side`` token (case-insensitive).
|
|
32
|
+
#: Aliases follow SolidWorks' default orientation: Front = XY (+Z),
|
|
33
|
+
#: Top = XZ (+Y), Right = YZ (+X).
|
|
34
|
+
SIDE_VECTORS: dict[str, tuple[float, float, float]] = {
|
|
35
|
+
"+x": (1.0, 0.0, 0.0),
|
|
36
|
+
"-x": (-1.0, 0.0, 0.0),
|
|
37
|
+
"+y": (0.0, 1.0, 0.0),
|
|
38
|
+
"-y": (0.0, -1.0, 0.0),
|
|
39
|
+
"+z": (0.0, 0.0, 1.0),
|
|
40
|
+
"-z": (0.0, 0.0, -1.0),
|
|
41
|
+
"right": (1.0, 0.0, 0.0),
|
|
42
|
+
"left": (-1.0, 0.0, 0.0),
|
|
43
|
+
"top": (0.0, 1.0, 0.0),
|
|
44
|
+
"bottom": (0.0, -1.0, 0.0),
|
|
45
|
+
"front": (0.0, 0.0, 1.0),
|
|
46
|
+
"back": (0.0, 0.0, -1.0),
|
|
47
|
+
}
|
|
48
|
+
EDGE_TYPES = ("all", "linear", "circular")
|
|
49
|
+
|
|
50
|
+
_PARALLEL = 0.999 # |cos| threshold for "same direction"
|
|
51
|
+
_ON_PLANE_M = 1e-6 # metres
|
|
52
|
+
_ON_FACE_M = 1e-5 # metres (0.01 mm) for point-on-face
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---- pure helpers ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _dot(a: tuple[float, ...], b: tuple[float, ...]) -> float:
|
|
59
|
+
return sum(x * y for x, y in zip(a, b, strict=True))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def side_vector(side: str) -> tuple[float, float, float]:
|
|
63
|
+
"""Return the unit vector for a side token.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: When the token is not one of :data:`SIDE_VECTORS`.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
return SIDE_VECTORS[side.strip().lower()]
|
|
70
|
+
except KeyError:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Unknown side {side!r}; use one of: " + ", ".join(SIDE_VECTORS)
|
|
73
|
+
) from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_face_query(
|
|
77
|
+
point: list[float] | None,
|
|
78
|
+
side: str | None,
|
|
79
|
+
plane: str | None,
|
|
80
|
+
edge_type: str,
|
|
81
|
+
) -> str | None:
|
|
82
|
+
"""Check a select_face_edges request; return an error message or None."""
|
|
83
|
+
given = [
|
|
84
|
+
name
|
|
85
|
+
for name, value in (("point", point), ("side", side), ("plane", plane))
|
|
86
|
+
if value not in (None, "")
|
|
87
|
+
]
|
|
88
|
+
if len(given) != 1:
|
|
89
|
+
return (
|
|
90
|
+
"Give exactly one of point, side or plane to identify the face "
|
|
91
|
+
f"(got {', '.join(given) or 'none'})."
|
|
92
|
+
)
|
|
93
|
+
if point is not None and len(point) != 3:
|
|
94
|
+
return "point must be [x, y, z] in millimetres."
|
|
95
|
+
if side is not None:
|
|
96
|
+
try:
|
|
97
|
+
side_vector(side)
|
|
98
|
+
except ValueError as e:
|
|
99
|
+
return str(e)
|
|
100
|
+
if edge_type not in EDGE_TYPES:
|
|
101
|
+
return f"edge_type must be one of {', '.join(EDGE_TYPES)} (got {edge_type!r})."
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class FaceInfo:
|
|
107
|
+
"""Geometry of one body face, in metres.
|
|
108
|
+
|
|
109
|
+
Attributes:
|
|
110
|
+
index: Position in the body's face list (stable within one call).
|
|
111
|
+
normal: Outward unit normal; ``(0, 0, 0)`` for non-planar faces.
|
|
112
|
+
box: ``(xmin, ymin, zmin, xmax, ymax, zmax)``.
|
|
113
|
+
area: Face area in m^2.
|
|
114
|
+
handle: The ``IFace2`` dispatch (``None`` in pure tests).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
index: int
|
|
118
|
+
normal: tuple[float, float, float]
|
|
119
|
+
box: tuple[float, float, float, float, float, float]
|
|
120
|
+
area: float
|
|
121
|
+
handle: Any = None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def is_planar(self) -> bool:
|
|
125
|
+
"""True when SolidWorks reported a non-zero normal."""
|
|
126
|
+
return any(abs(c) > 1e-9 for c in self.normal)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def center(self) -> tuple[float, float, float]:
|
|
130
|
+
"""Bounding-box centre (lies in the face's plane for planar faces)."""
|
|
131
|
+
b = self.box
|
|
132
|
+
return ((b[0] + b[3]) / 2, (b[1] + b[4]) / 2, (b[2] + b[5]) / 2)
|
|
133
|
+
|
|
134
|
+
def describe(self) -> dict[str, Any]:
|
|
135
|
+
"""JSON-friendly summary in millimetres."""
|
|
136
|
+
return {
|
|
137
|
+
"index": self.index,
|
|
138
|
+
"center_mm": [round(c * 1000, 6) for c in self.center],
|
|
139
|
+
"normal": [round(c, 6) + 0.0 for c in self.normal],
|
|
140
|
+
"area_mm2": round(self.area * 1e6, 6),
|
|
141
|
+
"planar": self.is_planar,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def pick_side_face(
|
|
146
|
+
faces: list[FaceInfo], direction: tuple[float, float, float]
|
|
147
|
+
) -> FaceInfo | None:
|
|
148
|
+
"""Return the planar face facing ``direction`` that lies furthest along it.
|
|
149
|
+
|
|
150
|
+
Ties (same offset within 0.1 µm) go to the larger face.
|
|
151
|
+
"""
|
|
152
|
+
facing = [
|
|
153
|
+
f for f in faces if f.is_planar and _dot(f.normal, direction) >= _PARALLEL
|
|
154
|
+
]
|
|
155
|
+
if not facing:
|
|
156
|
+
return None
|
|
157
|
+
return max(facing, key=lambda f: (round(_dot(f.center, direction), 7), f.area))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def plane_from_transform(
|
|
161
|
+
array_data: list[float],
|
|
162
|
+
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
|
163
|
+
"""Return ``(origin, normal)`` from ``IRefPlane.Transform.ArrayData``."""
|
|
164
|
+
normal = (float(array_data[6]), float(array_data[7]), float(array_data[8]))
|
|
165
|
+
origin = (float(array_data[9]), float(array_data[10]), float(array_data[11]))
|
|
166
|
+
return origin, normal
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def faces_on_plane(
|
|
170
|
+
faces: list[FaceInfo],
|
|
171
|
+
origin: tuple[float, float, float],
|
|
172
|
+
normal: tuple[float, float, float],
|
|
173
|
+
) -> list[FaceInfo]:
|
|
174
|
+
"""Return the planar faces that lie in the given plane."""
|
|
175
|
+
out = []
|
|
176
|
+
for f in faces:
|
|
177
|
+
if not f.is_planar or abs(_dot(f.normal, normal)) < _PARALLEL:
|
|
178
|
+
continue
|
|
179
|
+
offset = tuple(c - o for c, o in zip(f.center, origin, strict=True))
|
|
180
|
+
if abs(_dot(offset, normal)) <= _ON_PLANE_M:
|
|
181
|
+
out.append(f)
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def edge_matches(kind: str, edge_type: str) -> bool:
|
|
186
|
+
"""Whether an edge of ``kind`` passes the ``edge_type`` filter."""
|
|
187
|
+
return edge_type == "all" or kind == edge_type
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---- COM: edge description -----------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
#: Exactly the members read below. Flagging a whole interface costs ~27 ms
|
|
194
|
+
#: per fresh dispatch (see ``_WALK_MEMBERS`` in pywin32_adapter); a face can
|
|
195
|
+
#: hand back dozens of edges/curves/vertices, so flag just what is used.
|
|
196
|
+
_EDGE_MEMBERS = ("GetCurve", "GetStartVertex", "GetEndVertex", "Select4")
|
|
197
|
+
_FACE_MEMBERS = ("GetBox", "GetArea", "GetEdges", "GetClosestPointOn")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def describe_edge(adapter: Any, edge: Any) -> dict[str, Any]:
|
|
201
|
+
"""Describe an ``IEdge`` in millimetres; unresolvable fields are omitted.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
dict[str, Any]: ``curve`` (``linear``/``circular``/``other``), plus
|
|
205
|
+
``start_mm``/``end_mm``/``length_mm`` when the edge has vertices
|
|
206
|
+
(straight-line length - a curved edge is longer), and
|
|
207
|
+
``center_mm``/``axis``/``radius_mm`` for circular edges.
|
|
208
|
+
"""
|
|
209
|
+
_flag(adapter, edge, *_EDGE_MEMBERS)
|
|
210
|
+
entry: dict[str, Any] = {"curve": "other"}
|
|
211
|
+
|
|
212
|
+
curve = _attempt(adapter, lambda: edge.GetCurve())
|
|
213
|
+
if curve is not None:
|
|
214
|
+
_flag(adapter, curve, "IsCircle", "IsLine")
|
|
215
|
+
if _attempt(adapter, lambda: curve.IsCircle(), False):
|
|
216
|
+
entry["curve"] = "circular"
|
|
217
|
+
params = _attempt(adapter, lambda: list(curve.CircleParams))
|
|
218
|
+
if params and len(params) >= 7:
|
|
219
|
+
entry["center_mm"] = [c * 1000 for c in params[0:3]]
|
|
220
|
+
entry["axis"] = [round(float(c), 6) + 0.0 for c in params[3:6]]
|
|
221
|
+
entry["radius_mm"] = params[6] * 1000
|
|
222
|
+
elif _attempt(adapter, lambda: curve.IsLine(), False):
|
|
223
|
+
entry["curve"] = "linear"
|
|
224
|
+
|
|
225
|
+
points = []
|
|
226
|
+
for getter in ("GetStartVertex", "GetEndVertex"):
|
|
227
|
+
vertex = _attempt(adapter, lambda g=getter: getattr(edge, g)())
|
|
228
|
+
if vertex is None:
|
|
229
|
+
points.append(None)
|
|
230
|
+
continue
|
|
231
|
+
_flag(adapter, vertex, "GetPoint")
|
|
232
|
+
points.append(_attempt(adapter, lambda v=vertex: v.GetPoint()))
|
|
233
|
+
start, end = points
|
|
234
|
+
if start:
|
|
235
|
+
entry["start_mm"] = [c * 1000 for c in start]
|
|
236
|
+
if end:
|
|
237
|
+
entry["end_mm"] = [c * 1000 for c in end]
|
|
238
|
+
if start and end:
|
|
239
|
+
entry["length_mm"] = (
|
|
240
|
+
sum((a - b) ** 2 for a, b in zip(start, end, strict=True)) ** 0.5 * 1000
|
|
241
|
+
)
|
|
242
|
+
return entry
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---- COM: face resolution + selection -----------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class FaceEdgeSelector:
|
|
249
|
+
"""Resolve a face on the active part and multi-select its edges.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
adapter: A connected ``PyWin32Adapter`` (needs ``currentModel`` and
|
|
253
|
+
``_attempt``).
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(self, adapter: Any) -> None:
|
|
257
|
+
self._adapter = adapter
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def _model(self) -> Any:
|
|
261
|
+
return self._adapter.currentModel
|
|
262
|
+
|
|
263
|
+
def faces(self) -> list[FaceInfo]:
|
|
264
|
+
"""Return every face of every solid body on the active part."""
|
|
265
|
+
part = self._model
|
|
266
|
+
_flag(self._adapter, part, "GetBodies2")
|
|
267
|
+
bodies = as_list(_attempt(self._adapter, lambda: part.GetBodies2(0, False)))
|
|
268
|
+
out: list[FaceInfo] = []
|
|
269
|
+
for body in bodies:
|
|
270
|
+
_flag(self._adapter, body, "GetFaces")
|
|
271
|
+
for face in _attempt(self._adapter, lambda b=body: b.GetFaces()) or []:
|
|
272
|
+
_flag(self._adapter, face, *_FACE_MEMBERS)
|
|
273
|
+
normal = _attempt(self._adapter, lambda f=face: list(f.Normal)) or [
|
|
274
|
+
0,
|
|
275
|
+
0,
|
|
276
|
+
0,
|
|
277
|
+
]
|
|
278
|
+
box = (
|
|
279
|
+
_attempt(self._adapter, lambda f=face: list(f.GetBox())) or [0] * 6
|
|
280
|
+
)
|
|
281
|
+
area = _attempt(self._adapter, lambda f=face: f.GetArea()) or 0.0
|
|
282
|
+
out.append(
|
|
283
|
+
FaceInfo(
|
|
284
|
+
index=len(out),
|
|
285
|
+
normal=tuple(float(c) for c in normal[:3]), # type: ignore[arg-type]
|
|
286
|
+
box=tuple(float(c) for c in box[:6]), # type: ignore[arg-type]
|
|
287
|
+
area=float(area),
|
|
288
|
+
handle=face,
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
return out
|
|
292
|
+
|
|
293
|
+
def face_at_point(self, faces: list[FaceInfo], point_mm: list[float]) -> FaceInfo:
|
|
294
|
+
"""Return the face containing ``point_mm`` (closest within 0.01 mm)."""
|
|
295
|
+
q = tuple(c / 1000.0 for c in point_mm)
|
|
296
|
+
best: tuple[float, FaceInfo] | None = None
|
|
297
|
+
for f in faces:
|
|
298
|
+
closest = _attempt(
|
|
299
|
+
self._adapter, lambda f=f: list(f.handle.GetClosestPointOn(*q))
|
|
300
|
+
)
|
|
301
|
+
if not closest or len(closest) < 3:
|
|
302
|
+
continue
|
|
303
|
+
d = sum((a - b) ** 2 for a, b in zip(closest[:3], q, strict=True)) ** 0.5
|
|
304
|
+
if best is None or d < best[0]:
|
|
305
|
+
best = (d, f)
|
|
306
|
+
if best is None or best[0] > _ON_FACE_M:
|
|
307
|
+
raise LookupError(f"No face found at point {list(point_mm)} mm.")
|
|
308
|
+
return best[1]
|
|
309
|
+
|
|
310
|
+
def plane_geometry(
|
|
311
|
+
self, plane: str
|
|
312
|
+
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
|
313
|
+
"""Return ``(origin, normal)`` of a named reference plane."""
|
|
314
|
+
feature = _attempt(self._adapter, lambda: self._model.FeatureByName(plane))
|
|
315
|
+
if feature is None:
|
|
316
|
+
raise LookupError(f"No reference plane named {plane!r}.")
|
|
317
|
+
_flag(self._adapter, feature, "GetSpecificFeature2")
|
|
318
|
+
ref = _attempt(self._adapter, lambda: feature.GetSpecificFeature2())
|
|
319
|
+
if ref is None:
|
|
320
|
+
raise LookupError(f"{plane!r} is not a reference plane.")
|
|
321
|
+
data = _attempt(self._adapter, lambda: list(ref.Transform.ArrayData))
|
|
322
|
+
if not data or len(data) < 12:
|
|
323
|
+
raise LookupError(f"Could not read the geometry of plane {plane!r}.")
|
|
324
|
+
return plane_from_transform(data)
|
|
325
|
+
|
|
326
|
+
def resolve(
|
|
327
|
+
self, point: list[float] | None, side: str | None, plane: str | None
|
|
328
|
+
) -> list[FaceInfo]:
|
|
329
|
+
"""Resolve the request to one face (or several for ``plane``)."""
|
|
330
|
+
faces = self.faces()
|
|
331
|
+
if not faces:
|
|
332
|
+
raise LookupError("The active part has no solid body faces.")
|
|
333
|
+
if point is not None:
|
|
334
|
+
return [self.face_at_point(faces, point)]
|
|
335
|
+
if side is not None:
|
|
336
|
+
face = pick_side_face(faces, side_vector(side))
|
|
337
|
+
if face is None:
|
|
338
|
+
raise LookupError(f"No planar face faces {side!r}.")
|
|
339
|
+
return [face]
|
|
340
|
+
origin, normal = self.plane_geometry(str(plane))
|
|
341
|
+
on_plane = faces_on_plane(faces, origin, normal)
|
|
342
|
+
if not on_plane:
|
|
343
|
+
raise LookupError(f"No body face lies on {plane!r}.")
|
|
344
|
+
return on_plane
|
|
345
|
+
|
|
346
|
+
def edges(
|
|
347
|
+
self, faces: list[FaceInfo], edge_type: str
|
|
348
|
+
) -> list[tuple[Any, dict[str, Any]]]:
|
|
349
|
+
"""Return ``(edge, description)`` for the faces' edges passing the filter."""
|
|
350
|
+
out = []
|
|
351
|
+
for f in faces:
|
|
352
|
+
for edge in _attempt(self._adapter, lambda f=f: f.handle.GetEdges()) or []:
|
|
353
|
+
info = describe_edge(self._adapter, edge)
|
|
354
|
+
if edge_matches(info["curve"], edge_type):
|
|
355
|
+
info["face_index"] = f.index
|
|
356
|
+
out.append((edge, info))
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
def select(
|
|
360
|
+
self, edges: list[tuple[Any, dict[str, Any]]], append: bool
|
|
361
|
+
) -> int | None:
|
|
362
|
+
"""Select every edge; return the selection count afterwards.
|
|
363
|
+
|
|
364
|
+
All or nothing: if any edge refuses to select, the selection is
|
|
365
|
+
cleared before raising, so a following ``add_chamfer`` /
|
|
366
|
+
``add_fillet`` with empty ``edge_names`` cannot silently act on a
|
|
367
|
+
partial set.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
RuntimeError: When any edge refuses to select.
|
|
371
|
+
"""
|
|
372
|
+
model = self._model
|
|
373
|
+
sel_mgr = model.SelectionManager
|
|
374
|
+
_flag(self._adapter, sel_mgr, "CreateSelectData")
|
|
375
|
+
if not append:
|
|
376
|
+
_attempt(self._adapter, lambda: model.ClearSelection2(True))
|
|
377
|
+
select_data = _attempt(self._adapter, lambda: sel_mgr.CreateSelectData())
|
|
378
|
+
failed = []
|
|
379
|
+
for i, (edge, _info) in enumerate(edges):
|
|
380
|
+
_flag(self._adapter, edge, "Select4")
|
|
381
|
+
if not _attempt(
|
|
382
|
+
self._adapter, lambda e=edge: e.Select4(True, select_data), False
|
|
383
|
+
):
|
|
384
|
+
failed.append(i)
|
|
385
|
+
if failed:
|
|
386
|
+
_attempt(self._adapter, lambda: model.ClearSelection2(True))
|
|
387
|
+
raise RuntimeError(
|
|
388
|
+
f"{len(failed)} of {len(edges)} edge(s) could not be selected "
|
|
389
|
+
f"(indices {failed}); the selection was cleared so nothing acts "
|
|
390
|
+
"on a partial set."
|
|
391
|
+
)
|
|
392
|
+
return selection_count(self._adapter, model)
|