solidworks-mcp-python 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solidworks_mcp/__init__.py +57 -0
- solidworks_mcp/adapters/__init__.py +40 -0
- solidworks_mcp/adapters/base.py +1882 -0
- solidworks_mcp/adapters/circuit_breaker.py +2052 -0
- solidworks_mcp/adapters/com_executor.py +252 -0
- solidworks_mcp/adapters/complexity_analyzer.py +272 -0
- solidworks_mcp/adapters/connection_pool.py +1884 -0
- solidworks_mcp/adapters/factory.py +366 -0
- solidworks_mcp/adapters/intelligent_router.py +218 -0
- solidworks_mcp/adapters/mock_adapter.py +4016 -0
- solidworks_mcp/adapters/pywin32_adapter.py +2842 -0
- solidworks_mcp/adapters/solidworks/__init__.py +17 -0
- solidworks_mcp/adapters/solidworks/bitmap.py +180 -0
- solidworks_mcp/adapters/solidworks/com_utils.py +56 -0
- solidworks_mcp/adapters/solidworks/dimension_layout.py +753 -0
- solidworks_mcp/adapters/solidworks/documentation.py +1476 -0
- solidworks_mcp/adapters/solidworks/drawing_standard.py +1993 -0
- solidworks_mcp/adapters/solidworks/face_selection.py +392 -0
- solidworks_mcp/adapters/solidworks/features.py +3362 -0
- solidworks_mcp/adapters/solidworks/io.py +3649 -0
- solidworks_mcp/adapters/solidworks/rebuild_diagnostics.py +269 -0
- solidworks_mcp/adapters/solidworks/rebuild_inspector.py +146 -0
- solidworks_mcp/adapters/solidworks/selection.py +308 -0
- solidworks_mcp/adapters/solidworks/sketch.py +2545 -0
- solidworks_mcp/adapters/sw_feature_errors.py +66 -0
- solidworks_mcp/adapters/sw_type_info.py +317 -0
- solidworks_mcp/adapters/vba_adapter.py +318 -0
- solidworks_mcp/adapters/vba_macro_executor.py +215 -0
- solidworks_mcp/agents/__init__.py +68 -0
- solidworks_mcp/agents/design_knowledge/assembly_patterns.md +127 -0
- solidworks_mcp/agents/design_knowledge/hinges.md +118 -0
- solidworks_mcp/agents/design_knowledge/snap_fits.md +93 -0
- solidworks_mcp/agents/design_knowledge/solidworks_basics.md +3 -0
- solidworks_mcp/agents/design_knowledge/tolerancing.md +128 -0
- solidworks_mcp/agents/harness.py +216 -0
- solidworks_mcp/agents/history_db.py +1508 -0
- solidworks_mcp/agents/retrieval_index.py +164 -0
- solidworks_mcp/agents/schemas.py +165 -0
- solidworks_mcp/agents/smoke_test.py +285 -0
- solidworks_mcp/agents/soc_exporter.py +650 -0
- solidworks_mcp/agents/soc_pickup.py +332 -0
- solidworks_mcp/agents/soc_rewind.py +221 -0
- solidworks_mcp/agents/vector_rag.py +665 -0
- solidworks_mcp/cache/__init__.py +5 -0
- solidworks_mcp/cache/response_cache.py +164 -0
- solidworks_mcp/config.py +729 -0
- solidworks_mcp/exceptions.py +37 -0
- solidworks_mcp/security/__init__.py +68 -0
- solidworks_mcp/security/auth.py +128 -0
- solidworks_mcp/security/cors.py +27 -0
- solidworks_mcp/security/rate_limiting.py +90 -0
- solidworks_mcp/security/runtime.py +155 -0
- solidworks_mcp/server.py +777 -0
- solidworks_mcp/server_cli_fixed.py +120 -0
- solidworks_mcp/setup/__init__.py +1 -0
- solidworks_mcp/setup/cli.py +399 -0
- solidworks_mcp/setup/clients.py +157 -0
- solidworks_mcp/setup/config_file.py +230 -0
- solidworks_mcp/setup/handshake.py +202 -0
- solidworks_mcp/setup/launcher.py +102 -0
- solidworks_mcp/setup/legacy.py +100 -0
- solidworks_mcp/setup/registration.py +193 -0
- solidworks_mcp/setup/solidworks.py +49 -0
- solidworks_mcp/tools/__init__.py +64 -0
- solidworks_mcp/tools/analysis.py +723 -0
- solidworks_mcp/tools/automation.py +578 -0
- solidworks_mcp/tools/docs_discovery.py +1476 -0
- solidworks_mcp/tools/drawing.py +1015 -0
- solidworks_mcp/tools/drawing_analysis.py +456 -0
- solidworks_mcp/tools/drawing_automation.py +627 -0
- solidworks_mcp/tools/engineering_data.py +466 -0
- solidworks_mcp/tools/error_classifier.py +232 -0
- solidworks_mcp/tools/export.py +833 -0
- solidworks_mcp/tools/file_facts.py +40 -0
- solidworks_mcp/tools/file_management.py +1473 -0
- solidworks_mcp/tools/input_compat.py +17 -0
- solidworks_mcp/tools/macro_recording.py +464 -0
- solidworks_mcp/tools/modeling.py +2360 -0
- solidworks_mcp/tools/sketching.py +1422 -0
- solidworks_mcp/tools/template_management.py +367 -0
- solidworks_mcp/tools/tool_boundary.py +280 -0
- solidworks_mcp/tools/vba_generation.py +1145 -0
- solidworks_mcp/ui/__init__.py +9 -0
- solidworks_mcp/ui/local_llm.py +725 -0
- solidworks_mcp/ui/routers/__init__.py +10 -0
- solidworks_mcp/ui/routers/checkpoint.py +24 -0
- solidworks_mcp/ui/routers/docs.py +47 -0
- solidworks_mcp/ui/routers/llm.py +83 -0
- solidworks_mcp/ui/routers/local_model.py +69 -0
- solidworks_mcp/ui/routers/model.py +75 -0
- solidworks_mcp/ui/routers/preview.py +38 -0
- solidworks_mcp/ui/routers/session.py +161 -0
- solidworks_mcp/ui/routers/viewer.py +154 -0
- solidworks_mcp/ui/schemas.py +502 -0
- solidworks_mcp/ui/server.py +298 -0
- solidworks_mcp/ui/service.py +481 -0
- solidworks_mcp/ui/services/__init__.py +136 -0
- solidworks_mcp/ui/services/_utils.py +958 -0
- solidworks_mcp/ui/services/checkpoint_service.py +1110 -0
- solidworks_mcp/ui/services/docs_service.py +282 -0
- solidworks_mcp/ui/services/llm_service.py +956 -0
- solidworks_mcp/ui/services/model_service.py +459 -0
- solidworks_mcp/ui/services/parameter_repair_service.py +314 -0
- solidworks_mcp/ui/services/preview_service.py +489 -0
- solidworks_mcp/ui/services/session_service.py +1389 -0
- solidworks_mcp/utils/__init__.py +9 -0
- solidworks_mcp/utils/feature_tree_classifier.py +342 -0
- solidworks_mcp/utils/logging.py +76 -0
- solidworks_mcp/utils/validation.py +77 -0
- solidworks_mcp/version.py +8 -0
- solidworks_mcp_python-1.5.0.dist-info/METADATA +410 -0
- solidworks_mcp_python-1.5.0.dist-info/RECORD +115 -0
- solidworks_mcp_python-1.5.0.dist-info/WHEEL +4 -0
- solidworks_mcp_python-1.5.0.dist-info/entry_points.txt +3 -0
- solidworks_mcp_python-1.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|