swforge 0.9.1__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.
swforge/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ # -*- coding: utf-8 -*-
2
+ """swforge —— SolidWorks 自动化工具箱。
3
+
4
+ 把两类能力合到一起:
5
+
6
+ * **几何**:声明式 DSL → 生成 C# 早期绑定 → 编译执行(批量建模快 3~10 倍)
7
+ * **出图**:委托 AssetMate_MechDog 做标注与自检,本地负责排版与成册
8
+
9
+ 快速开始
10
+ --------
11
+ >>> from swforge import PartBuilder, FaceRule, apply_rules
12
+ >>> b = PartBuilder(r"D:\\out\\SWFORGE_支架.SLDPRT")
13
+ >>> b.box("底板", 0, 200, 0, 10, 0, 200, "steel")
14
+ >>> b.tube_y("立柱", 0, 1000, 0, 0, 150, 150, wall=6.3, color="yellow")
15
+ >>> r = b.build()
16
+ >>> r.ok, r.bodies
17
+ """
18
+
19
+ from .session import SWSession, get_session, SWError, BridgeLost
20
+ from .modeling import PartBuilder, BuildResult, Material, COLORS, preview_edges
21
+ from .resources import (document_inventory, resource_status, sweep_documents,
22
+ ensure_capacity)
23
+ from .spec import builder_from_spec
24
+ from .gb_materials import GB_MATERIALS, ensure_library, material_names
25
+ from .assembly import AssemblyBuilder, list_components, check_interference
26
+ from .interference import body_interference, check_all_pairs
27
+ from .mates import MateBuilder, list_mates
28
+ from .dims import add_overall_dimensions
29
+ from .batch import batch_drawings, collect_models, write_report
30
+ from .probe import probe_api
31
+ from .colors import FaceRule, apply_rules, read_back
32
+ from .cleanup import survey, delete_bodies, purge_giant, purge_small
33
+ from .drawing import (have_mechdog, produce, export_pdf, normalize_layout,
34
+ make_atlas, delivery_check, add_dimensions, count_dimensions)
35
+
36
+ __version__ = "0.9.1"
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ # session
41
+ "SWSession", "get_session", "SWError", "BridgeLost",
42
+ # modeling
43
+ "PartBuilder", "BuildResult", "Material", "COLORS", "builder_from_spec", "preview_edges",
44
+ "document_inventory", "resource_status", "sweep_documents", "ensure_capacity",
45
+ "GB_MATERIALS", "material_names", "ensure_library",
46
+ # assembly
47
+ "AssemblyBuilder", "list_components", "check_interference", "body_interference", "check_all_pairs",
48
+ # mates / dims
49
+ "MateBuilder", "list_mates", "add_overall_dimensions",
50
+ # batch
51
+ "batch_drawings", "collect_models", "write_report", "probe_api",
52
+ # colors
53
+ "FaceRule", "apply_rules", "read_back",
54
+ # cleanup
55
+ "survey", "delete_bodies", "purge_giant", "purge_small",
56
+ # drawing
57
+ "have_mechdog", "produce", "export_pdf", "normalize_layout",
58
+ "make_atlas", "delivery_check", "add_dimensions", "count_dimensions",
59
+ ]
60
+
swforge/assembly.py ADDED
@@ -0,0 +1,322 @@
1
+ # -*- coding: utf-8 -*-
2
+ """装配体:插入零件、固定基件、跑干涉检查。
3
+
4
+ 关键实测结论(本机 SW2025 SP5.0)
5
+ --------------------------------
6
+ 1. **零件必须先打开**再 `AddComponent5`,否则返回 None(不报错,静默失败)。
7
+ 这是本环境最反直觉的一条:直接传文件路径给一个未打开的零件,一律失败。
8
+ 2. `GetComponents(False)` 能拿到组件清单;组件的 `Name2` 形如 `a_base-1`。
9
+ 3. 干涉检查走 `IAssemblyDoc.GetInterferenceDetectionManager`,
10
+ 读完必须调用 `Done()` 释放。
11
+
12
+ 配合(Mate)
13
+ -----------
14
+ `CreateMate` / `AddMate5` 需要先选中两个实体的面,本版**没有实现自动选面**,
15
+ 只提供 `list_components()` 与 `check_interference()` 这类不依赖选择的检查。
16
+ 配合请先在 SW 界面里建立,再用本模块做干涉验证。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+
23
+ from .session import get_session, _value, SWError
24
+
25
+ __all__ = ["AssemblyBuilder", "list_components", "check_interference"]
26
+
27
+
28
+ def _abs(path: str) -> str:
29
+ if not path or not os.path.isfile(path):
30
+ raise ValueError(f"零件文件不存在:{path}")
31
+ return os.path.abspath(path)
32
+
33
+
34
+ def _default_asm_template() -> str:
35
+ cands = []
36
+ env = os.environ.get("SW_TEMPLATE_DIR")
37
+ if env:
38
+ cands.append(os.path.join(env, "Assembly.asmdot"))
39
+ base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
40
+ for year in range(2026, 2019, -1):
41
+ for vendor in ("SOLIDWORKS", "SolidWorks"):
42
+ cands.append(os.path.join(base, vendor, f"SOLIDWORKS {year}",
43
+ "templates", "gb_assembly.asmdot"))
44
+ cands.append(os.path.join(base, vendor, f"SOLIDWORKS {year}",
45
+ "templates", "Assembly.asmdot"))
46
+ for p in cands:
47
+ if os.path.isfile(p):
48
+ return p
49
+ raise RuntimeError("找不到装配模板,请设置 SW_TEMPLATE_DIR")
50
+
51
+
52
+ def _activate(sw, md):
53
+ """把指定文档设为活动文档。"""
54
+ from win32com.client import VARIANT
55
+ import pythoncom
56
+
57
+ title = str(_value(md, "GetTitle"))
58
+ err = VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
59
+ sw.ActivateDoc3(title, False, 0, err)
60
+ return sw.ActiveDoc
61
+
62
+
63
+ def _open_part(sw, path: str):
64
+ """打开零件并返回文档对象;已打开则直接复用。"""
65
+ target = os.path.normcase(os.path.abspath(path))
66
+ for d in (_value(sw, "GetDocuments") or []):
67
+ try:
68
+ cur = str(_value(d, "GetPathName"))
69
+ except Exception: # noqa: BLE001
70
+ continue
71
+ if cur and os.path.normcase(os.path.abspath(cur)) == target:
72
+ return d
73
+ from win32com.client import VARIANT
74
+ import pythoncom
75
+
76
+ err = VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
77
+ warn = VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
78
+ doc = sw.OpenDoc6(path, 1, 1, "", err, warn) # 1 = swDocPART
79
+ if doc is None:
80
+ raise SWError(f"打开零件失败:{path}")
81
+ return doc
82
+
83
+
84
+ def _fix_component(ad, comp) -> str:
85
+ """把组件固定。逐级尝试不同选择 API,返回实际生效的那条。"""
86
+ errors = []
87
+ try:
88
+ ad.ClearSelection2(True)
89
+ except Exception: # noqa: BLE001
90
+ pass
91
+ for label, sel in (
92
+ ("Select4(True,None,False)", lambda: comp.Select4(True, None, False)),
93
+ ("Select4(True,None)", lambda: comp.Select4(True, None)),
94
+ ("Select(True)", lambda: comp.Select(True)),
95
+ ("Select2(True,0)", lambda: comp.Select2(True, 0)),
96
+ ):
97
+ try:
98
+ sel()
99
+ ok = ad.FixComponent()
100
+ return f"{label} -> FixComponent={ok}"
101
+ except Exception as exc: # noqa: BLE001
102
+ errors.append(f"{label}: {type(exc).__name__}")
103
+ return "失败(" + "; ".join(errors) + ")"
104
+
105
+
106
+ class AssemblyBuilder:
107
+ """按顺序插入零件并记录,最后一次性建出装配体。
108
+
109
+ 用法::
110
+
111
+ a = AssemblyBuilder(r"D:\\out\\总装.SLDASM")
112
+ a.add(r"D:\\out\\底座.SLDPRT", x=0, y=0, z=0, fixed=True)
113
+ a.add(r"D:\\out\\立柱.SLDPRT", x=0, y=0, z=0)
114
+ r = a.build()
115
+ print(r["ok"], r["components"])
116
+ print(check_interference())
117
+ """
118
+
119
+ def __init__(self, path: str, template: str | None = None,
120
+ keep_open: bool = True):
121
+ if not str(path).lower().endswith(".sldasm"):
122
+ raise ValueError("装配体输出必须使用 .SLDASM 扩展名")
123
+ self.path = os.path.abspath(path)
124
+ self.template = template
125
+ self.keep_open = keep_open
126
+ self._items = [] # (path, x, y, z, fixed)
127
+
128
+ def add(self, part_path: str, x: float = 0.0, y: float = 0.0, z: float = 0.0,
129
+ fixed: bool = False) -> "AssemblyBuilder":
130
+ """插入零件。**坐标单位是毫米**(与全项目一致)。
131
+
132
+ SolidWorks 的 `AddComponent5` 内部要米,这里统一换算;
133
+ v0.7 及以前直接把毫米当米传,导致零件被放到 60 米外。
134
+ """
135
+ self._items.append((_abs(part_path), float(x), float(y), float(z), bool(fixed)))
136
+ return self
137
+
138
+ # ------------------------------------------------------------------ 构建
139
+ def build(self, max_documents: int = 8) -> dict:
140
+ from .resources import ensure_capacity
141
+
142
+ if not self._items:
143
+ return {"ok": False, "error": "没有要插入的零件"}
144
+ cap = ensure_capacity(need=1, limit=max_documents)
145
+ if not cap["allowed"]:
146
+ return {"ok": False, "error": cap["error"], "resource": cap}
147
+
148
+ sw = get_session().sw()
149
+ log = []
150
+ asm = None
151
+
152
+ def L(msg):
153
+ log.append(str(msg))
154
+ print(msg)
155
+
156
+ try:
157
+ tpl = self.template or _default_asm_template()
158
+ L(f"模板: {tpl}")
159
+ asm = sw.NewDocument(tpl, 0, 0, 0)
160
+ if asm is None:
161
+ return {"ok": False, "error": "新建装配文档失败", "log": "\n".join(log)}
162
+
163
+ ad = asm # 后期绑定同一个 dispatch
164
+ inserted = []
165
+ for path, x, y, z, fixed in self._items:
166
+ # ① 零件必须先打开,否则 AddComponent5 静默返回 None
167
+ _open_part(sw, path)
168
+ asm = _activate(sw, asm)
169
+ comp = None
170
+ try:
171
+ comp = ad.AddComponent5(path, 0, "", False, "",
172
+ x / 1000.0, y / 1000.0, z / 1000.0)
173
+ except Exception as exc: # noqa: BLE001
174
+ L(f"AddComponent5 异常 {os.path.basename(path)}: {exc}")
175
+ if comp is None:
176
+ try:
177
+ comp = ad.AddComponent4(path, "", x / 1000.0, y / 1000.0, z / 1000.0)
178
+ except Exception: # noqa: BLE001
179
+ pass
180
+ if comp is None:
181
+ L(f"插入失败: {os.path.basename(path)}")
182
+ continue
183
+ name = str(_value(comp, "Name2"))
184
+ L(f"插入 {name} @ ({x},{y},{z})")
185
+ if fixed:
186
+ L(f"固定 {name} -> {_fix_component(ad, comp)}")
187
+ inserted.append({"part": path, "component": name,
188
+ "position": [x, y, z], "fixed": fixed})
189
+
190
+ comps = self._components(ad)
191
+ L(f"组件数 = {len(comps)}")
192
+ ok = len(inserted) == len(self._items) and len(comps) >= len(self._items)
193
+ saved = False
194
+ if ok:
195
+ from win32com.client import VARIANT
196
+ import pythoncom
197
+ err = VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
198
+ warn = VARIANT(pythoncom.VT_BYREF | pythoncom.VT_I4, 0)
199
+ # ExportData 是 IDispatch 指针;直接传 None 会变成 VT_EMPTY 导致类型不匹配
200
+ export_data = VARIANT(pythoncom.VT_DISPATCH, None)
201
+ try:
202
+ saved = bool(asm.Extension.SaveAs(self.path, 0, 1, export_data,
203
+ err, warn))
204
+ L(f"保存 {saved} err={err.value}")
205
+ except Exception as exc: # noqa: BLE001
206
+ L(f"保存异常: {exc}")
207
+ return {
208
+ "ok": bool(ok and saved),
209
+ "path": self.path,
210
+ "components": comps,
211
+ "inserted": inserted,
212
+ "saved": saved,
213
+ "log": "\n".join(log),
214
+ }
215
+ finally:
216
+ if asm is not None and not self.keep_open:
217
+ try:
218
+ sw.CloseDoc(str(_value(asm, "GetTitle")))
219
+ except Exception: # noqa: BLE001
220
+ pass
221
+
222
+ @staticmethod
223
+ def _components(ad) -> list[dict]:
224
+ try:
225
+ raw = ad.GetComponents(False)
226
+ except Exception: # noqa: BLE001
227
+ return []
228
+ out = []
229
+ for c in (raw or []):
230
+ try:
231
+ out.append({
232
+ "name": str(_value(c, "Name2")),
233
+ "path": str(_value(c, "GetPathName")),
234
+ "suppressed": bool(_value(c, "IsSuppressed")),
235
+ })
236
+ except Exception: # noqa: BLE001
237
+ out.append({"name": str(c), "path": "", "suppressed": False})
238
+ return out
239
+
240
+
241
+ # ---------------------------------------------------------------------- 只读接口
242
+ def list_components(assembly_path: str | None = None) -> dict:
243
+ """列出当前(或指定)装配体的组件。"""
244
+ sw = get_session().sw()
245
+ md = sw.ActiveDoc
246
+ if assembly_path:
247
+ md = sw.OpenDoc(assembly_path, 2) # 2 = swDocASSEMBLY
248
+ sw.ActivateDoc(assembly_path)
249
+ md = sw.ActiveDoc
250
+ if md is None or _value(md, "GetType") != 2:
251
+ raise RuntimeError("当前文档不是装配体")
252
+ comps = AssemblyBuilder._components(md)
253
+ return {"ok": True, "path": str(_value(md, "GetPathName")),
254
+ "count": len(comps), "components": comps}
255
+
256
+
257
+ def check_interference(assembly_path: str | None = None,
258
+ treat_coincident_as_interference: bool = False) -> dict:
259
+ """跑干涉检查并返回干涉清单。
260
+
261
+ 读完后调用 `Done()` 释放管理器。
262
+ """
263
+ sw = get_session().sw()
264
+ md = sw.ActiveDoc
265
+ if assembly_path:
266
+ md = sw.OpenDoc(assembly_path, 2)
267
+ sw.ActivateDoc(assembly_path)
268
+ md = sw.ActiveDoc
269
+ if md is None or _value(md, "GetType") != 2:
270
+ return {"ok": False, "error": "当前文档不是装配体"}
271
+
272
+ # 后期绑定下这是**属性**(InterferenceDetectionManager),
273
+ # 方法名 GetInterferenceDetectionManager 取不到。
274
+ mgr = None
275
+ last = ""
276
+ for name in ("InterferenceDetectionManager", "GetInterferenceDetectionManager"):
277
+ try:
278
+ got = _value(md, name)
279
+ got = got() if callable(got) and not hasattr(got, "GetInterferenceCount") else got
280
+ if got is not None:
281
+ mgr = got
282
+ break
283
+ except Exception as exc: # noqa: BLE001
284
+ last = f"{name}: {type(exc).__name__}"
285
+ if mgr is None:
286
+ return {"ok": False, "error": f"取干涉管理器失败({last})"}
287
+ if mgr is None:
288
+ return {"ok": False, "error": "干涉管理器为空"}
289
+
290
+ try:
291
+ try:
292
+ mgr.TreatCoincidenceAsInterference = bool(treat_coincident_as_interference)
293
+ except Exception: # noqa: BLE001
294
+ pass
295
+ count = 0
296
+ items = []
297
+ try:
298
+ count = int(_value(mgr, "GetInterferenceCount") or 0)
299
+ except Exception: # noqa: BLE001
300
+ count = 0
301
+ if count:
302
+ try:
303
+ raw = mgr.GetInterferences()
304
+ except Exception: # noqa: BLE001
305
+ raw = None
306
+ for x in (raw or []):
307
+ try:
308
+ items.append({
309
+ "volume_mm3": round(float(_value(x, "Volume")) * 1e9, 3),
310
+ "components": [str(_value(c, "Name2")) for c in
311
+ (x.Components if hasattr(x, "Components") else [])],
312
+ })
313
+ except Exception: # noqa: BLE001
314
+ items.append({"raw": str(x)})
315
+ try:
316
+ mgr.Done()
317
+ except Exception: # noqa: BLE001
318
+ pass
319
+ return {"ok": True, "interference_count": count, "interferences": items,
320
+ "note": "count=0 表示无干涉;重合面是否算干涉由参数控制"}
321
+ except Exception as exc: # noqa: BLE001
322
+ return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
swforge/batch.py ADDED
@@ -0,0 +1,263 @@
1
+ # -*- coding: utf-8 -*-
2
+ """批量出图:一个文件夹的零件 → 一批带 GB 图框的工程图。
3
+
4
+ 为什么这个功能重要
5
+ ------------------
6
+ 单件出图只是"省一点手",**批量出图才是质变**:
7
+
8
+ 以前:50 个零件,每件 20~40 分钟出图 → 两三天
9
+ 现在:50 个零件,一批跑完 → 十几分钟(还要人工补公差)
10
+
11
+ 而且它天然适合演示 —— 屏幕上不是"出了一张图",是"刷出来一列图"。
12
+
13
+ 设计要点
14
+ --------
15
+ 1. **走已有的 produce() 链路**,不重造轮子,所以图框、标注、排版、PDF 全都自动带上
16
+ 2. **逐件隔离失败**:一个零件崩了不能拖垮整批,失败原因单独记
17
+ 3. **资源守卫**:SolidWorks 默认 8 个文档上限,所以每件出完就关
18
+ 4. **可中断可续跑**:已经存在的图默认跳过(`--skip-existing`),断了能接着跑
19
+ 5. **进度可见**:每件打一行,长任务最怕没反馈
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import fnmatch
25
+ import json
26
+ import os
27
+ import time
28
+ from dataclasses import dataclass, field, asdict
29
+
30
+ __all__ = ["batch_drawings", "collect_models"]
31
+
32
+
33
+ # ---------------------------------------------------------------- 模型收集
34
+ def collect_models(folder: str, pattern: str = "*.SLDPRT",
35
+ recursive: bool = False) -> list[str]:
36
+ """收集待出图的模型路径。
37
+
38
+ pattern 不区分大小写(Windows 上 .SLDPRT / .sldprt 都存在)。
39
+ """
40
+ folder = os.path.abspath(folder)
41
+ if not os.path.isdir(folder):
42
+ raise ValueError(f"不是文件夹:{folder}")
43
+
44
+ pat = pattern.lower()
45
+ out: list[str] = []
46
+
47
+ if recursive:
48
+ for dirpath, dirnames, filenames in os.walk(folder):
49
+ dirnames[:] = [d for d in dirnames if not d.startswith(".")]
50
+ for fn in filenames:
51
+ if fnmatch.fnmatch(fn.lower(), pat):
52
+ out.append(os.path.join(dirpath, fn))
53
+ else:
54
+ for fn in os.listdir(folder):
55
+ full = os.path.join(folder, fn)
56
+ if os.path.isfile(full) and fnmatch.fnmatch(fn.lower(), pat):
57
+ out.append(full)
58
+
59
+ return sorted(out)
60
+
61
+
62
+ def _release(*paths: str) -> None:
63
+ """关掉指向这些路径的已打开文档,释放 SolidWorks 文档名额。"""
64
+ try:
65
+ from .drawing import _close_open_document
66
+ except Exception:
67
+ return
68
+ for p in paths:
69
+ if not p:
70
+ continue
71
+ try:
72
+ _close_open_document(p)
73
+ except Exception:
74
+ continue
75
+
76
+
77
+ # ---------------------------------------------------------------- 结果结构
78
+ @dataclass
79
+ class ItemResult:
80
+ model: str
81
+ drawing: str = ""
82
+ ok: bool = False
83
+ skipped: bool = False
84
+ dims: int = 0
85
+ overall_done: int = 0
86
+ pdf: str = ""
87
+ pdf_ok: bool = False
88
+ seconds: float = 0.0
89
+ error: str = ""
90
+
91
+ def to_dict(self):
92
+ return asdict(self)
93
+
94
+
95
+ @dataclass
96
+ class BatchResult:
97
+ ok: bool = True
98
+ folder: str = ""
99
+ total: int = 0
100
+ succeeded: int = 0
101
+ failed: int = 0
102
+ skipped: int = 0
103
+ pdf_ok: int = 0
104
+ seconds: float = 0.0
105
+ items: list = field(default_factory=list)
106
+ error: str = ""
107
+
108
+ def to_dict(self):
109
+ d = asdict(self)
110
+ return d
111
+
112
+ def summary_line(self) -> str:
113
+ return (f"{self.succeeded}/{self.total} 成功"
114
+ f",失败 {self.failed},跳过 {self.skipped}"
115
+ f",PDF {self.pdf_ok},耗时 {self.seconds:.1f} 秒")
116
+
117
+
118
+ # ---------------------------------------------------------------- 主流程
119
+ def batch_drawings(folder: str,
120
+ out_dir: str | None = None,
121
+ pattern: str = "*.SLDPRT",
122
+ recursive: bool = False,
123
+ paper: str = "A3",
124
+ overall: bool = True,
125
+ pdf: bool = False,
126
+ skip_existing: bool = True,
127
+ limit: int = 0,
128
+ max_documents: int = 8,
129
+ progress=None) -> dict:
130
+ """批量出图。
131
+
132
+ 参数
133
+ ----
134
+ folder 模型所在文件夹
135
+ out_dir 输出文件夹(默认与模型同目录)
136
+ pattern 文件名通配,默认 `*.SLDPRT`
137
+ recursive 是否递归子目录
138
+ paper 纸张 A0~A4
139
+ overall 是否补总尺寸
140
+ pdf 是否同时导 PDF
141
+ skip_existing 目标图已存在时跳过(支持断点续跑)
142
+ limit 最多处理几个(0 = 不限,调试用)
143
+ max_documents 单批允许占用的 SW 文档数
144
+ progress 回调 fn(idx, total, item: ItemResult),用于实时打印
145
+
146
+ 返回 `BatchResult.to_dict()`。
147
+
148
+ 注意
149
+ ----
150
+ 这个函数**不吞异常**:单件失败会记进 items 并在 summary 里体现,
151
+ 但不会中断整批 —— 批量任务里"一个坏件毁掉全部"是不可接受的。
152
+ """
153
+ from .drawing import produce, add_dimensions
154
+ from .resources import sweep_documents
155
+
156
+ started = time.monotonic()
157
+ folder = os.path.abspath(folder)
158
+ out_dir = os.path.abspath(out_dir) if out_dir else folder
159
+
160
+ models = collect_models(folder, pattern=pattern, recursive=recursive)
161
+ if limit and limit > 0:
162
+ models = models[:limit]
163
+
164
+ result = BatchResult(folder=folder, total=len(models))
165
+ if not models:
166
+ result.ok = False
167
+ result.error = f"在 {folder} 里没找到匹配 {pattern} 的文件"
168
+ result.seconds = round(time.monotonic() - started, 2)
169
+ return result.to_dict()
170
+
171
+ os.makedirs(out_dir, exist_ok=True)
172
+
173
+ for idx, model in enumerate(models, 1):
174
+ item = ItemResult(model=model)
175
+ t0 = time.monotonic()
176
+
177
+ stem = os.path.splitext(os.path.basename(model))[0]
178
+ dwg = os.path.join(out_dir, stem + ".SLDDRW")
179
+ pdf_path = os.path.join(out_dir, stem + ".pdf")
180
+ item.drawing = dwg
181
+
182
+ # ---- 已存在就跳过
183
+ if skip_existing and os.path.isfile(dwg):
184
+ item.skipped = True
185
+ item.ok = True
186
+ item.seconds = 0.0
187
+ result.skipped += 1
188
+ result.items.append(item.to_dict())
189
+ if progress:
190
+ progress(idx, result.total, item)
191
+ continue
192
+
193
+ try:
194
+ # 每件开始前清一次场:SolidWorks 默认 8 文档上限,
195
+ # 批量场景下不清就会在第 8 件之后全部「静默失败」
196
+ try:
197
+ sweep_documents(dry_run=False)
198
+ except Exception:
199
+ pass
200
+
201
+ r = produce(model, dwg, paper=paper,
202
+ keep_open=True, max_documents=max_documents)
203
+ if not r.get("ok"):
204
+ item.error = str(r.get("error") or f"produce err={r.get('err')}")
205
+ item.seconds = round(time.monotonic() - t0, 2)
206
+ result.failed += 1
207
+ result.items.append(item.to_dict())
208
+ if progress:
209
+ progress(idx, result.total, item)
210
+ continue
211
+
212
+ # 标注(含总尺寸)
213
+ try:
214
+ d = add_dimensions(dwg, overall=overall)
215
+ item.dims = int(d.get("after") or 0)
216
+ ov = d.get("overall") or {}
217
+ item.overall_done = int(ov.get("done") or 0)
218
+ except Exception as exc: # noqa: BLE001
219
+ item.error = f"标注失败:{type(exc).__name__}: {exc}"
220
+
221
+ # 可选 PDF
222
+ if pdf:
223
+ try:
224
+ from .drawing import export_pdf
225
+ pr = export_pdf(dwg, pdf_path)
226
+ item.pdf = pdf_path
227
+ item.pdf_ok = bool(pr.get("ok"))
228
+ if item.pdf_ok:
229
+ result.pdf_ok += 1
230
+ except Exception as exc: # noqa: BLE001
231
+ item.error = (item.error + ";" if item.error else "") + \
232
+ f"PDF 失败:{type(exc).__name__}: {exc}"
233
+
234
+ item.ok = not item.error
235
+ if item.ok:
236
+ result.succeeded += 1
237
+ else:
238
+ result.failed += 1
239
+
240
+ except Exception as exc: # noqa: BLE001
241
+ item.error = f"{type(exc).__name__}: {exc}"
242
+ result.failed += 1
243
+ finally:
244
+ # 每件结束必须释放文档。SolidWorks 默认只允许 8 个文档同时打开,
245
+ # 批处理不清场会在第 8 件之后整批静默失败 —— 实测第 5 件就撞上了
246
+ # (模型 + 工程图各占一个,加上前一件残留)。
247
+ _release(model, dwg)
248
+ item.seconds = round(time.monotonic() - t0, 2)
249
+ result.items.append(item.to_dict())
250
+ if progress:
251
+ progress(idx, result.total, item)
252
+
253
+ result.seconds = round(time.monotonic() - started, 2)
254
+ result.ok = result.failed == 0 and result.succeeded > 0
255
+ return result.to_dict()
256
+
257
+
258
+ def write_report(result: dict, path: str) -> str:
259
+ """把批量结果写成 JSON 报告,便于事后查哪件失败了。"""
260
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
261
+ with open(path, "w", encoding="utf-8") as fh:
262
+ json.dump(result, fh, ensure_ascii=False, indent=2)
263
+ return path