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,753 @@
1
+ """Overlap-free placement of drawing dimensions (pure geometry, no COM).
2
+
3
+ Rule enforced: **nothing on the sheet may overlap.** A dimension's text box must
4
+ not intersect another dimension's text, be crossed by another dimension's lines,
5
+ sit on any view's geometry, touch an obstacle (title block, view captions, note
6
+ blocks) or leave the working area of the frame. A dimension's lines must not
7
+ cross another view, and its extension lines must not run across its own view to
8
+ the far side.
9
+
10
+ ``InsertModelAnnotations3`` places imported dimensions where they sit in the
11
+ model sketch, which on a drawing view lands them *inside* the view outline, on
12
+ top of the part and of each other (measured live on SW 2025).
13
+
14
+ :func:`plan_layout` moves each dimension outside its view, following the
15
+ A3 drafting standard
16
+ (``drawing_standard.py``): horizontal dimensions in rows above/below the part, rows 7 mm
17
+ apart; vertical dimensions in columns left/right, 9 mm apart; the first row
18
+ 8 mm from the outline, on the side its extension lines start from. Others
19
+ (diameters, radii, angles) keep their leader on the feature and step their
20
+ text outward from it. Rows are filled shortest span first, so the extension lines of longer
21
+ outer dimensions clear the inner text. Every candidate position is checked
22
+ against everything already placed; the first free one wins.
23
+
24
+ All coordinates are sheet metres; boxes and segments are ``(x0, y0, x1, y1)``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ import re
31
+ from dataclasses import dataclass, field
32
+
33
+ Box = tuple[float, float, float, float]
34
+ Segment = tuple[float, float, float, float]
35
+
36
+ #: Clearance kept around every text box when testing overlaps (0.75 mm).
37
+ TEXT_PADDING = 0.00075
38
+ #: First row of dimensions: 8 mm from the part outline.
39
+ FIRST_OFFSET = 0.008
40
+ #: Horizontal-dimension rows 7 mm apart.
41
+ ROW_PITCH = 0.007
42
+ #: Vertical-dimension columns 9 mm apart.
43
+ COLUMN_PITCH = 0.009
44
+ #: Gap used when a text box is bigger than the standard pitch allows.
45
+ STACK_GAP = 0.0035
46
+ #: Outward steps tried per side before giving up on that side.
47
+ MAX_STEPS = 12
48
+ #: A leader's text is tried every 4 mm outward from its feature.
49
+ LEADER_STEP = 0.004
50
+ #: Another view's outline is grown by 0.2 mm, so a line along its edge counts.
51
+ EDGE_TOLERANCE = 0.0002
52
+ #: More than 1 mm of a line on another view is a crossing; touching is not.
53
+ MIN_CROSSING = 0.001
54
+ #: A leader is planned at least 2 mm from any text. SolidWorks attaches it to
55
+ #: its text a little differently from the model here, and a leader planned to
56
+ #: just miss a "10" grazed it (seen live on SW 2026); the check stays exact.
57
+ LEADER_CLEARANCE = 0.002
58
+ #: A leader's text may sit inside its own view (a hole note on a plate) when
59
+ #: it is this far inside the view's outline ...
60
+ INSIDE_MARGIN = 0.008
61
+ #: ... and this far from every point a dimension starts from (hole centres,
62
+ #: edge points, the leader's own hole), the features the view shows there.
63
+ FEATURE_CLEARANCE = 0.006
64
+
65
+
66
+ def _grown(box: Box, by: float) -> Box:
67
+ """``box`` grown by ``by`` on every side."""
68
+ return (box[0] - by, box[1] - by, box[2] + by, box[3] + by)
69
+
70
+
71
+ def shown_length(text: str) -> int:
72
+ """Characters a dimension's text shows: each SolidWorks code is one symbol.
73
+
74
+ ``IDisplayData::GetTextAtIndex`` returns codes such as ``<MOD-DIAM>``
75
+ for the Ø sign; counted as typed, they make the text look three times
76
+ wider than it is drawn.
77
+ """
78
+ return len(re.sub(r"<[^<>]*>", "X", text).strip())
79
+
80
+
81
+ def text_box(x: float, y: float, width: float, height: float, angle: float) -> Box:
82
+ """Sheet box of a text run anchored at its lower-left corner and turned.
83
+
84
+ ``IDisplayData`` gives each text's lower-left corner, length, height and
85
+ angle. A vertical dimension's text is turned 90 degrees, so it runs up the
86
+ sheet from that corner and lies to its left (measured live on SW 2026).
87
+
88
+ Args:
89
+ x: Lower-left corner x.
90
+ y: Lower-left corner y.
91
+ width: Text length along its baseline.
92
+ height: Text height.
93
+ angle: Baseline angle in radians, counter-clockwise.
94
+
95
+ Returns:
96
+ Box: The axis-aligned box around the turned text.
97
+ """
98
+ c, s = math.cos(angle), math.sin(angle)
99
+ corners = [(0.0, 0.0), (width, 0.0), (0.0, height), (width, height)]
100
+ xs = [x + u * c - v * s for u, v in corners]
101
+ ys = [y + u * s + v * c for u, v in corners]
102
+ return (min(xs), min(ys), max(xs), max(ys))
103
+
104
+
105
+ def crosses_other_view(
106
+ lines: list[Segment], own_view: str, outlines: dict[str, Box]
107
+ ) -> str | None:
108
+ """Return the first other view any of ``lines`` runs across or along.
109
+
110
+ A dimension's extension lines start on its own view's geometry, so its own
111
+ outline is exempt. On any other view, more than :data:`MIN_CROSSING` of a
112
+ line inside that outline (grown by :data:`EDGE_TOLERANCE`, so a line lying
113
+ along its edge counts) is a crossing; a line ending at the edge is not.
114
+ Measured live on SW 2026: a front view's height dimension parked beyond
115
+ the side view drew its extension lines along the side view's top and
116
+ bottom edges, straight across it.
117
+
118
+ Args:
119
+ lines: The dimension's line segments.
120
+ own_view: The view the dimension belongs to.
121
+ outlines: View name -> view outline.
122
+
123
+ Returns:
124
+ str | None: The crossed view's name, or ``None`` when clear.
125
+ """
126
+ t = EDGE_TOLERANCE
127
+ for view, o in outlines.items():
128
+ if view == own_view:
129
+ continue
130
+ grown = (o[0] - t, o[1] - t, o[2] + t, o[3] + t)
131
+ if any(length_inside(s, grown) > MIN_CROSSING for s in lines):
132
+ return view
133
+ return None
134
+
135
+
136
+ @dataclass
137
+ class DimensionGeometry:
138
+ """One drawing dimension as read from ``IAnnotation::GetDisplayData``.
139
+
140
+ Attributes:
141
+ key: Stable identifier (view name + index).
142
+ view: Owning view name.
143
+ text_box: Current text box on the sheet.
144
+ position: ``IAnnotation::GetPosition`` (x, y) - what ``SetPosition`` moves.
145
+ lines: Every line segment of the dimension (extension + dimension lines).
146
+ orientation: ``"h"``, ``"v"`` or ``None``; set by :func:`plan_layout`.
147
+ span: Distance between the extension lines (0 when unknown).
148
+ """
149
+
150
+ key: str
151
+ view: str
152
+ text_box: Box
153
+ position: tuple[float, float]
154
+ lines: list[Segment] = field(default_factory=list)
155
+ orientation: str | None = None
156
+ span: float = 0.0
157
+
158
+
159
+ def attach_boxes(dims: list[DimensionGeometry], attached: dict[str, Box]) -> None:
160
+ """Grow each dimension's text box by what hangs off it, such as a GD&T frame.
161
+
162
+ A frame attached to a dimension moves with it, so its box is given
163
+ relative to the dimension's position and counts as part of its text.
164
+
165
+ Args:
166
+ dims: Dimensions as read; updated in place.
167
+ attached: Dimension key -> box relative to that dimension's position.
168
+ """
169
+ for dim in dims:
170
+ rel = attached.get(dim.key)
171
+ if rel is None:
172
+ continue
173
+ x, y = dim.position
174
+ tb = dim.text_box
175
+ dim.text_box = (
176
+ min(tb[0], x + rel[0]),
177
+ min(tb[1], y + rel[1]),
178
+ max(tb[2], x + rel[2]),
179
+ max(tb[3], y + rel[3]),
180
+ )
181
+
182
+
183
+ def box_overlap(a: Box, b: Box, pad: float = 0.0) -> bool:
184
+ """Return True when two boxes intersect (after growing each by ``pad``)."""
185
+ return not (
186
+ a[2] + pad <= b[0] - pad
187
+ or b[2] + pad <= a[0] - pad
188
+ or a[3] + pad <= b[1] - pad
189
+ or b[3] + pad <= a[1] - pad
190
+ )
191
+
192
+
193
+ def inside(box: Box, area: Box) -> bool:
194
+ """Return True when ``box`` lies entirely within ``area``."""
195
+ return (
196
+ area[0] <= box[0]
197
+ and box[2] <= area[2]
198
+ and area[1] <= box[1]
199
+ and box[3] <= area[3]
200
+ )
201
+
202
+
203
+ def segment_hits_box(seg: Segment, box: Box) -> bool:
204
+ """Liang-Barsky clip: True when the segment passes through the box."""
205
+ x0, y0, x1, y1 = seg
206
+ dx, dy = x1 - x0, y1 - y0
207
+ t0, t1 = 0.0, 1.0
208
+ for p, q in (
209
+ (-dx, x0 - box[0]),
210
+ (dx, box[2] - x0),
211
+ (-dy, y0 - box[1]),
212
+ (dy, box[3] - y0),
213
+ ):
214
+ if abs(p) < 1e-12:
215
+ if q < 0:
216
+ return False
217
+ continue
218
+ t = q / p
219
+ if p < 0:
220
+ t0 = max(t0, t)
221
+ else:
222
+ t1 = min(t1, t)
223
+ if t0 > t1:
224
+ return False
225
+ return True
226
+
227
+
228
+ def length_inside(seg: Segment, box: Box) -> float:
229
+ """Length of the part of a segment that lies inside a box (Liang-Barsky).
230
+
231
+ Args:
232
+ seg: The segment.
233
+ box: The box.
234
+
235
+ Returns:
236
+ float: 0.0 when the segment misses the box.
237
+ """
238
+ x0, y0, x1, y1 = seg
239
+ dx, dy = x1 - x0, y1 - y0
240
+ t0, t1 = 0.0, 1.0
241
+ for p, q in (
242
+ (-dx, x0 - box[0]),
243
+ (dx, box[2] - x0),
244
+ (-dy, y0 - box[1]),
245
+ (dy, box[3] - y0),
246
+ ):
247
+ if abs(p) < 1e-12:
248
+ if q < 0:
249
+ return 0.0
250
+ continue
251
+ t = q / p
252
+ if p < 0:
253
+ t0 = max(t0, t)
254
+ else:
255
+ t1 = min(t1, t)
256
+ if t0 > t1:
257
+ return 0.0
258
+ return (t1 - t0) * (dx * dx + dy * dy) ** 0.5
259
+
260
+
261
+ def shift_box(box: Box, dx: float, dy: float) -> Box:
262
+ """Translate a box."""
263
+ return (box[0] + dx, box[1] + dy, box[2] + dx, box[3] + dy)
264
+
265
+
266
+ def classify(lines: list[Segment]) -> tuple[str | None, float, list[float]]:
267
+ """Work out orientation, span and extension-line coordinates.
268
+
269
+ SolidWorks returns a linear dimension's two extension lines first. Both
270
+ vertical means a horizontal dimension (their x values are the span ends);
271
+ both horizontal means a vertical one.
272
+
273
+ Returns:
274
+ tuple: orientation, span, and the two extension-line coordinates.
275
+ """
276
+ if len(lines) >= 2:
277
+ a, b = lines[0], lines[1]
278
+ tol = 1e-6
279
+ if abs(a[0] - a[2]) < tol and abs(b[0] - b[2]) < tol and abs(a[0] - b[0]) > tol:
280
+ return "h", abs(a[0] - b[0]), sorted([a[0], b[0]])
281
+ if abs(a[1] - a[3]) < tol and abs(b[1] - b[3]) < tol and abs(a[1] - b[1]) > tol:
282
+ return "v", abs(a[1] - b[1]), sorted([a[1], b[1]])
283
+ return None, 0.0, []
284
+
285
+
286
+ def extension_origins(dim: DimensionGeometry) -> list[float]:
287
+ """Where each extension line starts on the part, across the dimension.
288
+
289
+ An extension line runs from the part to just past the dimension line, so
290
+ its start is the end farther from the text: a y for a horizontal
291
+ dimension, an x for a vertical one.
292
+
293
+ Args:
294
+ dim: The dimension.
295
+
296
+ Returns:
297
+ list[float]: One coordinate per extension line; empty for a leader.
298
+ """
299
+ orient = classify(dim.lines)[0]
300
+ if orient is None:
301
+ return []
302
+ axis = 1 if orient == "h" else 0
303
+ centre = (dim.text_box[axis] + dim.text_box[axis + 2]) / 2.0
304
+ return [
305
+ max((s[axis], s[axis + 2]), key=lambda c: abs(c - centre))
306
+ for s in dim.lines[:2]
307
+ ]
308
+
309
+
310
+ def crosses_own_view(dim: DimensionGeometry, outline: Box) -> bool:
311
+ """True when an extension line runs across its own view to the far side.
312
+
313
+ Measured live on SW 2026: a height dimension moved from the left of the
314
+ top view to its right drew both extension lines straight across the view.
315
+
316
+ Args:
317
+ dim: The dimension.
318
+ outline: Its view's outline.
319
+
320
+ Returns:
321
+ bool: ``False`` for leaders, which have no extension lines.
322
+ """
323
+ axis = 1 if classify(dim.lines)[0] == "h" else 0
324
+ low, high = outline[axis], outline[axis + 2]
325
+ middle = (low + high) / 2.0
326
+ for start, line in zip(extension_origins(dim), dim.lines, strict=False):
327
+ end = line[axis + 2] if line[axis] == start else line[axis]
328
+ if (start < middle and end > high) or (start > middle and end < low):
329
+ return True
330
+ return False
331
+
332
+
333
+ def leader_anchor(dim: DimensionGeometry) -> tuple[float, float] | None:
334
+ """Where a leader (diameter, radius) meets the part: the line end farthest from the text.
335
+
336
+ Args:
337
+ dim: The dimension.
338
+
339
+ Returns:
340
+ tuple[float, float] | None: The anchor, or ``None`` with no lines.
341
+ """
342
+ cx = (dim.text_box[0] + dim.text_box[2]) / 2.0
343
+ cy = (dim.text_box[1] + dim.text_box[3]) / 2.0
344
+ ends = [(s[0], s[1]) for s in dim.lines] + [(s[2], s[3]) for s in dim.lines]
345
+ if not ends:
346
+ return None
347
+ return max(ends, key=lambda p: (p[0] - cx) ** 2 + (p[1] - cy) ** 2)
348
+
349
+
350
+ def feature_points(
351
+ dims: list[DimensionGeometry], view: str, outline: Box, skip: str | None = None
352
+ ) -> list[tuple[float, float]]:
353
+ """Points inside a view where its dimensions start: its features.
354
+
355
+ Every line end of the view's dimensions (but ``skip``'s) that lies inside
356
+ the outline: extension lines start on holes and edges, leaders on the
357
+ feature they point at.
358
+ """
359
+ inner = _grown(outline, -0.0005)
360
+ return [
361
+ p
362
+ for d in dims
363
+ if d.view == view and d.key != skip
364
+ for s in d.lines
365
+ for p in ((s[0], s[1]), (s[2], s[3]))
366
+ if inner[0] < p[0] < inner[2] and inner[1] < p[1] < inner[3]
367
+ ]
368
+
369
+
370
+ def clear_inside(box: Box, outline: Box, points: list[tuple[float, float]]) -> bool:
371
+ """True when ``box`` sits well inside ``outline`` and clear of ``points``."""
372
+ room = _grown(outline, -INSIDE_MARGIN)
373
+ if not (
374
+ room[0] <= box[0]
375
+ and room[1] <= box[1]
376
+ and box[2] <= room[2]
377
+ and box[3] <= room[3]
378
+ ):
379
+ return False
380
+ near = _grown(box, FEATURE_CLEARANCE)
381
+ return not any(
382
+ near[0] <= x <= near[2] and near[1] <= y <= near[3] for x, y in points
383
+ )
384
+
385
+
386
+ def _leader_inside(
387
+ dim: DimensionGeometry, dims: list[DimensionGeometry], outline: Box
388
+ ) -> bool:
389
+ """A leader whose text sits clear inside its own view (see :func:`clear_inside`)."""
390
+ anchor = leader_anchor(dim)
391
+ if classify(dim.lines)[0] is not None or anchor is None:
392
+ return False
393
+ points = [*feature_points(dims, dim.view, outline, skip=dim.key), anchor]
394
+ return clear_inside(dim.text_box, outline, points)
395
+
396
+
397
+ def _inside_candidates(
398
+ dim: DimensionGeometry, outline: Box
399
+ ) -> list[tuple[str, float, float]]:
400
+ """Moves putting a leader's text inside its view, toward the view's centre."""
401
+ anchor = leader_anchor(dim)
402
+ tb = dim.text_box
403
+ w, h = tb[2] - tb[0], tb[3] - tb[1]
404
+ cx, cy = (tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0
405
+ ox, oy = (outline[0] + outline[2]) / 2.0, (outline[1] + outline[3]) / 2.0
406
+ ux, uy = ox - anchor[0], oy - anchor[1]
407
+ length = math.hypot(ux, uy) or 1.0
408
+ ux, uy = ux / length, uy / length
409
+ out = []
410
+ for step in range(2 * MAX_STEPS):
411
+ reach = FIRST_OFFSET + step * LEADER_STEP
412
+ tx = anchor[0] + ux * reach + math.copysign(w / 2.0, ux)
413
+ ty = anchor[1] + uy * reach + math.copysign(h / 2.0, uy)
414
+ out.append(("inside", tx - cx, ty - cy))
415
+ return out
416
+
417
+
418
+ def _leader_to(anchor: tuple[float, float], box: Box) -> Segment:
419
+ """The leader from ``anchor`` to the nearest point of the text ``box``."""
420
+ x = min(max(anchor[0], box[0]), box[2])
421
+ y = min(max(anchor[1], box[1]), box[3])
422
+ return (anchor[0], anchor[1], x, y)
423
+
424
+
425
+ def _projected_segments(
426
+ dim: DimensionGeometry, dx: float, dy: float, outline: Box
427
+ ) -> list[Segment]:
428
+ """Approximate a moved dimension's new dimension and extension lines.
429
+
430
+ The extension lines still start where they did on the part and now reach
431
+ the moved dimension line. A leader stays fixed to the feature it points
432
+ at and runs from there to the moved text; moving it with the text, as
433
+ before, hid a diameter leader drawn across the part and two neighbouring
434
+ dimensions (measured live on SW 2026).
435
+ """
436
+ orient, _span, ends = classify(dim.lines)
437
+ box = shift_box(dim.text_box, dx, dy)
438
+ origins = extension_origins(dim)
439
+ if orient == "h":
440
+ y = (box[1] + box[3]) / 2.0
441
+ return [(ends[0], y, ends[1], y)] + [
442
+ (s[0], o, s[0], y) for s, o in zip(dim.lines, origins, strict=False)
443
+ ]
444
+ if orient == "v":
445
+ x = (box[0] + box[2]) / 2.0
446
+ return [(x, ends[0], x, ends[1])] + [
447
+ (o, s[1], x, s[1]) for s, o in zip(dim.lines, origins, strict=False)
448
+ ]
449
+ anchor = leader_anchor(dim)
450
+ return [] if anchor is None else [_leader_to(anchor, box)]
451
+
452
+
453
+ def find_overlaps(
454
+ dims: list[DimensionGeometry],
455
+ outlines: dict[str, Box],
456
+ sheet: Box | None = None,
457
+ obstacles: dict[str, Box] | None = None,
458
+ ) -> list[dict[str, str]]:
459
+ """List every rule violation among dimensions as they currently sit.
460
+
461
+ Args:
462
+ dims: Dimensions with their real (read-back) geometry.
463
+ outlines: View name -> view outline box.
464
+ sheet: Working area of the frame, when known.
465
+ obstacles: Named boxes nothing may touch (title block, captions, notes).
466
+
467
+ Returns:
468
+ list[dict[str, str]]: ``{"dimension", "conflicts_with", "kind"}`` entries.
469
+ """
470
+ problems: list[dict[str, str]] = []
471
+ for i, dim in enumerate(dims):
472
+ for view, outline in outlines.items():
473
+ if view == dim.view and _leader_inside(dim, dims, outline):
474
+ continue
475
+ if box_overlap(dim.text_box, outline, -TEXT_PADDING / 2):
476
+ problems.append(
477
+ {
478
+ "dimension": dim.key,
479
+ "conflicts_with": view,
480
+ "kind": "on_view_geometry",
481
+ }
482
+ )
483
+ crossed = crosses_other_view(dim.lines, dim.view, outlines)
484
+ if crossed is not None:
485
+ problems.append(
486
+ {
487
+ "dimension": dim.key,
488
+ "conflicts_with": crossed,
489
+ "kind": "line_through_view",
490
+ }
491
+ )
492
+ own = outlines.get(dim.view)
493
+ if own is not None and crosses_own_view(dim, own):
494
+ problems.append(
495
+ {
496
+ "dimension": dim.key,
497
+ "conflicts_with": dim.view,
498
+ "kind": "line_through_own_view",
499
+ }
500
+ )
501
+ for label, obstacle in (obstacles or {}).items():
502
+ if box_overlap(dim.text_box, obstacle, TEXT_PADDING / 2) or any(
503
+ segment_hits_box(s, obstacle) for s in dim.lines
504
+ ):
505
+ problems.append(
506
+ {
507
+ "dimension": dim.key,
508
+ "conflicts_with": label,
509
+ "kind": "on_obstacle",
510
+ }
511
+ )
512
+ if sheet is not None and not inside(dim.text_box, sheet):
513
+ problems.append(
514
+ {
515
+ "dimension": dim.key,
516
+ "conflicts_with": "frame",
517
+ "kind": "outside_working_area",
518
+ }
519
+ )
520
+ for j, other in enumerate(dims):
521
+ if j == i:
522
+ continue
523
+ if j > i and box_overlap(dim.text_box, other.text_box, TEXT_PADDING):
524
+ problems.append(
525
+ {
526
+ "dimension": dim.key,
527
+ "conflicts_with": other.key,
528
+ "kind": "text_on_text",
529
+ }
530
+ )
531
+ if any(segment_hits_box(s, dim.text_box) for s in other.lines):
532
+ problems.append(
533
+ {
534
+ "dimension": dim.key,
535
+ "conflicts_with": other.key,
536
+ "kind": "line_through_text",
537
+ }
538
+ )
539
+ return problems
540
+
541
+
542
+ def _candidates(dim: DimensionGeometry, outline: Box) -> list[tuple[str, float, float]]:
543
+ """Ordered (side, dx, dy) moves to try for one dimension.
544
+
545
+ A linear dimension only goes to the side its extension lines start from;
546
+ on the far side they would cross its own view. Rows and columns are tried
547
+ from the innermost out, so dimensions that do not touch share a row; the
548
+ caller's checks push one outward when its text or lines would collide.
549
+ """
550
+ tb = dim.text_box
551
+ w, h = tb[2] - tb[0], tb[3] - tb[1]
552
+ cx, cy = (tb[0] + tb[2]) / 2.0, (tb[1] + tb[3]) / 2.0
553
+ ox, oy = (outline[0] + outline[2]) / 2.0, (outline[1] + outline[3]) / 2.0
554
+ origins = extension_origins(dim)
555
+ out: list[tuple[str, float, float]] = []
556
+ if dim.orientation == "h":
557
+ sides = list(dict.fromkeys("above" if o >= oy else "below" for o in origins))
558
+ pitch = max(ROW_PITCH, h + STACK_GAP)
559
+ for side in sides:
560
+ for step in range(MAX_STEPS):
561
+ off = FIRST_OFFSET + step * pitch
562
+ target = outline[3] + off if side == "above" else outline[1] - off
563
+ out.append((side, 0.0, target - cy))
564
+ elif dim.orientation == "v":
565
+ sides = list(dict.fromkeys("right" if o >= ox else "left" for o in origins))
566
+ pitch = max(COLUMN_PITCH, w + STACK_GAP)
567
+ for side in sides:
568
+ for step in range(MAX_STEPS):
569
+ off = FIRST_OFFSET + step * pitch
570
+ target = outline[2] + off if side == "right" else outline[0] - off
571
+ out.append((side, target - cx, 0.0))
572
+ else:
573
+ # A leader: step the text outward from the feature it points at,
574
+ # along each of eight directions, away from the view's centre first.
575
+ ax, ay = leader_anchor(dim) or (cx, cy)
576
+ root = 0.5**0.5
577
+ directions = [
578
+ (1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0),
579
+ (root, root), (-root, root), (root, -root), (-root, -root),
580
+ ] # fmt: skip
581
+ directions.sort(key=lambda d: -((ax - ox) * d[0] + (ay - oy) * d[1]))
582
+ for ux, uy in directions:
583
+ for step in range(2 * MAX_STEPS):
584
+ reach = FIRST_OFFSET / 2.0 + step * LEADER_STEP
585
+ tx = ax + ux * reach + math.copysign(w / 2.0, ux or 1.0) * abs(ux)
586
+ ty = ay + uy * reach + math.copysign(h / 2.0, uy or 1.0) * abs(uy)
587
+ out.append((f"leader{ux:+.1f}{uy:+.1f}", tx - cx, ty - cy))
588
+ return out
589
+
590
+
591
+ def plan_layout(
592
+ dims: list[DimensionGeometry],
593
+ outlines: dict[str, Box],
594
+ sheet: Box | None = None,
595
+ obstacles: dict[str, Box] | None = None,
596
+ fixed: set[str] | None = None,
597
+ inside_first: set[str] | None = None,
598
+ ) -> tuple[dict[str, tuple[float, float]], list[str]]:
599
+ """Choose a move for every dimension so the overlap rule holds.
600
+
601
+ Args:
602
+ dims: Dimensions with their current geometry.
603
+ outlines: View name -> view outline.
604
+ sheet: Working area of the frame, when known.
605
+ obstacles: Named boxes nothing may touch.
606
+ fixed: Keys of dimensions that stay where they are: their real text
607
+ and lines are obstacles. Used to re-plan only what a first pass
608
+ left in conflict.
609
+ inside_first: Leader dimensions to try inside their own view first,
610
+ near the feature (a hole note with its GD&T frame), before
611
+ outside it; see :func:`clear_inside`.
612
+
613
+ Returns:
614
+ tuple: ``{key: (dx, dy)}`` moves, and keys that found no free spot
615
+ (left in place and reported by the caller's final check).
616
+ """
617
+ blocked = list((obstacles or {}).values())
618
+ for dim in dims:
619
+ dim.orientation, dim.span, _ = classify(dim.lines)
620
+ keep = fixed or set()
621
+ placed_boxes: list[Box] = []
622
+ placed_lines: list[Segment] = []
623
+ for dim in dims:
624
+ if dim.key in keep:
625
+ placed_boxes.append(dim.text_box)
626
+ placed_lines.extend(dim.lines)
627
+ order = sorted(
628
+ (d for d in dims if d.key not in keep),
629
+ key=lambda d: (d.view, d.orientation or "z", d.span),
630
+ )
631
+ moves: dict[str, tuple[float, float]] = {}
632
+ unplaced: list[str] = []
633
+ for dim in order:
634
+ outline = outlines.get(dim.view)
635
+ if outline is None:
636
+ unplaced.append(dim.key)
637
+ continue
638
+ chosen = None
639
+ candidates = _candidates(dim, outline)
640
+ if dim.key in (inside_first or set()) and dim.orientation is None:
641
+ candidates = _inside_candidates(dim, outline) + candidates
642
+ features = [
643
+ *feature_points(dims, dim.view, outline, skip=dim.key),
644
+ leader_anchor(dim),
645
+ ]
646
+ for side, dx, dy in candidates:
647
+ box = shift_box(dim.text_box, dx, dy)
648
+ others = [
649
+ o for v, o in outlines.items() if side != "inside" or v != dim.view
650
+ ]
651
+ if any(box_overlap(box, o, TEXT_PADDING) for o in others):
652
+ continue
653
+ if side == "inside" and not clear_inside(box, outline, features):
654
+ continue
655
+ if any(box_overlap(box, o, TEXT_PADDING) for o in blocked):
656
+ continue
657
+ if sheet is not None and not inside(box, sheet):
658
+ continue
659
+ if any(box_overlap(box, p, TEXT_PADDING) for p in placed_boxes):
660
+ continue
661
+ if any(segment_hits_box(s, box) for s in placed_lines):
662
+ continue
663
+ segs = _projected_segments(dim, dx, dy, outline)
664
+ if crosses_other_view(segs, dim.view, outlines) is not None:
665
+ continue
666
+ clear = LEADER_CLEARANCE if dim.orientation is None else 0.0
667
+ if any(
668
+ segment_hits_box(s, _grown(p, clear))
669
+ for s in segs
670
+ for p in placed_boxes + blocked
671
+ ):
672
+ continue
673
+ chosen = (side, dx, dy, box, segs)
674
+ break
675
+ if chosen is None:
676
+ unplaced.append(dim.key)
677
+ placed_boxes.append(dim.text_box)
678
+ placed_lines.extend(dim.lines)
679
+ continue
680
+ _side, dx, dy, box, segs = chosen
681
+ moves[dim.key] = (dx, dy)
682
+ placed_boxes.append(box)
683
+ placed_lines.extend(segs)
684
+ return moves, unplaced
685
+
686
+
687
+ def find_annotation_overlaps(
688
+ notes: dict[str, Box],
689
+ dims: list[DimensionGeometry],
690
+ outlines: dict[str, Box],
691
+ sheet: Box | None = None,
692
+ obstacles: dict[str, Box] | None = None,
693
+ ) -> list[dict[str, str]]:
694
+ """Check sheet notes and captions against everything else on the sheet.
695
+
696
+ Args:
697
+ notes: Label -> measured text box of each note or caption.
698
+ dims: Dimensions with their real geometry.
699
+ outlines: View outlines.
700
+ sheet: Working area of the frame.
701
+ obstacles: Named boxes notes may not touch (title block).
702
+
703
+ Returns:
704
+ list[dict[str, str]]: ``{"dimension", "conflicts_with", "kind"}`` entries
705
+ (``dimension`` holds the note label, for one report format).
706
+ """
707
+ problems: list[dict[str, str]] = []
708
+ labels = list(notes)
709
+ for i, label in enumerate(labels):
710
+ box = notes[label]
711
+ for other in labels[i + 1 :]:
712
+ if box_overlap(box, notes[other], TEXT_PADDING / 2):
713
+ problems.append(
714
+ {
715
+ "dimension": label,
716
+ "conflicts_with": other,
717
+ "kind": "note_on_note",
718
+ }
719
+ )
720
+ for view, outline in outlines.items():
721
+ if box_overlap(box, outline, 0.0):
722
+ problems.append(
723
+ {"dimension": label, "conflicts_with": view, "kind": "note_on_view"}
724
+ )
725
+ for name, obstacle in (obstacles or {}).items():
726
+ if box_overlap(box, obstacle, 0.0):
727
+ problems.append(
728
+ {
729
+ "dimension": label,
730
+ "conflicts_with": name,
731
+ "kind": "note_on_obstacle",
732
+ }
733
+ )
734
+ if sheet is not None and not inside(box, sheet):
735
+ problems.append(
736
+ {
737
+ "dimension": label,
738
+ "conflicts_with": "frame",
739
+ "kind": "note_outside_working_area",
740
+ }
741
+ )
742
+ for dim in dims:
743
+ if box_overlap(box, dim.text_box, TEXT_PADDING / 2) or any(
744
+ segment_hits_box(s, box) for s in dim.lines
745
+ ):
746
+ problems.append(
747
+ {
748
+ "dimension": label,
749
+ "conflicts_with": dim.key,
750
+ "kind": "note_on_dimension",
751
+ }
752
+ )
753
+ return problems