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,1993 @@
1
+ """Standard A3 drawing generation for the PyWin32 adapter.
2
+
3
+ Implements an A3 ISO drafting standard on SolidWorks:
4
+
5
+ * **Sheet** - A3 landscape 420 x 297 mm with the SolidWorks ``a3 - iso``
6
+ sheet format (zoned border, ISO title block). Working area x 15-405,
7
+ y 15-282 mm; the title block area x 218-412, y 8-72 mm is kept clear.
8
+ * **Title block** - TITLE from the model's ``Description``, DWG NO. from the
9
+ drawing file name (saved as the drawing number), MATERIAL / WEIGHT linked to
10
+ the model's real material and mass, DRAWN by/date, revision and company from
11
+ drawing properties; scale, sheet n of N and size are automatic.
12
+ * **Views** - third-angle front, plan and side view plus an isometric, each in
13
+ its own cell, at the largest standard scale where the part, its dimension
14
+ rows and its caption fit (cells sized from the part, so it fills the sheet).
15
+ Each view gets a bold capital caption with its name, e.g. ``FRONT VIEW``.
16
+ * **Dimensions** - imported from the model, each shown once, laid out in rows
17
+ (first 8 mm out, rows 7 mm, columns 9 mm), trailing zeros removed.
18
+ * **Notes** - a NOTES block in its own cell (material, units, projection, plus
19
+ caller lines).
20
+ * **Title-block fields fit their boxes** - the DWG NO. (the drawing's file
21
+ name) and TITLE notes of the sheet format are shrunk, down to 2 mm, until
22
+ they fill at most 90% of the room before the next field in their row; one
23
+ that still runs over is an overlap. ``title_block_text_height_mm`` caps
24
+ their height for smaller text.
25
+ * **No overlap anywhere** - checked against the real read-back geometry.
26
+ The scale is chosen once to fit the sheet and is never stepped down;
27
+ overlaps are resolved by moving dimensions.
28
+
29
+ The drawing is built in separate COM steps (sheet, views, layout,
30
+ dimensions, notes, check), each through ``_handle_com_operation`` so each
31
+ gets its settle pause: one long burst of drawing calls is the pattern that
32
+ crashed SolidWorks 2026 in its idle-time refresh (COM pitfall #26).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import glob
38
+ import ntpath
39
+ import os
40
+ from dataclasses import dataclass, field
41
+ from datetime import date as _date
42
+ from typing import TYPE_CHECKING, Any, cast
43
+
44
+ from ..base import AdapterResult, AdapterResultStatus
45
+ from . import dimension_layout as _layout
46
+ from .features import _null_callout
47
+ from .io import _as_com, _resolve_drawing_view, _sw_type_info, _view_names
48
+
49
+ if TYPE_CHECKING:
50
+ from collections.abc import Callable
51
+
52
+ Box = tuple[float, float, float, float]
53
+
54
+ #: Standard scales from the drawing standard, largest first, as (num, den).
55
+ STANDARD_SCALES: list[tuple[float, float]] = [
56
+ (5, 1), (4, 1), (3, 1), (2.5, 1), (2, 1), (1.5, 1), (1, 1),
57
+ (1, 2), (1, 2.5), (1, 4), (1, 5), (1, 7.5), (1, 10), (1, 12.5),
58
+ (1, 15), (1, 20), (1, 25), (1, 30), (1, 40), (1, 50), (1, 60),
59
+ (1, 75), (1, 100),
60
+ ] # fmt: skip
61
+
62
+ SHEET_SIZE = (0.420, 0.297)
63
+ WORK_AREA: Box = (0.015, 0.015, 0.405, 0.282)
64
+ TITLE_BLOCK: Box = (0.218, 0.008, 0.412, 0.072)
65
+ #: Cells (x0, y0, x1, y1) in metres. Third-angle: plan above front, side right.
66
+ CELLS: dict[str, Box] = {
67
+ "front": (0.015, 0.075, 0.215, 0.178),
68
+ "side": (0.215, 0.075, 0.405, 0.178),
69
+ "plan": (0.015, 0.178, 0.215, 0.282),
70
+ "iso": (0.215, 0.178, 0.405, 0.282),
71
+ "notes": (0.015, 0.015, 0.215, 0.075),
72
+ }
73
+ CAPTIONS = {
74
+ "front": "FRONT VIEW",
75
+ "plan": "TOP VIEW",
76
+ "side": "SIDE VIEW",
77
+ "iso": "ISOMETRIC VIEW",
78
+ }
79
+ #: Caption strip at the bottom of every view cell.
80
+ CAPTION_STRIP = 0.009
81
+ #: A BOM placed by the generator sits 5 mm inside its cell's top-right corner,
82
+ #: and the isometric below it keeps 5 mm clear of it.
83
+ BOM_GAP = 0.005
84
+ CAPTION_HEIGHT = 0.0032
85
+ NOTE_HEIGHT = 0.003
86
+
87
+ # swUserPreferenceToggle_e / IntegerValue_e / StringValue_e (swconst.tlb, SW 2025)
88
+ _SW_SHOW_SHEET_FORMAT_DIALOG = 353
89
+ _SW_DETAILING_DIM_TRAILING_ZERO = 15
90
+ _SW_DIM_REMOVE_TRAILING_ZEROES = 2
91
+ _SW_PREF_SHEET_FORMAT_FOLDER = 7
92
+ # swDwgPaperSizes_e / swDwgTemplates_e
93
+ _SW_PAPER_A3 = 8
94
+ _SW_TEMPLATE_CUSTOM = 12
95
+ # swUserPreferenceStringValue_e / swBomType_e / swBOMConfigurationAnchorType_e /
96
+ #: swDisplayMode_e.swHIDDEN_GREYED: "Hidden Lines Visible", drawn dashed on a
97
+ #: drawing (read from swconst.tlb, SW 2026).
98
+ _SW_HIDDEN_LINES_VISIBLE = 1
99
+
100
+ # swNumberingType_e (used by insert_bom_table)
101
+ _SW_PREF_BOM_TEMPLATES = 22
102
+ _SW_BOM_TYPES = {"parts_only": 1, "top_level_only": 2, "indented": 3}
103
+ # swBOMConfigurationAnchorType_e: the table corner that stays put
104
+ _SW_BOM_ANCHOR_TOP_LEFT = 1
105
+ _SW_BOM_ANCHOR_TOP_RIGHT = 2
106
+ _SW_BOM_ANCHOR_BOTTOM_LEFT = 3
107
+ _SW_BOM_ANCHOR_BOTTOM_RIGHT = 4
108
+ _SW_NUMBERING_NONE = 0
109
+ #: GD&T characteristic -> SolidWorks GTOL symbol code (IGtol::SetFrameSymbols2).
110
+ _GTOL_SYMBOLS = {
111
+ "position": "<IGTOL-POSI>",
112
+ "flatness": "<IGTOL-FLAT>",
113
+ "perpendicularity": "<IGTOL-PERP>",
114
+ "parallelism": "<IGTOL-PARA>",
115
+ "profile_surface": "<IGTOL-SPROF>",
116
+ "concentricity": "<IGTOL-CONC>",
117
+ }
118
+ #: Material condition -> modifier symbol code.
119
+ _GTOL_MODIFIERS = {"none": "", "mmc": "<MOD-MMC>", "lmc": "<MOD-LMC>"}
120
+
121
+
122
+ def gtol_problem(
123
+ characteristic: str,
124
+ tolerance: float,
125
+ material_condition: str,
126
+ datums: list[str] | None,
127
+ ) -> str | None:
128
+ """Why a feature control frame's inputs are invalid, or ``None`` when valid.
129
+
130
+ Shared by the real and mock adapters so both refuse the same inputs.
131
+
132
+ Args:
133
+ characteristic: The GD&T characteristic name.
134
+ tolerance: The tolerance value.
135
+ material_condition: ``none``, ``mmc`` or ``lmc``.
136
+ datums: Datum letters, primary first.
137
+
138
+ Returns:
139
+ str | None: The problem, in words.
140
+ """
141
+ letters = [d.strip() for d in datums or []]
142
+ if characteristic not in _GTOL_SYMBOLS:
143
+ return f"characteristic must be one of {', '.join(_GTOL_SYMBOLS)}"
144
+ if material_condition not in _GTOL_MODIFIERS:
145
+ return f"material_condition must be one of {', '.join(_GTOL_MODIFIERS)}"
146
+ if tolerance <= 0:
147
+ return "tolerance must be positive"
148
+ if len(letters) > 3 or any(len(d) != 1 or not d.isalpha() for d in letters):
149
+ return "at most three datums, each a single letter"
150
+ return None
151
+
152
+
153
+ def _double_array(values: list[float]) -> Any:
154
+ """``VARIANT(VT_ARRAY | VT_R8)`` for array-valued COM properties (tuple when pywin32 is absent)."""
155
+ try:
156
+ import pythoncom
157
+ import win32com.client as w32
158
+
159
+ return w32.VARIANT(
160
+ pythoncom.VT_ARRAY | pythoncom.VT_R8, [float(v) for v in values]
161
+ )
162
+ except ImportError: # non-Windows
163
+ return tuple(values)
164
+
165
+
166
+ def _grow(box: tuple[float, ...], by: float) -> Box:
167
+ """``box`` grown by ``by`` on every side."""
168
+ return (box[0] - by, box[1] - by, box[2] + by, box[3] + by)
169
+
170
+
171
+ def scale_label(scale: tuple[float, float]) -> str:
172
+ """``(1, 2)`` -> ``"1:2"``, ``(2.5, 1)`` -> ``"2.5:1"``."""
173
+ return f"{scale[0]:g}:{scale[1]:g}"
174
+
175
+
176
+ def content_box(cell: Box) -> Box:
177
+ """The part of a view cell left after the caption strip."""
178
+ return (cell[0], cell[1] + CAPTION_STRIP, cell[2], cell[3])
179
+
180
+
181
+ #: Area for the four view cells: full width above the notes and title block.
182
+ VIEW_ZONE: Box = (0.015, 0.075, 0.405, 0.282)
183
+ #: Room for dimensions each side of a view when sizing cells: the first row
184
+ #: (8 mm) plus one more row/column. The scale is chosen once from this fit and
185
+ #: never stepped down; the real overlap check reports anything that remains.
186
+ FIT_H_MARGIN = _layout.FIRST_OFFSET + _layout.COLUMN_PITCH
187
+ FIT_V_MARGIN = _layout.FIRST_OFFSET + _layout.ROW_PITCH
188
+ ISO_MARGIN = 0.006
189
+
190
+
191
+ def plan_cells(
192
+ sizes: dict[str, tuple[float, float]], scale: tuple[float, float]
193
+ ) -> dict[str, Box] | None:
194
+ """Size the four view cells for one part at one scale, or None if it won't fit.
195
+
196
+ Third-angle grid: front bottom-left, side to its right, plan above the
197
+ front, isometric top-right. Column widths follow the part's width and
198
+ depth, row heights its height and depth; spare space is shared out evenly
199
+ so every view is centred with room around it.
200
+
201
+ Args:
202
+ sizes: ``front`` (width, height), ``plan`` (width, depth) and/or
203
+ ``side`` (depth, height), in model metres.
204
+ scale: Sheet scale (num, den).
205
+ """
206
+ f = scale[0] / scale[1]
207
+ front = sizes.get("front")
208
+ if front is None:
209
+ return None
210
+ width, height = front
211
+ depth = sizes["side"][0] if "side" in sizes else sizes.get("plan", (0.0, 0.0))[1]
212
+ left = width * f + 2 * FIT_H_MARGIN
213
+ right = depth * f + 2 * FIT_H_MARGIN
214
+ low = height * f + 2 * FIT_V_MARGIN + CAPTION_STRIP
215
+ high = depth * f + 2 * FIT_V_MARGIN + CAPTION_STRIP
216
+ zx0, zy0, zx1, zy1 = VIEW_ZONE
217
+ if left + right > zx1 - zx0 or low + high > zy1 - zy0:
218
+ return None
219
+ xm = zx0 + left + (zx1 - zx0 - left - right) / 2
220
+ ym = zy0 + low + (zy1 - zy0 - low - high) / 2
221
+ return {
222
+ "front": (zx0, zy0, xm, ym),
223
+ "side": (xm, zy0, zx1, ym),
224
+ "plan": (zx0, ym, xm, zy1),
225
+ "iso": (xm, ym, zx1, zy1),
226
+ "notes": CELLS["notes"],
227
+ }
228
+
229
+
230
+ def choose_layout(
231
+ sizes: dict[str, tuple[float, float]],
232
+ ) -> tuple[tuple[float, float], dict[str, Box]]:
233
+ """Largest standard scale whose cells fit, with those cells.
234
+
235
+ Args:
236
+ sizes: Model-space view sizes, as for :func:`plan_cells`.
237
+
238
+ Returns:
239
+ tuple: The scale (num, den) and the cells; the smallest standard scale
240
+ (with default cells if even that does not fit) as a last resort.
241
+ """
242
+ for scale in STANDARD_SCALES:
243
+ cells = plan_cells(sizes, scale)
244
+ if cells is not None:
245
+ return scale, cells
246
+ last = STANDARD_SCALES[-1]
247
+ return last, plan_cells(sizes, last) or dict(CELLS)
248
+
249
+
250
+ def iso_fit_scale(size: tuple[float, float], cell: Box) -> tuple[float, float]:
251
+ """Largest standard scale at which the isometric view fills its cell."""
252
+ area = content_box(cell)
253
+ for scale in STANDARD_SCALES:
254
+ f = scale[0] / scale[1]
255
+ if (
256
+ size[0] * f + 2 * ISO_MARGIN <= area[2] - area[0]
257
+ and size[1] * f + 2 * ISO_MARGIN <= area[3] - area[1]
258
+ ):
259
+ return scale
260
+ return STANDARD_SCALES[-1]
261
+
262
+
263
+ def title_block_properties(
264
+ *,
265
+ model_file: str,
266
+ title: str,
267
+ author: str,
268
+ drawn_date: str,
269
+ revision: str,
270
+ company: str,
271
+ subtitle: str | None = None,
272
+ ) -> tuple[dict[str, str], dict[str, str]]:
273
+ """Map the standard's title-block fields onto the ISO format's properties.
274
+
275
+ The ``a3 - iso`` format reads TITLE, MATERIAL and WEIGHT from the model
276
+ (``$PRPSHEET``) and DRAWN, REVISION and the company from the drawing
277
+ (``$PRP``). Material and weight are links, so they always show the model's
278
+ real values.
279
+
280
+ Returns:
281
+ tuple: (model properties, drawing properties).
282
+ """
283
+ # SolidWorks paths are Windows paths, whatever platform this runs on.
284
+ base = ntpath.basename(model_file)
285
+ description = title # keep TITLE short so it fits the box (standard section 1)
286
+ model_props = {
287
+ "Description": description,
288
+ "Material": f'"SW-Material@{base}"',
289
+ "Weight": f'"SW-Mass@{base}"',
290
+ }
291
+ drawing_props = {
292
+ "DrawnBy": author,
293
+ "DrawnDate": drawn_date,
294
+ "Revision": revision,
295
+ "COMPANYNAME": company or (subtitle or ""),
296
+ }
297
+ return model_props, {k: v for k, v in drawing_props.items() if v}
298
+
299
+
300
+ def find_sheet_format(adapter: Any, name: str = "a3 - iso.slddrt") -> str | None:
301
+ """Locate a sheet format file: SolidWorks' configured folder, then ProgramData."""
302
+ app = getattr(adapter, "swApp", None)
303
+ folders: list[str] = []
304
+ if app is not None:
305
+ raw = adapter._attempt(
306
+ lambda: app.GetUserPreferenceStringValue(_SW_PREF_SHEET_FORMAT_FOLDER),
307
+ default="",
308
+ )
309
+ folders.extend(f.strip() for f in str(raw or "").split(";") if f.strip())
310
+ folders.extend(
311
+ sorted(
312
+ glob.glob(r"C:\ProgramData\SOLIDWORKS\SOLIDWORKS *\lang\*\sheetformat"),
313
+ reverse=True,
314
+ )
315
+ )
316
+ for folder in folders:
317
+ candidate = os.path.join(folder, name)
318
+ if os.path.isfile(candidate):
319
+ return candidate
320
+ return None
321
+
322
+
323
+ #: The NOTES block holds at most this many numbered lines.
324
+ MAX_NOTE_LINES = 7
325
+
326
+ #: Title-block fields fitted to their boxes: linked property -> field name.
327
+ #: The ``a3 - iso`` format shows the drawing's file name as the DWG NO. and
328
+ #: the model's ``Description`` as the TITLE (measured live on SW 2026: a
329
+ #: default drawing number ran across the size box at the format's height).
330
+ TITLE_BLOCK_FIELDS = {"SW-File Name": "drawing number", "Description": "title"}
331
+
332
+ #: A fitted field's text is never made smaller than this.
333
+ MIN_FIELD_HEIGHT = 0.002
334
+
335
+ #: Clearance kept between a fitted field and the next field in its row.
336
+ FIELD_GAP = 0.002
337
+
338
+ #: A fitted field fills at most this share of its room, so it is not cramped.
339
+ FIELD_FILL = 0.9
340
+
341
+ #: What to tell a caller whose title-block field does not fit.
342
+ FIELD_HINT = (
343
+ "pass title_block_text_height_mm (e.g. 3) for smaller title-block text, "
344
+ "or a shorter drawing_number or title"
345
+ )
346
+
347
+ #: swTextFormat: the note uses its own format, not the document's.
348
+ _SW_NOTE_OWN_FORMAT = 0
349
+
350
+ #: Standard notes, after the material line.
351
+ STANDARD_NOTES = (
352
+ "ALL DIMENSIONS IN MILLIMETRES",
353
+ "THIRD ANGLE PROJECTION",
354
+ "REMOVE ALL BURRS AND SHARP EDGES",
355
+ )
356
+
357
+ #: Where the isometric view is first dropped, before it is moved to its cell.
358
+ _ISO_DROP_POINT = (0.31, 0.23)
359
+
360
+
361
+ def note_lines(material: str | None, extra: list[str] | None) -> list[str]:
362
+ """The NOTES block: a heading, then at most seven numbered lines.
363
+
364
+ Args:
365
+ material: The part's material, first when known.
366
+ extra: The caller's lines, after the standard ones.
367
+
368
+ Returns:
369
+ list[str]: ``["NOTES", "1. ...", ...]`` in capitals.
370
+ """
371
+ body = [f"MATERIAL: {material.upper()}"] if material else []
372
+ body += list(STANDARD_NOTES)
373
+ body += [line.upper() for line in (extra or []) if line.strip()]
374
+ numbered = [f"{i}. {line}" for i, line in enumerate(body[:MAX_NOTE_LINES], start=1)]
375
+ return ["NOTES", *numbered]
376
+
377
+
378
+ def view_roles(
379
+ views: dict[str, Any], outlines: dict[str, Box | None], orientations: dict[str, str]
380
+ ) -> dict[str, str]:
381
+ """Name each orthographic view's role: ``front``, ``plan`` or ``side``.
382
+
383
+ The front is the view whose orientation says "front" (else the first);
384
+ a view centred above the front is the plan, any other is the side.
385
+
386
+ Args:
387
+ views: View name -> ``IView``, in creation order.
388
+ outlines: View name -> outline, ``None`` when unreadable.
389
+ orientations: View name -> orientation name, e.g. ``*Front``.
390
+
391
+ Returns:
392
+ dict[str, str]: View name -> role; a view whose outline is unknown
393
+ gets no role.
394
+ """
395
+ names = list(views)
396
+ front = next(
397
+ (n for n in names if "front" in orientations.get(n, "").lower()), names[0]
398
+ )
399
+ role = {front: "front"}
400
+ fo = outlines.get(front)
401
+ for name in names:
402
+ o = outlines.get(name)
403
+ if name == front or o is None or fo is None:
404
+ continue
405
+ above = (o[1] + o[3]) / 2 > (fo[1] + fo[3]) / 2 + 1e-4
406
+ role[name] = "plan" if above else "side"
407
+ return role
408
+
409
+
410
+ def field_room(box: Box, others: list[Box], limit: float) -> float:
411
+ """How far right a title-block field may run: to the next note in its row.
412
+
413
+ A note is in the field's row when its vertical centre lies within the
414
+ field's height, and it is to the right when it starts more than 5 mm
415
+ after the field does (the field's own label sits at about its start).
416
+
417
+ Args:
418
+ box: The field's text box.
419
+ others: Every other note's box in the title block.
420
+ limit: The title block's right edge.
421
+
422
+ Returns:
423
+ float: The x the field's text must end before.
424
+ """
425
+ right = limit
426
+ for o in others:
427
+ centre = (o[1] + o[3]) / 2
428
+ if box[1] <= centre <= box[3] and o[0] > box[0] + 0.005:
429
+ right = min(right, o[0] - FIELD_GAP)
430
+ return right
431
+
432
+
433
+ @dataclass
434
+ class _Sheet:
435
+ """What the generator's steps share while they build one drawing.
436
+
437
+ Attributes:
438
+ path: The model being drawn.
439
+ sheet_format: The ``.slddrt`` applied to the sheet.
440
+ include_isometric: Whether to add the isometric view.
441
+ include_bom: Whether to put a BOM above the isometric.
442
+ material: The part's material, for the notes.
443
+ extra_notes: The caller's note lines.
444
+ views: View name -> ``IView``, orthographic first.
445
+ role: View name -> ``front``, ``plan``, ``side`` or ``iso``.
446
+ ortho: The orthographic view names.
447
+ iso_name: The isometric view's name, when one was made.
448
+ scale: The sheet scale.
449
+ cells: Role -> cell on the sheet.
450
+ iso_scale: The isometric view's own scale.
451
+ iso_size: The isometric's model size, for fitting it to a cell.
452
+ bom_box: The BOM table's box on the sheet, once placed.
453
+ captions: View name -> caption text.
454
+ notes: ``(label, IAnnotation)`` for every caption and note line.
455
+ note_lines: The NOTES block's text.
456
+ title_block: Field name -> how it was fitted to its box.
457
+ format_links: The property links the sheet format's notes show.
458
+ field_height: The caller's cap on the fitted fields' text height.
459
+ """
460
+
461
+ path: str
462
+ sheet_format: str
463
+ include_isometric: bool = True
464
+ include_bom: bool = False
465
+ material: str | None = None
466
+ extra_notes: list[str] = field(default_factory=list)
467
+ views: dict[str, Any] = field(default_factory=dict)
468
+ role: dict[str, str] = field(default_factory=dict)
469
+ ortho: list[str] = field(default_factory=list)
470
+ iso_name: str | None = None
471
+ scale: tuple[float, float] = (1, 1)
472
+ cells: dict[str, Box] = field(default_factory=dict)
473
+ iso_scale: tuple[float, float] | None = None
474
+ iso_size: tuple[float, float] | None = None
475
+ bom_box: Box | None = None
476
+ captions: dict[str, str] = field(default_factory=dict)
477
+ notes: list[tuple[str, Any]] = field(default_factory=list)
478
+ note_lines: list[str] = field(default_factory=list)
479
+ title_block: dict[str, dict[str, Any]] = field(default_factory=dict)
480
+ format_links: list[str] = field(default_factory=list)
481
+ field_height: float | None = None
482
+
483
+
484
+ def _step(steps: list[dict[str, Any]], name: str, result: AdapterResult[Any]) -> None:
485
+ """Record one step's outcome in the generator's ``steps`` list."""
486
+ steps.append(
487
+ {
488
+ "step": name,
489
+ "status": "success" if result.is_success else "error",
490
+ "error": result.error,
491
+ }
492
+ )
493
+
494
+
495
+ class SolidWorksDrawingStandardMixin:
496
+ """Create complete standard A3 drawings, BOM tables and the sheet scale."""
497
+
498
+ if TYPE_CHECKING:
499
+ currentModel: Any
500
+ swApp: Any
501
+
502
+ def _handle_com_operation(
503
+ self, operation_name: str, operation: Callable[..., Any], *args: Any
504
+ ) -> AdapterResult[Any]: ...
505
+
506
+ def _attempt(
507
+ self, operation: Callable[[], Any], default: Any = None
508
+ ) -> Any: ...
509
+
510
+ def _require_drawing(self) -> AdapterResult[Any] | None: ...
511
+
512
+ def _get_attr_or_call(self, obj: Any, attr_name: str) -> Any: ...
513
+
514
+ def _dimension_annotations(self, view: Any) -> list[tuple[Any, str | None]]: ...
515
+
516
+ def _select_display_dimension(self, model: Any, an: Any) -> bool: ...
517
+
518
+ def _sheet_bounds(self, drawing: Any) -> Any: ...
519
+
520
+ def _dimension_geometry(self, an: Any, key: str, view_name: str) -> Any: ...
521
+
522
+ def _import_dimensions(
523
+ self,
524
+ view_name: str | None = None,
525
+ include_hole_wizard: bool = True,
526
+ only_views: list[str] | None = None,
527
+ ) -> dict[str, Any]: ...
528
+
529
+ def _arrange_layout(
530
+ self,
531
+ view_name: str | None = None,
532
+ obstacles: dict[str, Any] | None = None,
533
+ work_area: Any = None,
534
+ move: bool = True,
535
+ attached: dict[str, Any] | None = None,
536
+ ) -> dict[str, Any]: ...
537
+
538
+ def _collect_layout(
539
+ self, drawing: Any, names: list[str]
540
+ ) -> tuple[dict, list, dict]: ...
541
+
542
+ async def open_model(self, file_path: str) -> AdapterResult[Any]: ...
543
+
544
+ async def get_material(
545
+ self, configuration: str | None = None
546
+ ) -> AdapterResult[Any]: ...
547
+
548
+ async def set_custom_property(
549
+ self, name: str, value: str, configuration: str | None = None
550
+ ) -> AdapterResult[Any]: ...
551
+
552
+ async def save_file(
553
+ self, file_path: str | None = None
554
+ ) -> AdapterResult[Any]: ...
555
+
556
+ async def create_drawing(
557
+ self, name: str | None = None, template: str | None = None
558
+ ) -> AdapterResult[Any]: ...
559
+
560
+ async def set_units(self, unit_system: str) -> AdapterResult[Any]: ...
561
+
562
+ async def export_file(
563
+ self, file_path: str, format_type: str
564
+ ) -> AdapterResult[Any]: ...
565
+
566
+ # -- BOM and sheet scale -------------------------------------------
567
+
568
+ def _table_box(self, table: Any) -> Box | None:
569
+ """A table's box on the sheet, from its stationary corner and cell sizes.
570
+
571
+ ``IAnnotation::GetPosition`` gives the stationary corner named by
572
+ ``ITableAnnotation::AnchorType`` (measured live on SW 2026); the table
573
+ spans the sum of its column widths and row heights from there.
574
+
575
+ Args:
576
+ table: The ``ITableAnnotation``.
577
+
578
+ Returns:
579
+ Box | None: ``(x0, y0, x1, y1)`` in sheet metres, or ``None`` when
580
+ the table cannot be measured.
581
+ """
582
+ t = _sw_type_info.flagged(table, "ITableAnnotation")
583
+ an = self._attempt(
584
+ lambda: _sw_type_info.flagged(t.GetAnnotation(), "IAnnotation"),
585
+ default=None,
586
+ )
587
+ pos = self._attempt(lambda: an.GetPosition(), default=None)
588
+ rows = int(self._attempt(lambda: t.RowCount, default=0) or 0)
589
+ cols = int(self._attempt(lambda: t.ColumnCount, default=0) or 0)
590
+ if not isinstance(pos, (list, tuple)) or len(pos) < 2 or not rows or not cols:
591
+ return None
592
+ height = sum(
593
+ float(self._attempt(lambda i=i: t.GetRowHeight(i), default=0.0) or 0.0)
594
+ for i in range(rows)
595
+ )
596
+ width = sum(
597
+ float(self._attempt(lambda i=i: t.GetColumnWidth(i), default=0.0) or 0.0)
598
+ for i in range(cols)
599
+ )
600
+ anchor = self._attempt(lambda: t.AnchorType, default=None)
601
+ x, y = float(pos[0]), float(pos[1])
602
+ right_anchored = anchor in (
603
+ _SW_BOM_ANCHOR_TOP_RIGHT,
604
+ _SW_BOM_ANCHOR_BOTTOM_RIGHT,
605
+ )
606
+ top_anchored = anchor in (_SW_BOM_ANCHOR_TOP_LEFT, _SW_BOM_ANCHOR_TOP_RIGHT)
607
+ left = x - width if right_anchored else x
608
+ bottom = y - height if top_anchored else y
609
+ return (left, bottom, left + width, bottom + height)
610
+
611
+ def _slide_onto_sheet(self, table: Any, box: Box, sheet: Box) -> bool:
612
+ """Move a table by the least distance that puts its box on the sheet.
613
+
614
+ The ISO A3 format's BOM anchor is at the top of the sheet, and a table
615
+ grown up from it there runs off the top (measured live on SW 2026).
616
+
617
+ Args:
618
+ table: The ``ITableAnnotation``.
619
+ box: Its box on the sheet, in metres.
620
+ sheet: The sheet's box, in metres.
621
+
622
+ Returns:
623
+ bool: Whether the table was moved. ``False`` when it is larger
624
+ than the sheet or its position cannot be read.
625
+ """
626
+ if (
627
+ box[2] - box[0] > sheet[2] - sheet[0]
628
+ or box[3] - box[1] > sheet[3] - sheet[1]
629
+ ):
630
+ return False
631
+ dx = max(sheet[0] - box[0], 0.0) + min(sheet[2] - box[2], 0.0)
632
+ dy = max(sheet[1] - box[1], 0.0) + min(sheet[3] - box[3], 0.0)
633
+ t = _sw_type_info.flagged(table, "ITableAnnotation")
634
+ an = self._attempt(
635
+ lambda: _sw_type_info.flagged(t.GetAnnotation(), "IAnnotation"),
636
+ default=None,
637
+ )
638
+ pos = self._attempt(lambda: an.GetPosition(), default=None)
639
+ if not isinstance(pos, (list, tuple)) or len(pos) < 2:
640
+ return False
641
+ x, y = float(pos[0]) + dx, float(pos[1]) + dy
642
+ self._attempt(lambda: an.SetPosition(x, y, 0.0), default=None)
643
+ return True
644
+
645
+ async def insert_bom_table(
646
+ self,
647
+ view_name: str,
648
+ template_path: str | None = None,
649
+ bom_type: str = "top_level_only",
650
+ x_mm: float | None = None,
651
+ y_mm: float | None = None,
652
+ ) -> AdapterResult[dict[str, Any]]:
653
+ """Insert a bill-of-materials table anchored to an assembly drawing view.
654
+
655
+ Wraps ``IView::InsertBomTable4``. With no position the table snaps to
656
+ the sheet format's BOM anchor, or to the sheet's bottom-left corner
657
+ when the sheet has no format. Its stationary corner is the
658
+ bottom-left, so the table grows up onto the sheet; with the top-left
659
+ it hung below the sheet (measured live on SW 2026). The table's box is
660
+ read back and must lie on the sheet. An anchor near the top of the
661
+ sheet (the ISO A3 format's) makes it grow off the top, so a table
662
+ placed by the anchor is slid back onto the sheet; one placed at a
663
+ given ``x_mm``/``y_mm`` is never moved. With no template the library
664
+ default ``bom-standard.sldbomtbt`` from the BOM templates folder is
665
+ used.
666
+
667
+ Args:
668
+ view_name: A view of an assembly on the active drawing.
669
+ template_path: ``.sldbomtbt`` table template.
670
+ bom_type: ``top_level_only``, ``parts_only`` or ``indented``.
671
+ x_mm: Optional table position on the sheet.
672
+ y_mm: Optional table position on the sheet.
673
+
674
+ Returns:
675
+ AdapterResult[dict[str, Any]]: The view, BOM type, template, row
676
+ count, ``table_box_mm`` and ``moved_onto_sheet``; an error naming
677
+ the box when the table still runs off the sheet.
678
+ """
679
+ guard = self._require_drawing()
680
+ if guard is not None:
681
+ return cast("AdapterResult[dict[str, Any]]", guard)
682
+ if bom_type not in _SW_BOM_TYPES:
683
+ return AdapterResult(
684
+ status=AdapterResultStatus.ERROR,
685
+ error="bom_type must be top_level_only, parts_only or indented",
686
+ )
687
+
688
+ return cast(
689
+ "AdapterResult[dict[str, Any]]",
690
+ self._handle_com_operation(
691
+ "insert_bom_table",
692
+ lambda: self._insert_bom(
693
+ view_name, template_path, bom_type, x_mm, y_mm
694
+ )[1],
695
+ ),
696
+ )
697
+
698
+ def _insert_bom(
699
+ self,
700
+ view_name: str,
701
+ template_path: str | None,
702
+ bom_type: str,
703
+ x_mm: float | None,
704
+ y_mm: float | None,
705
+ ) -> tuple[Any, dict[str, Any]]:
706
+ """Insert a BOM table on a view; insert_bom_table's body.
707
+
708
+ Returns:
709
+ tuple: The ``ITableAnnotation`` and what insert_bom_table reports.
710
+
711
+ Raises:
712
+ LookupError: No such view.
713
+ RuntimeError: The table runs off the sheet.
714
+ """
715
+ drawing = _sw_type_info.flagged(self.currentModel, "IDrawingDoc")
716
+ view = _resolve_drawing_view(self, drawing, view_name)
717
+ if view is None:
718
+ raise LookupError(
719
+ f"No view named {view_name!r}. Views: "
720
+ f"{', '.join(_view_names(self, drawing)) or 'none'}"
721
+ )
722
+ template = template_path
723
+ if not template:
724
+ folder = str(
725
+ self._attempt(
726
+ lambda: self.swApp.GetUserPreferenceStringValue(
727
+ _SW_PREF_BOM_TEMPLATES
728
+ ),
729
+ default="",
730
+ )
731
+ or ""
732
+ ).split(";")[0]
733
+ candidate = os.path.join(folder, "bom-standard.sldbomtbt")
734
+ template = candidate if os.path.exists(candidate) else ""
735
+ use_anchor = x_mm is None or y_mm is None
736
+ config = str(
737
+ self._attempt(lambda: view.ReferencedConfiguration, default="") or ""
738
+ )
739
+ numbering = 1 if bom_type == "indented" else _SW_NUMBERING_NONE
740
+ table = view.InsertBomTable4(
741
+ use_anchor,
742
+ (x_mm or 0.0) / 1000.0,
743
+ (y_mm or 0.0) / 1000.0,
744
+ _SW_BOM_ANCHOR_BOTTOM_LEFT,
745
+ _SW_BOM_TYPES[bom_type],
746
+ config,
747
+ template,
748
+ False,
749
+ numbering,
750
+ False,
751
+ )
752
+ if table is None:
753
+ raise Exception(
754
+ f"InsertBomTable4 returned nothing for view {view_name!r}. "
755
+ "A BOM needs a view of an assembly"
756
+ + ("" if template else " and a BOM template (none found)")
757
+ + "."
758
+ )
759
+ rows = self._attempt(
760
+ lambda: _sw_type_info.flagged(table, "ITableAnnotation").RowCount,
761
+ default=None,
762
+ )
763
+ box = self._table_box(table)
764
+ sheet = self._sheet_bounds(drawing)
765
+ on_sheet = _grow(sheet, 1e-6) if sheet else None
766
+ moved = False
767
+ if (
768
+ use_anchor
769
+ and box
770
+ and on_sheet
771
+ and not _layout.inside(box, on_sheet)
772
+ and self._slide_onto_sheet(table, box, sheet)
773
+ ):
774
+ moved = True
775
+ box = self._table_box(table)
776
+ box_mm = [round(c * 1000, 1) for c in box] if box else None
777
+ if box and on_sheet and not _layout.inside(box, on_sheet):
778
+ raise RuntimeError(
779
+ f"The BOM table was inserted but runs off the sheet: table "
780
+ f"{box_mm} mm, sheet {sheet[2] * 1000:g} x {sheet[3] * 1000:g} "
781
+ "mm. Give x_mm and y_mm to place it on the sheet."
782
+ )
783
+ return table, {
784
+ "view": view_name,
785
+ "bom_type": bom_type,
786
+ "template": template or None,
787
+ "row_count": rows,
788
+ "table_box_mm": box_mm,
789
+ "moved_onto_sheet": moved,
790
+ }
791
+
792
+ async def set_sheet_scale(
793
+ self, numerator: float, denominator: float
794
+ ) -> AdapterResult[dict[str, Any]]:
795
+ """Set the active drawing sheet's scale, e.g. 1:2 (``ISheet::SetScale``).
796
+
797
+ Args:
798
+ numerator: Scale numerator.
799
+ denominator: Scale denominator.
800
+
801
+ Returns:
802
+ AdapterResult[dict[str, Any]]: The sheet name, the ``scale`` read
803
+ back from the sheet and the ``requested`` one.
804
+ """
805
+ guard = self._require_drawing()
806
+ if guard is not None:
807
+ return cast("AdapterResult[dict[str, Any]]", guard)
808
+ if numerator <= 0 or denominator <= 0:
809
+ return AdapterResult(
810
+ status=AdapterResultStatus.ERROR, error="Scale values must be positive"
811
+ )
812
+
813
+ def _scale() -> dict[str, Any]:
814
+ drawing = _sw_type_info.flagged(self.currentModel, "IDrawingDoc")
815
+ sheet = _sw_type_info.flagged(drawing.GetCurrentSheet(), "ISheet")
816
+ if not sheet.SetScale(float(numerator), float(denominator), True, False):
817
+ raise Exception("ISheet.SetScale returned False")
818
+ self._attempt(lambda: self.currentModel.EditRebuild3(), default=None)
819
+ name = self._attempt(lambda: sheet.GetName(), default=None)
820
+ # GetProperties2: paper size, template, scale num, scale den, ...
821
+ props = self._attempt(lambda: sheet.GetProperties2(), default=None)
822
+ if not (isinstance(props, (list, tuple)) and len(props) >= 4):
823
+ raise Exception("The sheet's scale could not be read back")
824
+ return {
825
+ "sheet": name,
826
+ "scale": f"{float(props[2]):g}:{float(props[3]):g}",
827
+ "requested": f"{numerator:g}:{denominator:g}",
828
+ }
829
+
830
+ return cast(
831
+ "AdapterResult[dict[str, Any]]",
832
+ self._handle_com_operation("set_sheet_scale", _scale),
833
+ )
834
+
835
+ # -- GD&T ------------------------------------------------------------
836
+
837
+ def _select_edge_at(self, point_mm: tuple[float, float]) -> bool:
838
+ """Select the drawing edge nearest a sheet point, given in mm."""
839
+ model = self.currentModel
840
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
841
+ x, y = point_mm[0] / 1000.0, point_mm[1] / 1000.0
842
+ return bool(
843
+ self._attempt(
844
+ lambda: model.Extension.SelectByID2(
845
+ "", "EDGE", x, y, 0.0, False, 0, _null_callout(), 0
846
+ ),
847
+ default=False,
848
+ )
849
+ )
850
+
851
+ def _place_annotation(self, item: Any, x_mm: float, y_mm: float) -> None:
852
+ """Move an annotation-owning object (frame, datum tag) to a sheet point."""
853
+ an = self._attempt(lambda: self._get_attr_or_call(item, "GetAnnotation"))
854
+ if an is not None:
855
+ an = _sw_type_info.flagged(an, "IAnnotation")
856
+ self._attempt(
857
+ lambda: an.SetPosition(x_mm / 1000.0, y_mm / 1000.0, 0.0),
858
+ default=None,
859
+ )
860
+
861
+ async def add_gtol(
862
+ self,
863
+ characteristic: str,
864
+ tolerance: float,
865
+ x_mm: float,
866
+ y_mm: float,
867
+ diameter_zone: bool = False,
868
+ material_condition: str = "none",
869
+ datums: list[str] | None = None,
870
+ attach_at_mm: tuple[float, float] | None = None,
871
+ dimension: tuple[str, str] | None = None,
872
+ ) -> AdapterResult[dict[str, Any]]:
873
+ """Add a feature control frame (GD&T) to the active drawing.
874
+
875
+ ``IModelDoc2::InsertGtol`` creates the frame on whatever is selected:
876
+ a drawing dimension (``dimension``; the frame sits under its text and
877
+ moves with it, the usual place for a hole's position tolerance), the
878
+ edge picked at ``attach_at_mm`` (with a leader), or nothing
879
+ (free-standing);
880
+ ``IGtol::SetFrameSymbols2`` sets the characteristic, the Ø zone and
881
+ the material condition, and ``IGtol::SetFrameValues`` the tolerance
882
+ and datums. The values are read back with ``GetFrameValues``, so a
883
+ frame SolidWorks did not fill in is an error, not a success.
884
+
885
+ Args:
886
+ characteristic: ``position``, ``flatness``, ``perpendicularity``,
887
+ ``parallelism``, ``profile_surface`` or ``concentricity``.
888
+ tolerance: The tolerance value, in drawing units.
889
+ x_mm: Where the frame goes on the sheet.
890
+ y_mm: Where the frame goes on the sheet.
891
+ diameter_zone: Prefix the tolerance with Ø (a cylindrical zone).
892
+ material_condition: ``none``, ``mmc`` (Ⓜ) or ``lmc`` (Ⓛ).
893
+ datums: Up to three datum letters, primary first.
894
+ attach_at_mm: A sheet point on the edge the leader points to.
895
+ dimension: ``(view name, model dimension name)``, e.g.
896
+ ``("Drawing View2", "D3@Sketch1")``: attach the frame to that
897
+ drawing dimension. ``x_mm``/``y_mm`` are then not used.
898
+
899
+ Returns:
900
+ AdapterResult[dict[str, Any]]: The frame as read back.
901
+ """
902
+ guard = self._require_drawing()
903
+ if guard is not None:
904
+ return cast("AdapterResult[dict[str, Any]]", guard)
905
+ problem = gtol_problem(characteristic, tolerance, material_condition, datums)
906
+ if problem is not None:
907
+ return AdapterResult(status=AdapterResultStatus.ERROR, error=problem)
908
+ symbol = _GTOL_SYMBOLS[characteristic]
909
+ modifier = _GTOL_MODIFIERS[material_condition]
910
+ letters = [d.strip().upper() for d in datums or []]
911
+
912
+ def _insert() -> dict[str, Any]:
913
+ model = self.currentModel
914
+ target = None
915
+ if dimension is not None:
916
+ target = self._drawing_dimension(*dimension)
917
+ if target is None or not self._select_display_dimension(model, target):
918
+ raise LookupError(
919
+ f"No dimension {dimension[1]!r} in view {dimension[0]!r}"
920
+ )
921
+ attached: Any = dimension[1]
922
+ elif attach_at_mm is not None:
923
+ if not self._select_edge_at(attach_at_mm):
924
+ raise LookupError(
925
+ f"No drawing edge found at {list(attach_at_mm)} mm"
926
+ )
927
+ attached = True
928
+ else:
929
+ attached = False
930
+ gtol = self._get_attr_or_call(model, "InsertGtol")
931
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
932
+ if gtol is None:
933
+ raise Exception("InsertGtol returned nothing; no frame was added")
934
+ gtol = _sw_type_info.flagged(gtol, "IGtol")
935
+ padded = [*letters, "", "", ""][:3]
936
+ text = f"{tolerance:g}"
937
+ gtol.SetFrameSymbols2(
938
+ 1, symbol, diameter_zone, modifier, False, "", "", "", ""
939
+ )
940
+ gtol.SetFrameValues(1, text, "", *padded)
941
+ if dimension is None:
942
+ self._place_annotation(gtol, x_mm, y_mm)
943
+ self._attempt(lambda: model.GraphicsRedraw2(), default=None)
944
+ values = self._attempt(lambda: gtol.GetFrameValues(1), default=None)
945
+ read = [str(v) for v in values] if isinstance(values, (list, tuple)) else []
946
+ if not read or read[0] != text or read[2:5] != padded:
947
+ raise Exception(
948
+ f"The frame did not take its values: read back {read or None}, "
949
+ f"expected tolerance {text!r} and datums {padded}"
950
+ )
951
+ return {
952
+ "characteristic": characteristic,
953
+ "tolerance": text,
954
+ "diameter_zone": diameter_zone,
955
+ "material_condition": material_condition,
956
+ "datums": letters,
957
+ "attached": attached,
958
+ "position_mm": None if dimension is not None else [x_mm, y_mm],
959
+ "layout": (
960
+ None
961
+ if dimension is None
962
+ else self._clear_attached_frame(dimension[0], target, gtol)
963
+ ),
964
+ }
965
+
966
+ result = cast(
967
+ "AdapterResult[dict[str, Any]]",
968
+ self._handle_com_operation("add_gtol", _insert),
969
+ )
970
+ layout = (result.data or {}).get("layout") if result.is_success else None
971
+ if layout and layout["overlaps_after"]:
972
+ return AdapterResult(
973
+ status=AdapterResultStatus.ERROR,
974
+ error=(
975
+ f"The frame was added but {layout['overlaps_after']} overlap(s) "
976
+ "remain on the sheet - the no-overlap rule is not met; see layout"
977
+ ),
978
+ data=result.data,
979
+ execution_time=result.execution_time,
980
+ )
981
+ return result
982
+
983
+ def _drawing_dimension(self, view_name: str, name: str) -> Any:
984
+ """The drawing dimension ``name`` (e.g. ``D3@Sketch1``) in a view, or None.
985
+
986
+ On an assembly drawing a part's dimension can carry its component
987
+ too (``D3@Sketch1@Demo Plate-1@Plate Stack``), so the part's own name
988
+ followed by ``@`` matches as well.
989
+ """
990
+ drawing = _sw_type_info.flagged(self.currentModel, "IDrawingDoc")
991
+ view = _resolve_drawing_view(self, drawing, view_name)
992
+ if view is None:
993
+ return None
994
+ return next(
995
+ (
996
+ an
997
+ for an, n in self._dimension_annotations(view)
998
+ if n is not None and (n == name or n.startswith(f"{name}@"))
999
+ ),
1000
+ None,
1001
+ )
1002
+
1003
+ def _frame_box(self, gtol: Any) -> Box:
1004
+ """A frame's box on the sheet: its texts and its lines, as drawn.
1005
+
1006
+ Raises:
1007
+ RuntimeError: SolidWorks gave no display data for the frame.
1008
+ """
1009
+ an = self._attempt(lambda: self._get_attr_or_call(gtol, "GetAnnotation"))
1010
+ geometry = (
1011
+ None
1012
+ if an is None
1013
+ else self._attempt(
1014
+ lambda: self._dimension_geometry(
1015
+ _sw_type_info.flagged(an, "IAnnotation"), "frame", "sheet"
1016
+ )
1017
+ )
1018
+ )
1019
+ if geometry is None:
1020
+ raise RuntimeError("Cannot measure the frame, so it cannot be kept clear")
1021
+ tb = geometry.text_box
1022
+ xs = [tb[0], tb[2], *(p for seg in geometry.lines for p in (seg[0], seg[2]))]
1023
+ ys = [tb[1], tb[3], *(p for seg in geometry.lines for p in (seg[1], seg[3]))]
1024
+ return (min(xs), min(ys), max(xs), max(ys))
1025
+
1026
+ def _clear_attached_frame(self, view_name: str, an: Any, gtol: Any) -> dict:
1027
+ """Lay the view out again with the frame counted as part of its dimension.
1028
+
1029
+ SolidWorks hangs the frame under the dimension's text; the layout
1030
+ placed that dimension for its text alone, so the frame can land on
1031
+ its neighbours (seen live on SW 2026). The frame moves with the
1032
+ dimension, so its box is taken relative to the dimension's position.
1033
+
1034
+ Args:
1035
+ view_name: The dimension's view.
1036
+ an: The dimension's ``IAnnotation``.
1037
+ gtol: The frame just attached to it.
1038
+
1039
+ Returns:
1040
+ dict: The layout result; ``overlaps_after`` counts the frame.
1041
+
1042
+ Raises:
1043
+ LookupError: The dimension is not among the view's read dimensions.
1044
+ """
1045
+ model = self.currentModel
1046
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1047
+ frame = self._frame_box(gtol)
1048
+ x, y = (float(v) for v in an.GetPosition()[:2])
1049
+ drawing = self._drawing()
1050
+ _, geometries, _ = self._collect_layout(drawing, [view_name])
1051
+ key = next(
1052
+ (
1053
+ g.key
1054
+ for g in geometries
1055
+ if abs(g.position[0] - x) < 1e-6 and abs(g.position[1] - y) < 1e-6
1056
+ ),
1057
+ None,
1058
+ )
1059
+ if key is None:
1060
+ raise LookupError("The frame's dimension is not among the view's")
1061
+ rel = (frame[0] - x, frame[1] - y, frame[2] - x, frame[3] - y)
1062
+ obstacles = {"title block": TITLE_BLOCK, **self._sheet_note_boxes(drawing)}
1063
+ result = self._arrange_layout(
1064
+ view_name, obstacles, WORK_AREA, attached={key: rel}
1065
+ )
1066
+ result["frame_box_mm"] = [round(v * 1000, 1) for v in frame]
1067
+ result["obstacles"] = sorted(obstacles)
1068
+ return result
1069
+
1070
+ async def add_datum_tag(
1071
+ self,
1072
+ label: str,
1073
+ attach_at_mm: tuple[float, float],
1074
+ x_mm: float,
1075
+ y_mm: float,
1076
+ ) -> AdapterResult[dict[str, Any]]:
1077
+ """Add a datum feature symbol (a datum tag) on a drawing edge.
1078
+
1079
+ ``IModelDoc2::InsertDatumTag2`` attaches the tag to the edge picked at
1080
+ ``attach_at_mm``; ``IDatumTag::SetLabel`` names it, and the label is
1081
+ read back with ``GetLabel``.
1082
+
1083
+ Args:
1084
+ label: The datum letter, e.g. ``A``.
1085
+ attach_at_mm: A sheet point on the edge or face line it marks.
1086
+ x_mm: Where the tag goes on the sheet.
1087
+ y_mm: Where the tag goes on the sheet.
1088
+
1089
+ Returns:
1090
+ AdapterResult[dict[str, Any]]: The label as read back.
1091
+ """
1092
+ guard = self._require_drawing()
1093
+ if guard is not None:
1094
+ return cast("AdapterResult[dict[str, Any]]", guard)
1095
+ letter = label.strip().upper()
1096
+ if len(letter) != 1 or not letter.isalpha():
1097
+ return AdapterResult(
1098
+ status=AdapterResultStatus.ERROR,
1099
+ error="label must be a single letter, e.g. 'A'",
1100
+ )
1101
+
1102
+ def _insert() -> dict[str, Any]:
1103
+ model = self.currentModel
1104
+ if not self._select_edge_at(attach_at_mm):
1105
+ raise LookupError(f"No drawing edge found at {list(attach_at_mm)} mm")
1106
+ tag = self._get_attr_or_call(model, "InsertDatumTag2")
1107
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1108
+ if tag is None:
1109
+ raise Exception("InsertDatumTag2 returned nothing; no tag was added")
1110
+ tag = _sw_type_info.flagged(tag, "IDatumTag")
1111
+ tag.SetLabel(letter)
1112
+ self._place_annotation(tag, x_mm, y_mm)
1113
+ read = str(
1114
+ self._attempt(lambda: self._get_attr_or_call(tag, "GetLabel")) or ""
1115
+ )
1116
+ if read != letter:
1117
+ raise Exception(f"The datum tag reads {read!r}, not {letter!r}")
1118
+ return {"label": letter, "position_mm": [x_mm, y_mm]}
1119
+
1120
+ return cast(
1121
+ "AdapterResult[dict[str, Any]]",
1122
+ self._handle_com_operation("add_datum_tag", _insert),
1123
+ )
1124
+
1125
+ # -- small COM helpers ---------------------------------------------
1126
+
1127
+ def _set_view_position(self, view: Any, x: float, y: float) -> bool:
1128
+ """Move a view's centre to (x, y) and confirm by reading it back.
1129
+
1130
+ A plain tuple is mis-marshalled under late binding (x arrives as
1131
+ ~1e-310 and the view stays off the sheet); an explicit
1132
+ ``VARIANT(VT_ARRAY | VT_R8)`` works (pitfall #31, measured live on
1133
+ SW 2025).
1134
+
1135
+ Args:
1136
+ view: The ``IView``.
1137
+ x: Sheet x of the view's centre.
1138
+ y: Sheet y of the view's centre.
1139
+
1140
+ Returns:
1141
+ bool: Whether the view reads back at (x, y).
1142
+ """
1143
+ self._attempt(
1144
+ lambda: setattr(view, "Position", _double_array([x, y])), default=None
1145
+ )
1146
+ pos = self._attempt(lambda: view.Position, default=None)
1147
+ return (
1148
+ isinstance(pos, (list, tuple))
1149
+ and len(pos) >= 2
1150
+ and abs(float(pos[0]) - x) < 1e-4
1151
+ and abs(float(pos[1]) - y) < 1e-4
1152
+ )
1153
+
1154
+ def _use_open_document(self, path: str) -> bool:
1155
+ """Make an already-open copy of ``path`` the active model, if there is one.
1156
+
1157
+ Args:
1158
+ path: The model's absolute path.
1159
+
1160
+ Returns:
1161
+ bool: ``True`` when that exact file was open and is now active.
1162
+ """
1163
+ app = self.swApp
1164
+ doc = self._attempt(lambda: app.GetOpenDocumentByName(path), default=None)
1165
+ if doc is None:
1166
+ return False
1167
+ open_path = str(self._attempt(lambda: doc.GetPathName(), default="") or "")
1168
+ if os.path.normcase(os.path.abspath(open_path)) != os.path.normcase(path):
1169
+ return False
1170
+ title = self._attempt(lambda: doc.GetTitle(), default=None)
1171
+ if title:
1172
+ self._attempt(lambda: app.ActivateDoc3(title, False, 0, 0), default=None)
1173
+ self.currentModel = doc
1174
+ return True
1175
+
1176
+ def _view_outline(self, view: Any) -> Box | None:
1177
+ """A view's outline on the sheet, or ``None`` when unreadable."""
1178
+ raw = self._attempt(lambda: view.GetOutline(), default=None)
1179
+ if isinstance(raw, (list, tuple)) and len(raw) >= 4:
1180
+ return (float(raw[0]), float(raw[1]), float(raw[2]), float(raw[3]))
1181
+ return None
1182
+
1183
+ def _insert_sheet_note(
1184
+ self, text: str, x: float, y: float, height: float, bold: bool
1185
+ ) -> Any:
1186
+ """Insert a sheet note at (x, y) in the given height and weight.
1187
+
1188
+ Args:
1189
+ text: The note.
1190
+ x: Sheet x.
1191
+ y: Sheet y.
1192
+ height: Character height in metres.
1193
+ bold: Bold text.
1194
+
1195
+ Returns:
1196
+ Any: The note's ``IAnnotation``, or ``None`` when it was not made.
1197
+ """
1198
+ model = self.currentModel
1199
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1200
+ note = self._attempt(lambda: model.InsertNote(text), default=None)
1201
+ if note is None:
1202
+ return None
1203
+ an = self._attempt(
1204
+ lambda: _as_com(
1205
+ self, _as_com(self, note, "INote").GetAnnotation(), "IAnnotation"
1206
+ ),
1207
+ default=None,
1208
+ )
1209
+ if an is None:
1210
+ return None
1211
+ tf = self._attempt(lambda: an.GetTextFormat(0), default=None)
1212
+ if tf is not None:
1213
+ tf = _as_com(self, tf, "ITextFormat")
1214
+ self._attempt(lambda: setattr(tf, "CharHeight", height), default=None)
1215
+ self._attempt(lambda: setattr(tf, "Bold", bold), default=None)
1216
+ self._attempt(lambda: an.SetTextFormat(0, False, tf), default=None)
1217
+ self._attempt(lambda: an.SetPosition(x, y, 0.0), default=None)
1218
+ return an
1219
+
1220
+ def _place_note(
1221
+ self,
1222
+ an: Any,
1223
+ text: str,
1224
+ *,
1225
+ left: float | None = None,
1226
+ top: float | None = None,
1227
+ centre_x: float | None = None,
1228
+ bottom: float | None = None,
1229
+ ) -> Box:
1230
+ """Shift a note so its measured box meets the given edges.
1231
+
1232
+ Args:
1233
+ an: The note's ``IAnnotation``.
1234
+ text: Its text, for the fallback size estimate.
1235
+ left: Wanted left edge.
1236
+ top: Wanted top edge.
1237
+ centre_x: Wanted horizontal centre.
1238
+ bottom: Wanted bottom edge.
1239
+
1240
+ Returns:
1241
+ Box: The note's box after the move.
1242
+ """
1243
+ box = self._note_box(an, text, 0.0, 0.0, NOTE_HEIGHT)
1244
+ dx = dy = 0.0
1245
+ if left is not None:
1246
+ dx = left - box[0]
1247
+ if centre_x is not None:
1248
+ dx = centre_x - (box[0] + box[2]) / 2
1249
+ if top is not None:
1250
+ dy = top - box[3]
1251
+ if bottom is not None:
1252
+ dy = bottom - box[1]
1253
+ if abs(dx) > 1e-5 or abs(dy) > 1e-5:
1254
+ pos = self._attempt(lambda: an.GetPosition(), default=None)
1255
+ if isinstance(pos, (list, tuple)) and len(pos) >= 2:
1256
+ self._attempt(
1257
+ lambda: an.SetPosition(float(pos[0]) + dx, float(pos[1]) + dy, 0.0),
1258
+ default=None,
1259
+ )
1260
+ box = self._note_box(an, text, 0.0, 0.0, NOTE_HEIGHT)
1261
+ return box
1262
+
1263
+ def _note_box(
1264
+ self, an: Any, fallback_text: str, x: float, y: float, height: float
1265
+ ) -> Box:
1266
+ """A note's measured box; estimated from its text when unreadable.
1267
+
1268
+ Args:
1269
+ an: The note's ``IAnnotation``.
1270
+ fallback_text: Its text, for the estimate.
1271
+ x: Estimate's left edge.
1272
+ y: Estimate's top edge.
1273
+ height: Estimate's text height.
1274
+
1275
+ Returns:
1276
+ Box: The note's box on the sheet.
1277
+ """
1278
+ geometry = self._attempt(
1279
+ lambda: self._dimension_geometry(an, "note", "sheet"), default=None
1280
+ )
1281
+ if geometry is not None:
1282
+ return geometry.text_box
1283
+ width = 0.62 * height * len(fallback_text)
1284
+ return (x, y - height, x + width, y)
1285
+
1286
+ def _drawing(self) -> Any:
1287
+ """The active document as an ``IDrawingDoc``."""
1288
+ return _sw_type_info.flagged(self.currentModel, "IDrawingDoc")
1289
+
1290
+ def _obstacles(self, s: _Sheet, captions: bool = True) -> dict[str, Box]:
1291
+ """What dimensions must keep clear of: title block, notes, captions.
1292
+
1293
+ Args:
1294
+ s: The sheet plan.
1295
+ captions: Keep each view's whole caption strip clear. ``False``
1296
+ once the captions are on the sheet and measured instead.
1297
+ """
1298
+ result: dict[str, Box] = {"title block": TITLE_BLOCK, "notes": s.cells["notes"]}
1299
+ for r in s.role.values() if captions else ():
1300
+ c = s.cells[r]
1301
+ result[f"{r} caption"] = (c[0], c[1], c[2], c[1] + CAPTION_STRIP)
1302
+ return result
1303
+
1304
+ # -- the generator's COM steps --------------------------------------
1305
+
1306
+ def _setup_a3_sheet(self, s: _Sheet) -> dict[str, Any]:
1307
+ """Apply the A3 ISO sheet format and drop trailing zeros.
1308
+
1309
+ Raises:
1310
+ RuntimeError: ``SetupSheet5`` refused the format.
1311
+ """
1312
+ model = self.currentModel
1313
+ drawing = self._drawing()
1314
+ sheet = _as_com(self, drawing.GetCurrentSheet(), "ISheet")
1315
+ sheet_name = self._attempt(lambda: sheet.GetName(), default="Sheet1")
1316
+ if not drawing.SetupSheet5(
1317
+ sheet_name,
1318
+ _SW_PAPER_A3,
1319
+ _SW_TEMPLATE_CUSTOM,
1320
+ 1.0,
1321
+ 1.0,
1322
+ False,
1323
+ s.sheet_format,
1324
+ SHEET_SIZE[0],
1325
+ SHEET_SIZE[1],
1326
+ "Default",
1327
+ True,
1328
+ ):
1329
+ raise RuntimeError(f"SetupSheet5 refused the sheet format {s.sheet_format}")
1330
+ self._attempt(
1331
+ lambda: model.SetUserPreferenceIntegerValue(
1332
+ _SW_DETAILING_DIM_TRAILING_ZERO, _SW_DIM_REMOVE_TRAILING_ZEROES
1333
+ ),
1334
+ default=None,
1335
+ )
1336
+ return {"sheet": sheet_name, "sheet_format": s.sheet_format}
1337
+
1338
+ def _create_views(self, s: _Sheet) -> dict[str, Any]:
1339
+ """Create the third-angle views, name their roles, add the isometric.
1340
+
1341
+ Raises:
1342
+ RuntimeError: No view could be made from the model.
1343
+ """
1344
+ drawing = self._drawing()
1345
+ before = set(_view_names(self, drawing))
1346
+ self._attempt(lambda: drawing.Create3rdAngleViews2(s.path), default=False)
1347
+ s.ortho = [n for n in _view_names(self, drawing) if n not in before]
1348
+ if not s.ortho:
1349
+ raise RuntimeError(f"No views could be created from {s.path}")
1350
+ s.views = {n: _resolve_drawing_view(self, drawing, n) for n in s.ortho}
1351
+ outlines = {n: self._view_outline(v) for n, v in s.views.items()}
1352
+ orientations = {
1353
+ n: str(self._attempt(lambda v=v: v.GetOrientationName(), default="") or "")
1354
+ for n, v in s.views.items()
1355
+ }
1356
+ s.role = view_roles(s.views, outlines, orientations)
1357
+ # Orthographic views show hidden edges dashed (a through hole in the
1358
+ # front and side views); the isometric stays as it is.
1359
+ hidden = {n: self._show_hidden_lines(v) for n, v in s.views.items()}
1360
+ if s.include_isometric:
1361
+ before_iso = set(_view_names(self, drawing))
1362
+ self._attempt(
1363
+ lambda: drawing.CreateDrawViewFromModelView3(
1364
+ s.path, "*Isometric", *_ISO_DROP_POINT, 0.0
1365
+ ),
1366
+ default=None,
1367
+ )
1368
+ s.iso_name = next(
1369
+ (n for n in _view_names(self, drawing) if n not in before_iso), None
1370
+ )
1371
+ if s.iso_name:
1372
+ s.views[s.iso_name] = _resolve_drawing_view(self, drawing, s.iso_name)
1373
+ s.role[s.iso_name] = "iso"
1374
+ return {"views": dict(s.role), "hidden_lines_visible": hidden}
1375
+
1376
+ def _show_hidden_lines(self, view: Any) -> bool | None:
1377
+ """Draw a view's hidden edges dashed (``IView::SetDisplayMode4``).
1378
+
1379
+ Returns:
1380
+ bool | None: Whether the view reads back as hidden lines visible;
1381
+ ``None`` when its mode cannot be read.
1382
+ """
1383
+ self._attempt(
1384
+ lambda: view.SetDisplayMode4(
1385
+ False, _SW_HIDDEN_LINES_VISIBLE, False, False, False
1386
+ ),
1387
+ default=None,
1388
+ )
1389
+ mode = self._attempt(
1390
+ lambda: self._get_attr_or_call(view, "GetDisplayMode2"), default=None
1391
+ )
1392
+ return None if mode is None else mode == _SW_HIDDEN_LINES_VISIBLE
1393
+
1394
+ def _view_sizes(self, s: _Sheet) -> dict[str, tuple[float, float]]:
1395
+ """Each view's size at 1:1 (outline / view scale), by role."""
1396
+ result = {}
1397
+ for n, v in s.views.items():
1398
+ o = self._view_outline(v)
1399
+ scale = float(self._attempt(lambda v=v: v.ScaleDecimal, default=1.0) or 1.0)
1400
+ if n in s.role and o is not None and scale > 0:
1401
+ result[s.role[n]] = ((o[2] - o[0]) / scale, (o[3] - o[1]) / scale)
1402
+ return result
1403
+
1404
+ def _fit_views(self, s: _Sheet) -> dict[str, Any]:
1405
+ """Pick the scale, then centre each view in its cell.
1406
+
1407
+ The plan follows the front's x and the side its y, so the views stay
1408
+ aligned for third-angle projection.
1409
+ """
1410
+ model = self.currentModel
1411
+ sizes = self._view_sizes(s)
1412
+ iso_size = sizes.pop("iso", None)
1413
+ s.scale, s.cells = choose_layout(sizes)
1414
+ sheet = _as_com(self, self._drawing().GetCurrentSheet(), "ISheet")
1415
+ sheet.SetScale(float(s.scale[0]), float(s.scale[1]), True, False)
1416
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1417
+ s.iso_size = iso_size
1418
+ if s.iso_name and iso_size:
1419
+ self._fit_iso(s)
1420
+ fc = content_box(s.cells["front"])
1421
+ fcx, fcy = (fc[0] + fc[2]) / 2, (fc[1] + fc[3]) / 2
1422
+ placed = {}
1423
+ for n, r in sorted(s.role.items(), key=lambda item: item[1] != "front"):
1424
+ c = content_box(s.cells[r])
1425
+ x, y = (c[0] + c[2]) / 2, (c[1] + c[3]) / 2
1426
+ if r == "plan":
1427
+ x = fcx
1428
+ elif r == "side":
1429
+ y = fcy
1430
+ placed[n] = self._set_view_position(s.views[n], x, y)
1431
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1432
+ return {
1433
+ "scale": scale_label(s.scale),
1434
+ "isometric_scale": scale_label(s.iso_scale) if s.iso_scale else None,
1435
+ "positioned": placed,
1436
+ }
1437
+
1438
+ def _fit_iso(self, s: _Sheet) -> None:
1439
+ """Scale the isometric to the largest standard scale that fits its cell."""
1440
+ s.iso_scale = iso_fit_scale(s.iso_size, s.cells["iso"])
1441
+ iso = s.views[s.iso_name]
1442
+ ratio = _double_array([float(s.iso_scale[0]), float(s.iso_scale[1])])
1443
+ self._attempt(lambda: setattr(iso, "UseSheetScale", False), default=None)
1444
+ self._attempt(lambda: setattr(iso, "ScaleRatio", ratio), default=None)
1445
+
1446
+ def _bom_above_iso(self, s: _Sheet) -> dict[str, Any] | None:
1447
+ """Put the BOM in the top of the isometric's cell; fit the isometric below.
1448
+
1449
+ The table is inserted, measured, and moved so its top-right corner sits
1450
+ ``BOM_GAP`` inside the cell's. The cell then ends below the table, and
1451
+ the isometric is scaled and centred in what is left, so they never meet.
1452
+
1453
+ Raises:
1454
+ RuntimeError: The table cannot be measured once inserted.
1455
+ """
1456
+ if not s.include_bom:
1457
+ return None
1458
+ cell = s.cells["iso"]
1459
+ front = next(n for n, r in s.role.items() if r == "front")
1460
+ table, info = self._insert_bom(
1461
+ front, None, "top_level_only", cell[0] * 1000 + 10, cell[1] * 1000 + 20
1462
+ )
1463
+ box = self._table_box(table)
1464
+ if box is None:
1465
+ raise RuntimeError("The BOM was inserted but cannot be measured")
1466
+ an = _sw_type_info.flagged(
1467
+ _sw_type_info.flagged(table, "ITableAnnotation").GetAnnotation(),
1468
+ "IAnnotation",
1469
+ )
1470
+ x, y = (float(v) for v in an.GetPosition()[:2])
1471
+ an.SetPosition(
1472
+ x + cell[2] - BOM_GAP - box[2], y + cell[3] - BOM_GAP - box[3], 0.0
1473
+ )
1474
+ box = self._table_box(table)
1475
+ if box is None:
1476
+ raise RuntimeError("The BOM was moved but cannot be measured")
1477
+ s.bom_box = box
1478
+ s.cells["iso"] = (cell[0], cell[1], cell[2], box[1] - BOM_GAP)
1479
+ if s.iso_name and s.iso_size:
1480
+ self._fit_iso(s)
1481
+ c = content_box(s.cells["iso"])
1482
+ self._set_view_position(
1483
+ s.views[s.iso_name], (c[0] + c[2]) / 2, (c[1] + c[3]) / 2
1484
+ )
1485
+ self._attempt(lambda: self.currentModel.EditRebuild3(), default=None)
1486
+ info["table_box_mm"] = [round(v * 1000, 1) for v in box]
1487
+ return info
1488
+
1489
+ def _dimension_views(self, s: _Sheet) -> dict[str, Any]:
1490
+ """Import the model dimensions and lay them out clear of everything."""
1491
+ dims = self._import_dimensions(None, True, only_views=list(s.ortho))
1492
+ dims["layout"] = self._arrange_layout(None, self._obstacles(s), WORK_AREA)
1493
+ return dims
1494
+
1495
+ def _annotate(self, s: _Sheet) -> dict[str, Any]:
1496
+ """Add a bold caption under each view and the NOTES block.
1497
+
1498
+ Each note is placed from its measured box, so lines cannot run into
1499
+ each other.
1500
+ """
1501
+ model = self.currentModel
1502
+ for n, r in s.role.items():
1503
+ text = CAPTIONS[r]
1504
+ c = s.cells[r]
1505
+ s.captions[n] = text
1506
+ an = self._insert_sheet_note(
1507
+ text, c[0] + 0.01, c[1] + CAPTION_STRIP, CAPTION_HEIGHT, True
1508
+ )
1509
+ if an is not None:
1510
+ self._place_note(
1511
+ an, text, centre_x=(c[0] + c[2]) / 2, bottom=c[1] + 0.0015
1512
+ )
1513
+ s.notes.append((f"caption {r}", an))
1514
+ s.note_lines = note_lines(s.material, s.extra_notes)
1515
+ nc = CELLS["notes"]
1516
+ top = nc[3] - 0.003
1517
+ for i, line in enumerate(s.note_lines):
1518
+ height = CAPTION_HEIGHT if i == 0 else NOTE_HEIGHT
1519
+ an = self._insert_sheet_note(line, nc[0] + 0.010, top, height, i == 0)
1520
+ if an is None:
1521
+ continue
1522
+ box = self._place_note(an, line, left=nc[0] + 0.010, top=top)
1523
+ s.notes.append((f"note line {i}", an))
1524
+ top = box[1] - 0.0015
1525
+ self._attempt(lambda: model.ClearSelection2(True), default=None)
1526
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1527
+ return {"captions": dict(s.captions), "notes": list(s.note_lines)}
1528
+
1529
+ def _note_extent(self, note: Any) -> Box | None:
1530
+ """A note's box from ``INote::GetExtent`` (lower-left, upper-right)."""
1531
+ raw = self._attempt(lambda: note.GetExtent(), default=None)
1532
+ if isinstance(raw, (list, tuple)) and len(raw) >= 6:
1533
+ return (float(raw[0]), float(raw[1]), float(raw[3]), float(raw[4]))
1534
+ return None
1535
+
1536
+ def _format_notes(self, drawing: Any) -> list[tuple[Any, str, Box | None]]:
1537
+ """The notes on the sheet's own view: ``(INote, linked text, box)``.
1538
+
1539
+ While the sheet format is being edited, these are the format's notes:
1540
+ its title-block fields and labels.
1541
+ """
1542
+ sheet_view = self._attempt(
1543
+ lambda: _sw_type_info.flagged(drawing.GetFirstView(), "IView"),
1544
+ default=None,
1545
+ )
1546
+ found = []
1547
+ note = self._attempt(lambda: sheet_view.GetFirstNote(), default=None)
1548
+ while note is not None and len(found) < 500:
1549
+ n = _sw_type_info.flagged(note, "INote")
1550
+ linked = str(
1551
+ self._attempt(lambda n=n: n.PropertyLinkedText, default="") or ""
1552
+ )
1553
+ found.append((n, linked, self._note_extent(n)))
1554
+ note = self._attempt(lambda n=n: n.GetNext(), default=None)
1555
+ return found
1556
+
1557
+ def _fit_field(
1558
+ self, note: Any, right: float, cap: float | None = None
1559
+ ) -> dict[str, Any]:
1560
+ """Shrink one note's text until it fills at most 90% of its room.
1561
+
1562
+ Args:
1563
+ note: The field's ``INote``.
1564
+ right: The x its text must end before.
1565
+ cap: The caller's largest text height in metres, applied first.
1566
+
1567
+ Returns:
1568
+ dict[str, Any]: ``fits``, the text height before and after (mm)
1569
+ and the final box (mm).
1570
+ """
1571
+ model = self.currentModel
1572
+ box = self._note_extent(note)
1573
+ tf = self._attempt(
1574
+ lambda: _as_com(self, note.GetTextFormat(), "ITextFormat"), default=None
1575
+ )
1576
+ before = float(self._attempt(lambda: tf.CharHeight, default=0.0) or 0.0)
1577
+ height = before
1578
+
1579
+ def apply(h: float) -> Box | None:
1580
+ self._attempt(lambda: setattr(tf, "CharHeight", h), default=None)
1581
+ self._attempt(
1582
+ lambda: note.SetTextFormat(_SW_NOTE_OWN_FORMAT, tf), default=None
1583
+ )
1584
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1585
+ return self._note_extent(note)
1586
+
1587
+ if cap is not None and height > cap:
1588
+ height = max(MIN_FIELD_HEIGHT, cap)
1589
+ box = apply(height)
1590
+ for _ in range(4):
1591
+ if box is None or height <= MIN_FIELD_HEIGHT:
1592
+ break
1593
+ room = max(right - box[0], 0.0)
1594
+ if box[2] - box[0] <= room * FIELD_FILL:
1595
+ break
1596
+ height = max(
1597
+ MIN_FIELD_HEIGHT, height * room * FIELD_FILL / (box[2] - box[0])
1598
+ )
1599
+ box = apply(height)
1600
+ return {
1601
+ "fits": box is not None and box[2] <= right + 1e-6,
1602
+ "height_mm_before": round(before * 1000, 2),
1603
+ "height_mm": round(height * 1000, 2),
1604
+ "box_mm": [round(c * 1000, 1) for c in box] if box else None,
1605
+ "room_ends_mm": round(right * 1000, 1),
1606
+ }
1607
+
1608
+ def _fit_title_block(self, s: _Sheet) -> dict[str, dict[str, Any]]:
1609
+ """Fit the DWG NO. and TITLE fields of the sheet format to their boxes.
1610
+
1611
+ Edits the sheet format (``EditTemplate``), always returning to the
1612
+ sheet (``EditSheet``). Run after the drawing is saved: the DWG NO.
1613
+ shows the file name, which is ``Draw1`` until then.
1614
+ """
1615
+ model = self.currentModel
1616
+ drawing = self._drawing()
1617
+ self._attempt(lambda: drawing.EditTemplate(), default=None)
1618
+ try:
1619
+ notes = self._format_notes(drawing)
1620
+ # The properties the format shows, e.g. $PRPSHEET:"SW-Material":
1621
+ # which model property each title-block field reads.
1622
+ s.format_links = sorted({n[1] for n in notes if n[1]})
1623
+ for prop, name in TITLE_BLOCK_FIELDS.items():
1624
+ match = next((n for n in notes if f'"{prop}"' in n[1]), None)
1625
+ if match is None or match[2] is None:
1626
+ s.title_block[name] = {"found": match is not None, "fits": None}
1627
+ continue
1628
+ others = [n[2] for n in notes if n is not match and n[2] is not None]
1629
+ right = field_room(match[2], others, TITLE_BLOCK[2])
1630
+ s.title_block[name] = {
1631
+ "found": True,
1632
+ **self._fit_field(match[0], right, s.field_height),
1633
+ }
1634
+ finally:
1635
+ self._attempt(lambda: drawing.EditSheet(), default=None)
1636
+ self._attempt(lambda: model.EditRebuild3(), default=None)
1637
+ return dict(s.title_block)
1638
+
1639
+ def _sheet_note_boxes(self, drawing: Any) -> dict[str, Box]:
1640
+ """Every note on the sheet (view captions, the NOTES block) by its box.
1641
+
1642
+ A note inserted with nothing selected goes into whichever view is
1643
+ active, not onto the sheet, so the notes of the sheet and of every
1644
+ view are read. Each is measured from its display data, as dimensions
1645
+ are, so dimensions keep clear of the text as SolidWorks placed it.
1646
+ """
1647
+ boxes: dict[str, Box] = {}
1648
+ view = self._attempt(lambda: drawing.GetFirstView(), default=None)
1649
+ while view is not None:
1650
+ v = _sw_type_info.flagged(view, "IView")
1651
+ note = self._attempt(lambda v=v: v.GetFirstNote(), default=None)
1652
+ while note is not None and len(boxes) < 500:
1653
+ n = _sw_type_info.flagged(note, "INote")
1654
+ box = self._measured_note(n)
1655
+ if box is not None:
1656
+ boxes[f"note {len(boxes)}"] = box
1657
+ note = self._attempt(lambda n=n: n.GetNext(), default=None)
1658
+ view = self._attempt(lambda v=v: v.GetNextView(), default=None)
1659
+ return boxes
1660
+
1661
+ def _measured_note(self, note: Any) -> Box | None:
1662
+ """A note's box from its display data; its extent when that is unreadable."""
1663
+ an = self._attempt(lambda: note.GetAnnotation(), default=None)
1664
+ geometry = (
1665
+ None
1666
+ if an is None
1667
+ else self._attempt(
1668
+ lambda: self._dimension_geometry(
1669
+ _sw_type_info.flagged(an, "IAnnotation"), "note", "sheet"
1670
+ )
1671
+ )
1672
+ )
1673
+ return geometry.text_box if geometry is not None else self._note_extent(note)
1674
+
1675
+ def _check_sheet(self, s: _Sheet) -> dict[str, Any]:
1676
+ """Lay the dimensions out on the finished sheet, then check it, read back.
1677
+
1678
+ The layout runs again here: the steps after the first layout (captions,
1679
+ notes, the title block, the save) can leave dimensions where they were
1680
+ imported (seen live on SW 2026), and only now is every note on the sheet,
1681
+ so each caption is kept clear as measured, not its whole strip.
1682
+ """
1683
+ drawing = self._drawing()
1684
+ obstacles = {**self._obstacles(s, False), **self._sheet_note_boxes(drawing)}
1685
+ if s.bom_box is not None:
1686
+ obstacles["bom"] = s.bom_box
1687
+ final = self._arrange_layout(None, obstacles, WORK_AREA)
1688
+ outlines, dims, _ = self._collect_layout(drawing, _view_names(self, drawing))
1689
+ boxes = {
1690
+ label: self._note_box(an, label, 0.0, 0.0, NOTE_HEIGHT)
1691
+ for label, an in s.notes
1692
+ }
1693
+ if s.bom_box is not None:
1694
+ boxes["bom"] = s.bom_box
1695
+ problems = _layout.find_annotation_overlaps(
1696
+ boxes, dims, outlines, WORK_AREA, {"title block": TITLE_BLOCK}
1697
+ )
1698
+ final["annotation_overlaps"] = problems
1699
+ final["overlaps_after"] = final.get("overlaps_after", 0) + len(problems)
1700
+ return final
1701
+
1702
+ # -- the generator ----------------------------------------------------
1703
+
1704
+ def _new_drawing_without_dialog(self) -> bool | None:
1705
+ """Read and turn off the Sheet Format/Size prompt (pitfall #32).
1706
+
1707
+ Returns:
1708
+ bool | None: The toggle's previous value, ``None`` when unreadable.
1709
+ """
1710
+ app = self.swApp
1711
+ previous = self._attempt(
1712
+ lambda: app.GetUserPreferenceToggle(_SW_SHOW_SHEET_FORMAT_DIALOG),
1713
+ default=None,
1714
+ )
1715
+ self._attempt(
1716
+ lambda: app.SetUserPreferenceToggle(_SW_SHOW_SHEET_FORMAT_DIALOG, False),
1717
+ default=None,
1718
+ )
1719
+ return None if previous is None else bool(previous)
1720
+
1721
+ def _restore_dialog(self, previous: bool | None) -> None:
1722
+ """Put the Sheet Format/Size prompt back as it was."""
1723
+ if previous is not None:
1724
+ app = self.swApp
1725
+ self._attempt(
1726
+ lambda: app.SetUserPreferenceToggle(
1727
+ _SW_SHOW_SHEET_FORMAT_DIALOG, previous
1728
+ ),
1729
+ default=None,
1730
+ )
1731
+
1732
+ async def create_standard_drawing(
1733
+ self,
1734
+ model_path: str,
1735
+ output_dir: str | None = None,
1736
+ title: str | None = None,
1737
+ subtitle: str | None = None,
1738
+ drawing_number: str | None = None,
1739
+ author: str = "",
1740
+ revision: str = "A",
1741
+ company: str = "",
1742
+ drawn_date: str | None = None,
1743
+ notes: list[str] | None = None,
1744
+ include_isometric: bool = True,
1745
+ write_model_properties: bool = True,
1746
+ title_block_text_height_mm: float | None = None,
1747
+ include_bom: bool = False,
1748
+ ) -> AdapterResult[dict[str, Any]]:
1749
+ """Create a complete A3 drawing that follows the drawing standard.
1750
+
1751
+ Opens the model (or uses its open copy), writes the title-block
1752
+ properties into it, makes a drawing on the A3 ISO format, builds the
1753
+ sheet in separate COM steps, writes the drawing properties, saves
1754
+ ``<drawing_number>.SLDDRW`` and exports a PDF.
1755
+
1756
+ Args:
1757
+ model_path: Part (.SLDPRT) or assembly (.SLDASM) to draw.
1758
+ output_dir: Folder for the .SLDDRW and PDF; the model's folder by default.
1759
+ title: Title block TITLE; the model file name by default.
1760
+ subtitle: Company field when ``company`` is empty.
1761
+ drawing_number: DWG NO.; also the drawing file name. Default
1762
+ ``<MODEL>-Rev<revision>-01``.
1763
+ author: DRAWN name.
1764
+ revision: Revision letter.
1765
+ company: Company name field.
1766
+ drawn_date: DRAWN date; today (e.g. "17 Oct 2026") by default.
1767
+ notes: Extra note lines (at most seven lines in total are shown).
1768
+ include_isometric: Add the isometric view in its cell.
1769
+ write_model_properties: Write Description/Material/Weight custom
1770
+ properties into the model (the format reads them from there)
1771
+ and save the model.
1772
+ title_block_text_height_mm: Largest text height for the DWG NO.
1773
+ and TITLE fields, e.g. 3 for smaller text; they are shrunk
1774
+ further when they still do not fit.
1775
+ include_bom: Assemblies only: a top-level BOM in the top of the
1776
+ isometric's cell, the isometric fitted below it.
1777
+
1778
+ Returns:
1779
+ AdapterResult[dict[str, Any]]: Files, scale, views, dimension
1780
+ counts, the overlap check, every property written and each step's
1781
+ outcome. ERROR, with that data, when a step fails, the drawing or
1782
+ PDF could not be saved, or any overlap remains.
1783
+ """
1784
+ path = os.path.abspath(model_path)
1785
+ if not os.path.isfile(path):
1786
+ return AdapterResult(
1787
+ status=AdapterResultStatus.ERROR, error=f"Model file not found: {path}"
1788
+ )
1789
+ ext = os.path.splitext(path)[1].lower()
1790
+ if ext not in {".sldprt", ".sldasm"}:
1791
+ return AdapterResult(
1792
+ status=AdapterResultStatus.ERROR,
1793
+ error=f"model_path must be a .SLDPRT or .SLDASM, not {ext or 'no extension'}",
1794
+ )
1795
+ if include_bom and ext != ".sldasm":
1796
+ return AdapterResult(
1797
+ status=AdapterResultStatus.ERROR,
1798
+ error="include_bom needs an assembly (.SLDASM); a part has no BOM",
1799
+ )
1800
+ stem = os.path.splitext(os.path.basename(path))[0]
1801
+ rev = (revision or "A").strip()
1802
+ number = drawing_number or f"{stem.upper().replace(' ', '-')}-Rev{rev}-01"
1803
+ out_dir = os.path.abspath(output_dir or os.path.dirname(path))
1804
+ drawing_path = os.path.join(out_dir, f"{number}.SLDDRW")
1805
+ pdf_path = os.path.join(out_dir, f"{number}.pdf")
1806
+ steps: list[dict[str, Any]] = []
1807
+ written: dict[str, dict[str, str]] = {}
1808
+ data: dict[str, Any] = {"drawing_number": number, "steps": steps}
1809
+
1810
+ def failed(error: str) -> AdapterResult[dict[str, Any]]:
1811
+ return AdapterResult(
1812
+ status=AdapterResultStatus.ERROR, error=error, data=data
1813
+ )
1814
+
1815
+ # 1. The model: open it, read its material, write the title-block properties.
1816
+ active = self._handle_com_operation(
1817
+ "activate_document", lambda: self._use_open_document(path)
1818
+ )
1819
+ if not (active.is_success and active.data):
1820
+ opened = await self.open_model(path)
1821
+ if not opened.is_success:
1822
+ return failed(
1823
+ f"Could not open model: {opened.error}. If a different file "
1824
+ "with the same name is open in SolidWorks, close it first."
1825
+ )
1826
+ material = None
1827
+ if ext == ".sldprt":
1828
+ mat = await self.get_material()
1829
+ material = (mat.data or {}).get("material") if mat.is_success else None
1830
+ model_props, drawing_props = title_block_properties(
1831
+ model_file=path,
1832
+ title=title or stem,
1833
+ subtitle=subtitle,
1834
+ author=author,
1835
+ drawn_date=drawn_date or _date.today().strftime("%d %b %Y"),
1836
+ revision=rev,
1837
+ company=company,
1838
+ )
1839
+ if ext == ".sldasm":
1840
+ # An assembly has no material of its own: a link to it shows
1841
+ # "<not specified>". Its own Material property is left as set.
1842
+ model_props.pop("Material")
1843
+ if write_model_properties:
1844
+ for name, value in model_props.items():
1845
+ _step(
1846
+ steps,
1847
+ f"model_property:{name}",
1848
+ await self.set_custom_property(name, value),
1849
+ )
1850
+ _step(steps, "save_model", await self.save_file())
1851
+ written["model"] = model_props
1852
+
1853
+ # 2. A drawing on the A3 ISO format, without the Sheet Format/Size prompt.
1854
+ found = self._handle_com_operation(
1855
+ "standard_drawing_format", lambda: find_sheet_format(self)
1856
+ )
1857
+ if not (found.is_success and found.data):
1858
+ return failed(
1859
+ "Sheet format 'a3 - iso.slddrt' not found in SolidWorks' sheet "
1860
+ "format folders or under C:\\ProgramData\\SOLIDWORKS"
1861
+ )
1862
+ prompt = self._handle_com_operation(
1863
+ "standard_drawing_dialog", self._new_drawing_without_dialog
1864
+ )
1865
+ try:
1866
+ created = await self.create_drawing()
1867
+ finally:
1868
+ self._handle_com_operation(
1869
+ "standard_drawing_dialog",
1870
+ lambda: self._restore_dialog(
1871
+ prompt.data if prompt.is_success else None
1872
+ ),
1873
+ )
1874
+ if not created.is_success:
1875
+ return failed(f"Could not create drawing: {created.error}")
1876
+ _step(steps, "drawing_units_mm", await self.set_units("mm"))
1877
+
1878
+ # 3. The sheet, one COM step at a time.
1879
+ s = _Sheet(
1880
+ path=path,
1881
+ sheet_format=str(found.data),
1882
+ include_isometric=include_isometric,
1883
+ include_bom=include_bom,
1884
+ material=material,
1885
+ extra_notes=list(notes or []),
1886
+ field_height=(
1887
+ title_block_text_height_mm / 1000.0
1888
+ if title_block_text_height_mm
1889
+ else None
1890
+ ),
1891
+ )
1892
+ for name, build in (
1893
+ ("sheet", self._setup_a3_sheet),
1894
+ ("views", self._create_views),
1895
+ ("layout", self._fit_views),
1896
+ ("bom", self._bom_above_iso),
1897
+ ("dimensions", self._dimension_views),
1898
+ ("notes", self._annotate),
1899
+ ("check", self._check_sheet),
1900
+ ):
1901
+ done = self._handle_com_operation(
1902
+ f"standard_drawing_{name}", lambda build=build: build(s)
1903
+ )
1904
+ _step(steps, f"build_{name}", done)
1905
+ if not done.is_success:
1906
+ return failed(f"Drawing step '{name}' failed: {done.error}")
1907
+ if name == "dimensions":
1908
+ data["dimensions"] = done.data
1909
+ if name == "bom":
1910
+ data["bom"] = done.data
1911
+ if name == "views":
1912
+ data["hidden_lines_visible"] = done.data["hidden_lines_visible"]
1913
+ if name == "check":
1914
+ data["overlap_check"] = done.data
1915
+ data.update(
1916
+ {
1917
+ "scale": scale_label(s.scale),
1918
+ "isometric_scale": scale_label(s.iso_scale) if s.iso_scale else None,
1919
+ "views": {
1920
+ n: {"role": s.role.get(n), "caption": s.captions.get(n)}
1921
+ for n in s.views
1922
+ },
1923
+ "sheet_format": s.sheet_format,
1924
+ "notes": s.note_lines,
1925
+ }
1926
+ )
1927
+
1928
+ # 4. Drawing properties, then the files.
1929
+ for name, value in drawing_props.items():
1930
+ _step(
1931
+ steps,
1932
+ f"drawing_property:{name}",
1933
+ await self.set_custom_property(name, value),
1934
+ )
1935
+ written["drawing"] = drawing_props
1936
+ data["properties_written"] = written
1937
+ os.makedirs(out_dir, exist_ok=True)
1938
+ saved = await self.save_file(drawing_path)
1939
+ _step(steps, "save_drawing", saved)
1940
+ data["drawing_path"] = drawing_path if saved.is_success else None
1941
+ if not saved.is_success:
1942
+ return failed(
1943
+ f"Drawing built but not saved to {drawing_path}: {saved.error}"
1944
+ )
1945
+
1946
+ # 5. The title block, now that the DWG NO. shows the saved file name.
1947
+ fitted = self._handle_com_operation(
1948
+ "standard_drawing_title_block", lambda: self._fit_title_block(s)
1949
+ )
1950
+ _step(steps, "fit_title_block", fitted)
1951
+ data["title_block"] = fitted.data if fitted.is_success else None
1952
+ data["format_links"] = s.format_links
1953
+ check = data["overlap_check"]
1954
+ for name, field_fit in (data["title_block"] or {}).items():
1955
+ if field_fit.get("fits") is False:
1956
+ check["annotation_overlaps"].append(
1957
+ {
1958
+ "dimension": f"title block {name}",
1959
+ "conflicts_with": "the next title-block field",
1960
+ "kind": "field_overflow",
1961
+ }
1962
+ )
1963
+ check["overlaps_after"] = check.get("overlaps_after", 0) + 1
1964
+ _step(steps, "save_drawing_again", await self.save_file(drawing_path))
1965
+ pdf = await self.export_file(pdf_path, "pdf")
1966
+ _step(steps, "export_pdf", pdf)
1967
+ data["pdf_path"] = pdf_path if pdf.is_success else None
1968
+ if not pdf.is_success:
1969
+ return failed(
1970
+ f"Drawing saved to {drawing_path} but the PDF export failed: {pdf.error}"
1971
+ )
1972
+
1973
+ overlaps = data["overlap_check"].get("overlaps_after", 0)
1974
+ overflowing = any(
1975
+ f.get("fits") is False for f in (data.get("title_block") or {}).values()
1976
+ )
1977
+ if overlaps:
1978
+ check = data["overlap_check"]
1979
+ found = [
1980
+ *check.get("remaining_overlaps", []),
1981
+ *check.get("annotation_overlaps", []),
1982
+ ]
1983
+ named = "; ".join(
1984
+ f"{o['dimension']} vs {o['conflicts_with']} ({o['kind']})"
1985
+ for o in found[:5]
1986
+ )
1987
+ return failed(
1988
+ f"Drawing saved but {overlaps} overlap(s) remain - the no-overlap "
1989
+ "rule is not met; see overlap_check"
1990
+ + (f": {named}" if named else "")
1991
+ + (f". For a title-block field, {FIELD_HINT}" if overflowing else "")
1992
+ )
1993
+ return AdapterResult(status=AdapterResultStatus.SUCCESS, data=data)