dsh-engineering-ui 0.0.0-stage → 1.5.0
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.
- package/LICENSE +21 -0
- package/README.md +772 -3
- package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
- package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
- package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
- package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
- package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
- package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
- package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
- package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
- package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
- package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
- package/engineering/skills/cad-workflow/SKILL.md +132 -0
- package/engineering/skills/mode-selection/SKILL.md +660 -0
- package/engineering/skills/physics-in-loop/SKILL.md +238 -0
- package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
- package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
- package/engineering/skills/sw-design/SKILL.md +112 -0
- package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
- package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
- package/engineering/skills/sw-to-cad/SKILL.md +121 -0
- package/engineering/skills/webots-simulation/SKILL.md +58 -0
- package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
- package/engineering/tools/_plugin_paths.py +93 -0
- package/engineering/tools/_root.py +330 -0
- package/engineering/tools/_store.py +446 -0
- package/engineering/tools/ac_bridge.py +459 -0
- package/engineering/tools/ac_rules.json +21 -0
- package/engineering/tools/ac_validate.py +699 -0
- package/engineering/tools/ask_user.py +887 -0
- package/engineering/tools/build_arm1.py +46 -0
- package/engineering/tools/build_assembly.py +94 -0
- package/engineering/tools/build_base.py +57 -0
- package/engineering/tools/build_housing.py +70 -0
- package/engineering/tools/build_structural.py +43 -0
- package/engineering/tools/build_transmission.py +69 -0
- package/engineering/tools/cadx.lsp +128 -0
- package/engineering/tools/choice_contract.py +61 -0
- package/engineering/tools/conn_state.py +1671 -0
- package/engineering/tools/defense_gate.py +3374 -0
- package/engineering/tools/drawing_input.py +314 -0
- package/engineering/tools/env_check.py +467 -0
- package/engineering/tools/examples/build_axis.py +142 -0
- package/engineering/tools/examples/build_cup.py +56 -0
- package/engineering/tools/examples/build_plate.py +58 -0
- package/engineering/tools/examples/build_sphere.py +30 -0
- package/engineering/tools/examples/build_transition_duct.py +381 -0
- package/engineering/tools/gate_integrity.py +401 -0
- package/engineering/tools/mode2_flow.py +3589 -0
- package/engineering/tools/mode_checker.py +77 -0
- package/engineering/tools/mode_gate.py +8013 -0
- package/engineering/tools/part_registry.py +4266 -0
- package/engineering/tools/physics/__init__.py +50 -0
- package/engineering/tools/physics/_compat.py +12 -0
- package/engineering/tools/physics/design_state.py +142 -0
- package/engineering/tools/physics/domain_validator.py +751 -0
- package/engineering/tools/physics/fatigue.py +489 -0
- package/engineering/tools/physics/fea_solver.py +1163 -0
- package/engineering/tools/physics/feapy_solver.py +314 -0
- package/engineering/tools/physics/gb_materials.json +1228 -0
- package/engineering/tools/physics/geometry_gate.py +337 -0
- package/engineering/tools/physics/load_case.py +951 -0
- package/engineering/tools/physics/material_db.py +874 -0
- package/engineering/tools/physics/mesh_adapter.py +321 -0
- package/engineering/tools/physics/refine_rules.py +272 -0
- package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
- package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
- package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
- package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
- package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
- package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
- package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
- package/engineering/tools/physics/rules/housing_rules.json +48 -0
- package/engineering/tools/physics/rules/manifest.json +54 -0
- package/engineering/tools/physics/rules/mold_rules.json +48 -0
- package/engineering/tools/physics/rules/structural_rules.json +56 -0
- package/engineering/tools/physics/rules/transmission_rules.json +48 -0
- package/engineering/tools/physics/simulation_report.py +829 -0
- package/engineering/tools/physics_bridge.py +2685 -0
- package/engineering/tools/profile-manifest.ps1 +72 -0
- package/engineering/tools/run_regression.py +109 -0
- package/engineering/tools/settings.py +419 -0
- package/engineering/tools/solidworks-modeling.md +519 -0
- package/engineering/tools/sw_bridge.py +8215 -0
- package/engineering/tools/sw_guard.py +1066 -0
- package/engineering/tools/sw_surface.py +87 -0
- package/engineering/tools/swapi.py +14766 -0
- package/engineering/tools/verify_sync.py +218 -0
- package/engineering/tools/visual_check.py +2589 -0
- package/engineering/tools/webots_env.py +121 -0
- package/engineering/tools/webots_mcp/__init__.py +3 -0
- package/engineering/tools/webots_mcp/__main__.py +3 -0
- package/engineering/tools/webots_mcp/assembly.py +391 -0
- package/engineering/tools/webots_mcp/bootstrap.py +55 -0
- package/engineering/tools/webots_mcp/controller_worker.py +124 -0
- package/engineering/tools/webots_mcp/evidence.py +288 -0
- package/engineering/tools/webots_mcp/fake_worker.py +69 -0
- package/engineering/tools/webots_mcp/fault_resources.py +121 -0
- package/engineering/tools/webots_mcp/host.py +45 -0
- package/engineering/tools/webots_mcp/ledger.py +73 -0
- package/engineering/tools/webots_mcp/owned_job.py +70 -0
- package/engineering/tools/webots_mcp/physical_gate.py +187 -0
- package/engineering/tools/webots_mcp/physics.py +152 -0
- package/engineering/tools/webots_mcp/pipeline.py +108 -0
- package/engineering/tools/webots_mcp/recipes.py +129 -0
- package/engineering/tools/webots_mcp/repair.py +132 -0
- package/engineering/tools/webots_mcp/repair_trial.py +375 -0
- package/engineering/tools/webots_mcp/runtime.py +311 -0
- package/engineering/tools/webots_mcp/server.py +217 -0
- package/engineering/tools/webots_mcp/sw_export.py +509 -0
- package/engineering/tools/webots_mcp/transport.py +156 -0
- package/engineering/tools/workflow_gate.py +6134 -0
- package/package.json +64 -4
|
@@ -0,0 +1,2589 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""visual_check —— 「看图 → 矫正 → 继续看 → 继续矫正」的代码级强制闭环
|
|
3
|
+
|
|
4
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
本模块存在的唯一理由(用户原话)
|
|
6
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
|
|
8
|
+
「在组装的时候,组装子代理必须在成功建模之后,使用截图进行查看,
|
|
9
|
+
三视图查看建模如何,即建模子代理必须以"看图→矫正模型→继续看→
|
|
10
|
+
继续矫正"为建模成功后最为主要的矫正建模方式。
|
|
11
|
+
必须要让模型自己看到他建的是什么样子的东西」
|
|
12
|
+
|
|
13
|
+
以及用户的总原则:
|
|
14
|
+
|
|
15
|
+
「所有约束都要写在代码里强制执行,不能只写在提示词/文档里」
|
|
16
|
+
|
|
17
|
+
因此本模块把「建模后必须看图」从一个**口头约定**升级为**会返回 False、
|
|
18
|
+
能阻断放行**的代码契约。四段能力:
|
|
19
|
+
|
|
20
|
+
A. capture_views() —— 真正驱动 SolidWorks 切到 前视/上视/右视/等轴测
|
|
21
|
+
再截图,落盘并回传每张图的路径/大小/哈希。
|
|
22
|
+
B. check_and_correct() —— ★核心闭环。每一轮都【真实回读几何】
|
|
23
|
+
(body_box_mm 六元组包围盒)与 expect 比对,
|
|
24
|
+
不一致就产出结构化「矫正指令」进入下一轮;
|
|
25
|
+
达到 max_rounds 仍不一致 → FAIL,绝不上报假成功。
|
|
26
|
+
C. verify_visual_evidence() —— 「看图这件事真的发生过」的代码级证明:
|
|
27
|
+
每轮引用的截图必须真实存在于磁盘且字节数 > 0。
|
|
28
|
+
D. enforce_visual_gate() —— 建模成功之后【必须】走的门;
|
|
29
|
+
未通过时 ok=False,上游房间收尾可据此拒绝放行。
|
|
30
|
+
|
|
31
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
设计原则
|
|
33
|
+
═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
|
|
35
|
+
1. **fail-closed**:任何"取不到几何 / 截不到图 / 零件打不开"的情形一律
|
|
36
|
+
ok=False 并说明原因,**绝不返回一个不存在的文件路径当成功**。
|
|
37
|
+
2. **判据来自真实回读**,不是"截图这个动作做过了"。截图是【证据】,
|
|
38
|
+
几何回读才是【判据】—— 这正是原缺陷(把"截了图"当"建对了")的修复点。
|
|
39
|
+
3. **只依赖标准库**(json/os/sys/time/argparse/subprocess/hashlib...)。
|
|
40
|
+
对 swapi / mode2_flow 的导入一律 try/except 包住 ——
|
|
41
|
+
它们不在时本模块仍可完成逻辑校验(降级会如实标注)。
|
|
42
|
+
4. **全量留痕**:每轮的截图路径、实测包围盒、偏差、判定、矫正建议
|
|
43
|
+
都原子写(os.replace)到 out_dir/visual_loop.json,供总理子代理/父代理复查。
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import hashlib
|
|
49
|
+
import json
|
|
50
|
+
import os
|
|
51
|
+
import sys
|
|
52
|
+
import time
|
|
53
|
+
|
|
54
|
+
# ── 【编码修复】Windows 控制台默认 cp936,中文 JSON 会乱码 ──────────────
|
|
55
|
+
# 做法与 sw_guard.py 的 _setup_utf8 一致(它在导入时就 reconfigure)。
|
|
56
|
+
# 这里【不 import sw_guard】—— 该文件正被其他子代理修改,避免耦合。
|
|
57
|
+
# 同时提供 _utf8_out() 兜底:reconfigure 失败也不因编码崩掉。
|
|
58
|
+
_UTF8_READY = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _setup_utf8(force: bool = False) -> bool:
|
|
62
|
+
"""把 stdout/stderr 切到 UTF-8;幂等且静默失败(绝不影响主流程)。"""
|
|
63
|
+
global _UTF8_READY
|
|
64
|
+
if _UTF8_READY and not force:
|
|
65
|
+
return True
|
|
66
|
+
ok = False
|
|
67
|
+
for stream_name in ("stdout", "stderr"):
|
|
68
|
+
stream = getattr(sys, stream_name, None)
|
|
69
|
+
if stream is None:
|
|
70
|
+
continue
|
|
71
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
72
|
+
if reconfigure is not None:
|
|
73
|
+
try:
|
|
74
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
75
|
+
ok = True
|
|
76
|
+
continue
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
# 兜底:不支持 reconfigure 的对象,包一层 UTF-8 wrapper
|
|
80
|
+
try:
|
|
81
|
+
buf = getattr(stream, "buffer", None)
|
|
82
|
+
if buf is not None:
|
|
83
|
+
import io
|
|
84
|
+
setattr(sys, stream_name,
|
|
85
|
+
io.TextIOWrapper(buf, encoding="utf-8",
|
|
86
|
+
errors="replace", line_buffering=True))
|
|
87
|
+
ok = True
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
_UTF8_READY = ok
|
|
91
|
+
return ok
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_setup_utf8()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _utf8_out(text: str) -> None:
|
|
98
|
+
"""安全打印:即使 stdout 不是 UTF-8 也绝不因编码崩溃。"""
|
|
99
|
+
try:
|
|
100
|
+
sys.stdout.write(text + "\n")
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
except UnicodeEncodeError:
|
|
103
|
+
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
|
104
|
+
sys.stdout.write(text.encode(enc, "replace").decode(enc, "replace") + "\n")
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def emit(obj) -> None:
|
|
109
|
+
"""统一的 CLI 输出口径:json.dumps(..., ensure_ascii=False, indent=2)。"""
|
|
110
|
+
_utf8_out(json.dumps(obj, ensure_ascii=False, indent=2))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
114
|
+
# 一、★ 代码级常量:所有阈值/次数/判定都在这里,且【真正生效】
|
|
115
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
116
|
+
|
|
117
|
+
#: 默认三视图(+ 等轴测)。三视图看不出立体形状,所以必须补等轴测图。
|
|
118
|
+
DEFAULT_VIEWS = ("front", "top", "right", "iso")
|
|
119
|
+
|
|
120
|
+
#: 视图名 → SolidWorks ShowNamedView2 的 VName 字符串。
|
|
121
|
+
# 【核实来源】swapi.py 第 5211~5218 行的 `_PLANE_VIEW` 映射表用的就是这套
|
|
122
|
+
# 带 `*` 前缀的官方视图名("*Front" / "*Top" / "*Right" / "*Bottom" /
|
|
123
|
+
# "*Back" / "*Left"),第 5079 行等轴测用 `VIEW_ISO_NAME = "*Isometric"`
|
|
124
|
+
# (swapi.py 第 2724 行)。因此这里沿用同一套 VName,不自己造名字。
|
|
125
|
+
VIEW_VNAME = {
|
|
126
|
+
"front": "*Front",
|
|
127
|
+
"back": "*Back",
|
|
128
|
+
"left": "*Left",
|
|
129
|
+
"right": "*Right",
|
|
130
|
+
"top": "*Top",
|
|
131
|
+
"bottom": "*Bottom",
|
|
132
|
+
"iso": "*Isometric",
|
|
133
|
+
"isometric": "*Isometric",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#: 视图名 → ShowNamedView2 的 ViewId。
|
|
137
|
+
#
|
|
138
|
+
# ══ 【实机实测·重要修正】ViewId 必须给真实值,传 0 是【无效调用】══════
|
|
139
|
+
# 实测方法(2026-10-12,SW2025 SP5.0,零件 DSH_底座立柱):
|
|
140
|
+
# 用 SaveBMP 导出每次切换后的位图并算 sha1,看图像是否真的变了。
|
|
141
|
+
# 结果(决定性证据):
|
|
142
|
+
# *Front vid=0 → sha1=e126aa76ca02
|
|
143
|
+
# *Front vid=1 → sha1=4e3be2f2a435 ← 变了
|
|
144
|
+
# *Front vid=2 → sha1=3f50a44335db ← 变了
|
|
145
|
+
# *Top vid=0 → sha1=3f50a44335db ← 与上一张【完全相同】= 没切换
|
|
146
|
+
# *Top vid=5 → sha1=0595a644ddd0 ← 变了
|
|
147
|
+
# *Right vid=0 → sha1=0595a644ddd0 ← 与上一张【完全相同】= 没切换
|
|
148
|
+
# *Right vid=4 → sha1=5c822a33f474 ← 变了
|
|
149
|
+
# *Iso vid=0 → sha1=5c822a33f474 ← 与上一张【完全相同】= 没切换
|
|
150
|
+
# *Iso vid=7 → sha1=07e3b653b472 ← 变了
|
|
151
|
+
# 结论:`ShowNamedView2(name, 0)` 不改变视角 —— 图像原封不动地停在
|
|
152
|
+
# 上一次的取景上。三视图会因此拍成三张一模一样的图。
|
|
153
|
+
# (swapi.py:5223 的 `_normal_to` 就是传 0,所以它只对"已经在正确
|
|
154
|
+
# 基准面草图"的场景有效,不能用于本模块的"逐视图取证"。)
|
|
155
|
+
#
|
|
156
|
+
# 取值依据:swconst.tlb 的 `swStandardViews_e` 枚举【恰好有 9 个成员】
|
|
157
|
+
# (值 1..9,与官方标准视图一一对应:
|
|
158
|
+
# 前/后/左/右/上/下/等轴测/上下二等轴测/左右二等轴测)。
|
|
159
|
+
#
|
|
160
|
+
# ★★ 二次确证(2026-10-12,非对称零件像素级反推)★★
|
|
161
|
+
# 为排除"只是图变了、但视角映射错位"的可能,另造了一个
|
|
162
|
+
# **非对称**长方体 120(X) × 40(Y) × 20(Z),渲染后做像素轮廓测量,
|
|
163
|
+
# 用三个视图各自独有的宽高比反推它到底在显示哪两个轴:
|
|
164
|
+
# *Front vid=1 → 实测 px 989×331,比值 2.988 ← 理论 120/40 = 3.0 ✓
|
|
165
|
+
# *Top vid=5 → 实测 px 989×165,比值 5.994 ← 理论 120/20 = 6.0 ✓
|
|
166
|
+
# *Right vid=4 → 实测 px 381×765,比值 0.498 ← 理论 20/40 = 0.5 ✓
|
|
167
|
+
# 三者在 0.4% 像素误差内与理论值一一吻合,证明:
|
|
168
|
+
# front = XY 面(宽 X、高 Y)
|
|
169
|
+
# top = XZ 面(宽 X、高 Z)
|
|
170
|
+
# right = ZY 面(宽 Z、高 Y)
|
|
171
|
+
# 这正是 GB/T 标准三视图的正确投影关系,映射无误。
|
|
172
|
+
# (同一套取值在 Ø100×170 的圆柱上也自洽:front=正圆、
|
|
173
|
+
# top=竖长方形、right=横长方形。)
|
|
174
|
+
#
|
|
175
|
+
# ⚠️ 未经实测的取值:back=2 / left=3 / bottom=6 是按同一枚举的自然顺序
|
|
176
|
+
# 推导的,本模块**未**对它们做像素级反推确认(本次只验收了
|
|
177
|
+
# front/top/right/iso 四个)。若调用方要用这三个视图,
|
|
178
|
+
# **请先按上面的 sha1 / 轮廓比值方法自测一遍**,不要默认它们一定对。
|
|
179
|
+
VIEW_ID = {
|
|
180
|
+
"front": 1,
|
|
181
|
+
"back": 2,
|
|
182
|
+
"left": 3,
|
|
183
|
+
"right": 4,
|
|
184
|
+
"top": 5,
|
|
185
|
+
"bottom": 6,
|
|
186
|
+
"iso": 7,
|
|
187
|
+
"isometric": 7,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#: 视图名 → 该视图应当显示的「水平轴, 垂直轴」。
|
|
191
|
+
# ★ 依据:2026-10-12 用非对称长方体 120(X)×40(Y)×20(Z) 做像素级
|
|
192
|
+
# 轮廓反推确证(详见 VIEW_ID 上方的二次确证记录):
|
|
193
|
+
# front = XY 面 / top = XZ 面 / right = ZY 面。
|
|
194
|
+
#
|
|
195
|
+
# ⚠️ 【命名口径提醒 · 务必知悉】这里沿用 **SolidWorks 自己的视图/基准面
|
|
196
|
+
# 命名**,与 swapi.py:5211-5218 的 `_PLANE_VIEW` 完全一致:
|
|
197
|
+
# "Front Plane" → "*Front" (前视基准面,法向 Z,含 X、Y)
|
|
198
|
+
# "Top Plane" → "*Top" (上视基准面,法向 Y,含 X、Z)
|
|
199
|
+
# "Right Plane" → "*Right" (右视基准面,法向 X,含 Z、Y)
|
|
200
|
+
# 判据来源:在 swapi 里 `begin_sketch("Front Plane")` 后画
|
|
201
|
+
# `rect(0,0,120,40)` 并 `extrude(20)`,得到 bbox
|
|
202
|
+
# X=120 / Y=40 / Z=20 —— 说明 Front Plane 确实张成 X、Y 两轴,
|
|
203
|
+
# 与上表一致。因此本表是"与 SW 基准面定义自洽"的。
|
|
204
|
+
#
|
|
205
|
+
# 对习惯了「主视图/俯视图/左视图」严格 GB/T 心智模型的调用方:
|
|
206
|
+
# SW 的 "Front" 是 **XY 投影**。若你的零件把回转轴放在 Z 上
|
|
207
|
+
# (如本工程 DSH_底座立柱),那么 SW 的 front 视图看上去是
|
|
208
|
+
# "一个圆" —— 那是**正确**的 XY 投影,不是拍错。
|
|
209
|
+
# 本模块只保证"名字与 SW 基准面定义一致且投影可验算",
|
|
210
|
+
# 不替调用方重新定义视图语义。
|
|
211
|
+
VIEW_PLANE_AXES = {
|
|
212
|
+
"front": ("x", "y"),
|
|
213
|
+
"top": ("x", "z"),
|
|
214
|
+
"right": ("z", "y"),
|
|
215
|
+
"back": ("x", "y"),
|
|
216
|
+
"bottom": ("x", "z"),
|
|
217
|
+
"left": ("z", "y"),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#: 轮廓比值校验的允许相对误差。
|
|
221
|
+
# 像素测量本身有 ±1px 级别误差(本次实测三视图偏差最大 0.4%),
|
|
222
|
+
# 5% 足够容纳测量噪声,同时仍能抓出"拿错轴/拍错面"这种量级错误。
|
|
223
|
+
SILHOUETTE_RATIO_TOL = 0.05
|
|
224
|
+
|
|
225
|
+
#: 轮廓像素测量时的背景判定阈值。
|
|
226
|
+
# SW 视口背景是浅色渐变;模型本体是灰色填充 + 洋红高亮边线。
|
|
227
|
+
_BG_DARK = 190 # 任一通道低于此值视为"深色像素(模型/边线)"
|
|
228
|
+
_MAGENTA_MIN = 180 # 洋红判定:R、B 高
|
|
229
|
+
_MAGENTA_G_MAX = 140 # 洋红判定:G 低
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def measure_silhouette(path, step=2, max_pixels=4_000_000):
|
|
233
|
+
"""测量位图里「模型轮廓」的像素包围盒(用于验算视图投影是否正确)。
|
|
234
|
+
|
|
235
|
+
★ 为什么需要它:光看"三张图 sha1 不一样"只能证明图变了,
|
|
236
|
+
不能证明**拍对了面**。本函数量出轮廓宽高比,配合
|
|
237
|
+
`VIEW_PLANE_AXES` + `body_box_mm()` 就能算出"这个视图理论上
|
|
238
|
+
该是什么比值",从而把"拍对了"变成可验算的判据。
|
|
239
|
+
|
|
240
|
+
判据:SW 视口背景为浅色渐变,模型本体为灰色填充/洋红高亮边线,
|
|
241
|
+
因此把"任一通道明显偏暗"或"明显偏洋红"的像素视为模型像素。
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
path: 位图路径(PNG/BMP 均可,由 PIL 解码)
|
|
245
|
+
step: 采样步长(2 = 隔一个像素取一个,够精度且快)
|
|
246
|
+
max_pixels: 像素总量上限,超过则自动放大 step,避免大图卡死
|
|
247
|
+
Returns:
|
|
248
|
+
{"ok","px_w","px_h","ratio","box","sampled_step","error"?}
|
|
249
|
+
"""
|
|
250
|
+
out = {"ok": False, "px_w": 0, "px_h": 0, "ratio": None, "box": None,
|
|
251
|
+
"sampled_step": step}
|
|
252
|
+
try:
|
|
253
|
+
from PIL import Image # 可选依赖:PIL 不在就如实失败
|
|
254
|
+
except Exception as e:
|
|
255
|
+
out["error"] = "PIL 不可用,无法做轮廓测量: %r" % (e,)
|
|
256
|
+
return out
|
|
257
|
+
try:
|
|
258
|
+
im = Image.open(path).convert("RGB")
|
|
259
|
+
except Exception as e:
|
|
260
|
+
out["error"] = "打开位图失败: %r" % (e,)
|
|
261
|
+
return out
|
|
262
|
+
W, H = im.size
|
|
263
|
+
total = W * H
|
|
264
|
+
if total > max_pixels:
|
|
265
|
+
step = max(step, int((total / float(max_pixels)) ** 0.5) + 1)
|
|
266
|
+
out["sampled_step"] = step
|
|
267
|
+
px = im.load()
|
|
268
|
+
minx, miny, maxx, maxy = W, H, -1, -1
|
|
269
|
+
for y in range(0, H, step):
|
|
270
|
+
for x in range(0, W, step):
|
|
271
|
+
try:
|
|
272
|
+
r, g, b = px[x, y]
|
|
273
|
+
except Exception:
|
|
274
|
+
continue
|
|
275
|
+
is_magenta = (r > _MAGENTA_MIN and b > _MAGENTA_MIN
|
|
276
|
+
and g < _MAGENTA_G_MAX)
|
|
277
|
+
is_dark = (r < _BG_DARK and g < _BG_DARK and b < _BG_DARK)
|
|
278
|
+
if is_magenta or is_dark:
|
|
279
|
+
if x < minx:
|
|
280
|
+
minx = x
|
|
281
|
+
if y < miny:
|
|
282
|
+
miny = y
|
|
283
|
+
if x > maxx:
|
|
284
|
+
maxx = x
|
|
285
|
+
if y > maxy:
|
|
286
|
+
maxy = y
|
|
287
|
+
if maxx < 0:
|
|
288
|
+
out["error"] = "未在图中找到模型轮廓像素(可能是空白图或全背景)"
|
|
289
|
+
return out
|
|
290
|
+
w, h = maxx - minx + 1, maxy - miny + 1
|
|
291
|
+
out.update({"ok": True, "px_w": w, "px_h": h,
|
|
292
|
+
"ratio": round(w / float(h), 4) if h else None,
|
|
293
|
+
"box": [minx, miny, maxx, maxy], "image_size": [W, H]})
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def expected_view_ratio(view, bbox_mm):
|
|
298
|
+
"""某视图的【理论】轮廓宽高比 = 水平轴尺寸 / 垂直轴尺寸。
|
|
299
|
+
|
|
300
|
+
Returns: float 或 None(视图/包围盒不可用时)。
|
|
301
|
+
"""
|
|
302
|
+
axes = VIEW_PLANE_AXES.get(view)
|
|
303
|
+
if not axes or not bbox_mm or len(bbox_mm) != 6:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
idx = {"x": 0, "y": 1, "z": 2}
|
|
307
|
+
h_ax, v_ax = axes
|
|
308
|
+
hi = idx[h_ax]
|
|
309
|
+
vi = idx[v_ax]
|
|
310
|
+
h_len = float(bbox_mm[2 * hi + 1]) - float(bbox_mm[2 * hi])
|
|
311
|
+
v_len = float(bbox_mm[2 * vi + 1]) - float(bbox_mm[2 * vi])
|
|
312
|
+
if v_len <= 0 or h_len <= 0:
|
|
313
|
+
return None
|
|
314
|
+
return round(h_len / v_len, 4)
|
|
315
|
+
except Exception:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def check_view_projection(views, bbox_mm, tol=None):
|
|
320
|
+
"""★ 验算「三视图真的拍对了面」—— 而不只是「三张图不一样」。
|
|
321
|
+
|
|
322
|
+
原理:对每个视图量出轮廓像素宽高比,与
|
|
323
|
+
`VIEW_PLANE_AXES` 指定的投影面理论比值(由真实包围盒算出)比对。
|
|
324
|
+
例如 120×40×20 的长方体:front 应 ≈3.0、top 应 ≈6.0、right 应 ≈0.5。
|
|
325
|
+
若视图拍错了面(例如把上视当前视),比值会明显对不上 → 判失败。
|
|
326
|
+
|
|
327
|
+
★ 这是对"三视图"最实质的校验:杜绝"三张图各不相同、但全都不是
|
|
328
|
+
正确的三视图"这种更隐蔽的假成功。
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
views: capture_views 返回的 `views` 字典(每项含 path)
|
|
332
|
+
bbox_mm: 真实包围盒六元组(来自 read_actual_geometry)
|
|
333
|
+
tol: 允许相对误差,默认 SILHOUETTE_RATIO_TOL
|
|
334
|
+
Returns:
|
|
335
|
+
{"ok","verified","tolerance","checked":[...],"failed":[...],
|
|
336
|
+
"skipped":[...],"note"}
|
|
337
|
+
★ `ok` = 验算做过且【全部通过】
|
|
338
|
+
★ `verified` = 验算是否真的做成了(至少一个视图完成比对且无失败)
|
|
339
|
+
—— 用来区分「验算失败(拍错面)」与「压根没验成(缺包围盒/
|
|
340
|
+
PIL 不可用/退化尺寸)」。这两者绝不能混为一谈:
|
|
341
|
+
前者要拦,后者应如实标注"未验算"而不是判失败。
|
|
342
|
+
"""
|
|
343
|
+
tol = SILHOUETTE_RATIO_TOL if tol is None else float(tol)
|
|
344
|
+
out = {"ok": False, "verified": False, "tolerance": tol,
|
|
345
|
+
"checked": [], "failed": [], "skipped": [], "note": ""}
|
|
346
|
+
if not views:
|
|
347
|
+
out["note"] = "没有视图可验算"
|
|
348
|
+
return out
|
|
349
|
+
if not bbox_mm or len(bbox_mm) != 6:
|
|
350
|
+
out["note"] = "没有真实包围盒,无法算出理论比值 —— 跳过投影验算"
|
|
351
|
+
return out
|
|
352
|
+
for v, rec in views.items():
|
|
353
|
+
if v not in VIEW_PLANE_AXES:
|
|
354
|
+
out["skipped"].append({"view": v, "why": "等轴测/无投影面定义,不适用"})
|
|
355
|
+
continue
|
|
356
|
+
path = (rec or {}).get("path")
|
|
357
|
+
exp = expected_view_ratio(v, bbox_mm)
|
|
358
|
+
if exp is None:
|
|
359
|
+
out["skipped"].append({"view": v, "why": "算不出理论比值(退化尺寸)"})
|
|
360
|
+
continue
|
|
361
|
+
ms = measure_silhouette(path) if path and os.path.exists(path) else \
|
|
362
|
+
{"ok": False, "error": "文件不存在: %s" % path}
|
|
363
|
+
if not ms.get("ok"):
|
|
364
|
+
out["skipped"].append({"view": v, "why": ms.get("error")})
|
|
365
|
+
continue
|
|
366
|
+
got = ms.get("ratio")
|
|
367
|
+
try:
|
|
368
|
+
rel = abs(got - exp) / exp if (got and exp) else None
|
|
369
|
+
except Exception:
|
|
370
|
+
rel = None
|
|
371
|
+
item = {"view": v, "axes": list(VIEW_PLANE_AXES[v]),
|
|
372
|
+
"expected_ratio": exp, "measured_ratio": got,
|
|
373
|
+
"px_w": ms["px_w"], "px_h": ms["px_h"],
|
|
374
|
+
"rel_error": (round(rel, 4) if rel is not None else None),
|
|
375
|
+
"within_tolerance": (rel is not None and rel <= tol)}
|
|
376
|
+
(out["checked"] if item["within_tolerance"]
|
|
377
|
+
else out["failed"]).append(item)
|
|
378
|
+
out["ok"] = bool(out["checked"]) and not out["failed"]
|
|
379
|
+
out["verified"] = bool(out["checked"]) or bool(out["failed"])
|
|
380
|
+
if out["ok"]:
|
|
381
|
+
out["note"] = ("%d 个视图的轮廓宽高比与理论投影一致 —— "
|
|
382
|
+
"三视图确实拍对了面。" % len(out["checked"]))
|
|
383
|
+
elif out["failed"]:
|
|
384
|
+
out["note"] = ("以下视图的轮廓比例与理论投影不符,疑似拍错面:%s"
|
|
385
|
+
% "、".join("%s(实测%s/理论%s)"
|
|
386
|
+
% (x["view"], x["measured_ratio"],
|
|
387
|
+
x["expected_ratio"])
|
|
388
|
+
for x in out["failed"]))
|
|
389
|
+
else:
|
|
390
|
+
out["note"] = ("未能完成投影验算(%d 个视图被跳过)—— "
|
|
391
|
+
"如实标注为「未验算」,不等于验算失败。"
|
|
392
|
+
% len(out["skipped"]))
|
|
393
|
+
out["verified"] = False
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _is_orthographic(view):
|
|
398
|
+
"""该视图是否属于正投影视图(等轴测不参与轮廓比例验算)。"""
|
|
399
|
+
return view in VIEW_PLANE_AXES
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
#: 视图名 → 截图文件名的中文标签(文件名可读性)。
|
|
403
|
+
VIEW_LABEL_CN = {
|
|
404
|
+
"front": "前视", "back": "后视", "left": "左视", "right": "右视",
|
|
405
|
+
"top": "上视", "bottom": "下视", "iso": "等轴测",
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#: 文件名里禁止出现的字符(Windows 非法字符 + 路径分隔符)。
|
|
409
|
+
_FS_BAD_CHARS = '<>:"/\\|?*'
|
|
410
|
+
|
|
411
|
+
# ★ 几何比对容差(mm)。包围盒六元组的每一个面都要求 |实测 - 期望| <= 此值。
|
|
412
|
+
# 取 0.05mm 的理由:本工程口径是 mm,SW 建模脚本的坐标本身就是
|
|
413
|
+
# 0.01mm 量级的十进制数,0.05mm 足够吸收浮点/换算噪声,
|
|
414
|
+
# 同时远小于任何一个真实设计尺寸误差(不可能"把 5mm 的偏差放过去")。
|
|
415
|
+
BBOX_TOL_MM = 0.05
|
|
416
|
+
|
|
417
|
+
# ★ 闭环默认最大轮数(用户口径:看图→矫正→继续看→继续矫正)。
|
|
418
|
+
MAX_ROUNDS_DEFAULT = 3
|
|
419
|
+
|
|
420
|
+
# ★ 硬上限:防止上游传 max_rounds=10000 把闭环变成"无限重试",绕过纠偏语义。
|
|
421
|
+
MAX_ROUNDS_HARD_CAP = 10
|
|
422
|
+
|
|
423
|
+
# ★ 截图文件必须大于这个字节数才算"真的截到了图"。
|
|
424
|
+
# 0 字节当然是假的;这里再往上抬一点,拦掉"只写了 PNG 魔数/文件头"的
|
|
425
|
+
# 假截图(真实 SW 截图都是几十 KB 起步)。
|
|
426
|
+
MIN_SHOT_BYTES = 64
|
|
427
|
+
|
|
428
|
+
# ★ 切完视图后、截图前必须等待的秒数。
|
|
429
|
+
# 实机教训(2026-10-12,SW2025 SP5.0):ShowNamedView2 之后立刻调
|
|
430
|
+
# ViewZoomtofit2 再立刻截图,SW 可能还没重绘完 —— 抓到的取景是
|
|
431
|
+
# 过渡帧(零件被放到巨大/偏离),虽然文件非空、swapi 也报 ok=True,
|
|
432
|
+
# 但那张图对"看形状"没有价值。留一个可调的静置时间。
|
|
433
|
+
VIEW_SETTLE_SEC = 0.8
|
|
434
|
+
|
|
435
|
+
# ★ 截图前等待 SW 窗口尺寸稳定的参数。
|
|
436
|
+
# 实机教训(2026-10-12):get_sw() 会调 _show_main_window(maximize=True),
|
|
437
|
+
# 窗口最大化动画期间抓屏,会得到与后续不一致的尺寸 —— 实测同一轮里
|
|
438
|
+
# 第一张图是 1707x1019、后三张是 2560x1528,且第一张的取景是错的。
|
|
439
|
+
# 这里在截图前轮询窗口矩形,连续 STABLE_POLLS 次不变才认为稳定。
|
|
440
|
+
WINDOW_STABLE_POLLS = 3
|
|
441
|
+
WINDOW_STABLE_TIMEOUT_SEC = 8.0
|
|
442
|
+
|
|
443
|
+
# ★ 是否在首次截图前强制把 SW 主窗口最大化并等其稳定。
|
|
444
|
+
# 实机实测(2026-10-12):SW 启动后主窗口先是 1707x1019(非最大化),
|
|
445
|
+
# 只有等到某次交互之后才变成 2560x1528(最大化)。若不等,
|
|
446
|
+
# 第一张图必然是"未最大化窗口 + 旧取景",对看模型毫无价值。
|
|
447
|
+
MAXIMIZE_BEFORE_SHOT = True
|
|
448
|
+
|
|
449
|
+
#: 判定取值
|
|
450
|
+
VERDICT_PASS = "PASS"
|
|
451
|
+
VERDICT_MISMATCH = "MISMATCH"
|
|
452
|
+
VERDICT_FAIL = "FAIL" # 轮次用尽仍不一致(与 PASS 对称)
|
|
453
|
+
VERDICT_ERROR = "ERROR" # 连几何/截图都取不到(比 MISMATCH 更早的失败)
|
|
454
|
+
|
|
455
|
+
#: 证据文件名(闭环留痕 / 强制门证据)
|
|
456
|
+
LOOP_FILE = "visual_loop.json"
|
|
457
|
+
GATE_FILE = "visual_gate.json"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
461
|
+
# 二、可选依赖(一律 try/except;不可用时降级并【如实标注】)
|
|
462
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
463
|
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
464
|
+
if BASE_DIR not in sys.path:
|
|
465
|
+
sys.path.insert(0, BASE_DIR)
|
|
466
|
+
|
|
467
|
+
_SWAPI = None
|
|
468
|
+
_SWAPI_ERR = None
|
|
469
|
+
_MODE2 = None
|
|
470
|
+
_MODE2_ERR = None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _try_import_swapi():
|
|
474
|
+
"""尝试加载 swapi(SolidWorks 桥接)。返回 (module_or_None, error_or_None)。"""
|
|
475
|
+
global _SWAPI, _SWAPI_ERR
|
|
476
|
+
if _SWAPI is not None or _SWAPI_ERR is not None:
|
|
477
|
+
return _SWAPI, _SWAPI_ERR
|
|
478
|
+
try:
|
|
479
|
+
import swapi as _m
|
|
480
|
+
_SWAPI = _m
|
|
481
|
+
_SWAPI_ERR = None
|
|
482
|
+
except Exception as e: # pragma: no cover - 环境相关
|
|
483
|
+
_SWAPI = None
|
|
484
|
+
_SWAPI_ERR = "%s: %s" % (type(e).__name__, e)
|
|
485
|
+
return _SWAPI, _SWAPI_ERR
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _try_import_mode2():
|
|
489
|
+
"""尝试加载 mode2_flow(模式二强制契约)。
|
|
490
|
+
|
|
491
|
+
⚠️ mode2_flow / part_registry 正在被其他子代理修改,
|
|
492
|
+
这里【只读导入】且用 try/except 包住 —— 不在时本模块必须照常工作。
|
|
493
|
+
"""
|
|
494
|
+
global _MODE2, _MODE2_ERR
|
|
495
|
+
if _MODE2 is not None or _MODE2_ERR is not None:
|
|
496
|
+
return _MODE2, _MODE2_ERR
|
|
497
|
+
try:
|
|
498
|
+
import mode2_flow as _m
|
|
499
|
+
_MODE2 = _m
|
|
500
|
+
_MODE2_ERR = None
|
|
501
|
+
except Exception as e: # pragma: no cover - 环境相关
|
|
502
|
+
_MODE2 = None
|
|
503
|
+
_MODE2_ERR = "%s: %s" % (type(e).__name__, e)
|
|
504
|
+
return _MODE2, _MODE2_ERR
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
508
|
+
# 三、通用小工具
|
|
509
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
510
|
+
def _now_str():
|
|
511
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
515
|
+
# 【Bug⑨】长任务自动推进房间阶段(看图矫正可能跑很多轮,主代理需要看得见)
|
|
516
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
517
|
+
def push_room_validating(room=None, what=""):
|
|
518
|
+
"""在【房间上下文】里把 room-report 的 stage 推进到 validating。
|
|
519
|
+
|
|
520
|
+
现场缺陷(竞速小车任务,Bug⑨):
|
|
521
|
+
底盘结构件房连续 74 分钟的 room-report 都停在 modeling,而实际早已
|
|
522
|
+
进入"物理校核 + 看图矫正"阶段。看图闭环(check_and_correct)本身
|
|
523
|
+
可能跑 3~10 轮、每轮都要切视图+截图+回读几何,是典型的长任务;
|
|
524
|
+
期间完全不碰 room-report → 主代理无法区分"在矫正"与"已僵死"。
|
|
525
|
+
|
|
526
|
+
行为契约(幂等 / 不覆盖终态 / 失败不阻断,细节见 mode_gate.push_room_stage):
|
|
527
|
+
① 房间必须存在且 active(only_if_active)→ 否则不写任何文件;
|
|
528
|
+
② 最新 stage 已是 validating → 幂等跳过;
|
|
529
|
+
③ 已上报 done/failed → 不让步;
|
|
530
|
+
④ 本函数【不抛异常】,任何失败都折成 ok=False 返回。
|
|
531
|
+
|
|
532
|
+
⚠️ 与 mode_gate 之间是【单向弱依赖】:只在运行时按需 import,
|
|
533
|
+
模块不可用时本模块照常工作(看图闭环不受影响)。
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
room: 房间名;None → 取 DSH_ROOM 环境变量。
|
|
537
|
+
what: 正在做什么(写进 detail)。
|
|
538
|
+
Returns:
|
|
539
|
+
{"ok", "pushed", "skipped", "room", "stage", "reason"?}
|
|
540
|
+
"""
|
|
541
|
+
out = {"ok": False, "pushed": False, "skipped": None, "room": None,
|
|
542
|
+
"stage": "validating"}
|
|
543
|
+
try:
|
|
544
|
+
_room = (room or os.environ.get("DSH_ROOM") or "").strip()
|
|
545
|
+
out["room"] = _room or None
|
|
546
|
+
if not _room:
|
|
547
|
+
out["skipped"] = "no-room-context"
|
|
548
|
+
out["reason"] = "无房间上下文(room 未传且 DSH_ROOM 未设置)"
|
|
549
|
+
return out
|
|
550
|
+
try:
|
|
551
|
+
import mode_gate as _mg # 同目录优先(BASE_DIR 已在 sys.path)
|
|
552
|
+
except Exception as e:
|
|
553
|
+
out["skipped"] = "no-mode-gate"
|
|
554
|
+
out["reason"] = "mode_gate 不可用: %r" % (e,)
|
|
555
|
+
return out
|
|
556
|
+
if not hasattr(_mg, "push_room_stage"):
|
|
557
|
+
out["skipped"] = "mode-gate-too-old"
|
|
558
|
+
out["reason"] = "mode_gate 缺少 push_room_stage(旧版本副本?)"
|
|
559
|
+
return out
|
|
560
|
+
_detail = (str(what).strip() or "看图矫正中") + "(自动推进,Bug⑨)"
|
|
561
|
+
return _mg.push_room_stage(_room, "validating", _detail)
|
|
562
|
+
except Exception as e:
|
|
563
|
+
out["skipped"] = "exception"
|
|
564
|
+
out["reason"] = repr(e)
|
|
565
|
+
return out
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _safe_name(text, fallback="part"):
|
|
569
|
+
"""把零件名/视图名清洗成可安全用作文件名的字符串。"""
|
|
570
|
+
s = str(text or "").strip()
|
|
571
|
+
for ch in _FS_BAD_CHARS:
|
|
572
|
+
s = s.replace(ch, "_")
|
|
573
|
+
s = s.replace(" ", "_").strip("._")
|
|
574
|
+
return s or fallback
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def atomic_write_json(path, data):
|
|
578
|
+
"""原子写 JSON:先写临时文件再 os.replace(避免并发读到半个文件)。
|
|
579
|
+
|
|
580
|
+
写法与 mode2_flow._atomic_write_json(第 166~171 行)保持一致。
|
|
581
|
+
"""
|
|
582
|
+
d = os.path.dirname(os.path.abspath(path))
|
|
583
|
+
if d:
|
|
584
|
+
os.makedirs(d, exist_ok=True)
|
|
585
|
+
tmp = "%s.tmp.%d" % (path, os.getpid())
|
|
586
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
587
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
588
|
+
os.replace(tmp, path)
|
|
589
|
+
return path
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def read_json(path):
|
|
593
|
+
"""读 JSON(兼容 BOM)。失败返回 (None, 错误说明)。"""
|
|
594
|
+
try:
|
|
595
|
+
with open(path, "r", encoding="utf-8-sig") as f:
|
|
596
|
+
return json.load(f), None
|
|
597
|
+
except Exception as e:
|
|
598
|
+
return None, "%s: %s" % (type(e).__name__, e)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _file_sha1(path, limit=None):
|
|
602
|
+
"""算文件 sha1(limit 非空时只算前 limit 字节),失败返回 None。
|
|
603
|
+
|
|
604
|
+
══ 【实机事故·不要用 limit 做"内容是否相同"的判据】══════════════════════
|
|
605
|
+
本函数原本被 file_probe 以 `limit=1<<20`(只哈希前 1MB)调用,用来给
|
|
606
|
+
"重复视图检测"提供指纹。实测(2026-10-12,DSH_悬臂支架)证明这会【误报】:
|
|
607
|
+
|
|
608
|
+
SaveBMP 产出的 BMP 是 4320054 字节,而 **BMP 像素按【自底向上】存储** ——
|
|
609
|
+
文件前段是文件头 + 图像【底部】的行。front 与 top 两张图虽然整体
|
|
610
|
+
完全不同(全文件 sha1 分别 55c0009dec75 / 52951e5ae8d1),
|
|
611
|
+
但它们底部都是空白区域,于是:
|
|
612
|
+
全文件 sha1 : front=55c0009dec75 top=52951e5ae8d1 ← 不同(正确)
|
|
613
|
+
前 1MB sha1 : front=350a1b10f2db top=350a1b10f2db ← 【相同】(假阳性)
|
|
614
|
+
结果"重复视图检测"把一个**完全正常的采集**判成了失败,
|
|
615
|
+
错误信息还误导成"ViewId 传了 0"(而 ViewId 其实是对的)。
|
|
616
|
+
|
|
617
|
+
结论:`limit` 只适合"快速取一个变更指纹"(如文件是否被碰过),
|
|
618
|
+
**绝不能**用于"两张图内容是否相同"的判定 —— 那必须覆盖整幅像素。
|
|
619
|
+
"""
|
|
620
|
+
try:
|
|
621
|
+
h = hashlib.sha1()
|
|
622
|
+
with open(path, "rb") as f:
|
|
623
|
+
if limit:
|
|
624
|
+
h.update(f.read(limit))
|
|
625
|
+
else:
|
|
626
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
627
|
+
h.update(chunk)
|
|
628
|
+
return h.hexdigest()
|
|
629
|
+
except Exception:
|
|
630
|
+
return None
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def file_probe(path):
|
|
634
|
+
"""探测一个"截图文件"的真实存在性 —— 这是 verify 的最小单元。
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
{"path","exists","size_bytes","is_file","sha1_12","sha1_full_12","ok","why"}
|
|
638
|
+
ok=True 仅当:路径存在 + 是普通文件 + 字节数 > MIN_SHOT_BYTES。
|
|
639
|
+
|
|
640
|
+
★ 两个指纹的分工(不要混用 —— 混用会误报,实测已栽过):
|
|
641
|
+
· `sha1_12` = 前 1MB 的指纹。**只**用于"这个文件后来被改过吗"
|
|
642
|
+
一类轻量判断,快但**不能**代表整幅图像内容。
|
|
643
|
+
· `sha1_full_12` = **全文件**指纹。用于"两张图内容是否相同"
|
|
644
|
+
(重复视图检测必须用它)。
|
|
645
|
+
"""
|
|
646
|
+
out = {"path": path, "exists": False, "size_bytes": 0,
|
|
647
|
+
"is_file": False, "sha1_12": None, "sha1_full_12": None,
|
|
648
|
+
"ok": False, "why": ""}
|
|
649
|
+
if not path or not isinstance(path, str):
|
|
650
|
+
out["why"] = "路径为空或类型错误(%r)" % (path,)
|
|
651
|
+
return out
|
|
652
|
+
p = os.path.abspath(path)
|
|
653
|
+
out["path"] = p
|
|
654
|
+
try:
|
|
655
|
+
out["exists"] = os.path.exists(p)
|
|
656
|
+
out["is_file"] = os.path.isfile(p)
|
|
657
|
+
if out["is_file"]:
|
|
658
|
+
out["size_bytes"] = int(os.path.getsize(p))
|
|
659
|
+
out["sha1_12"] = (_file_sha1(p, limit=1 << 20) or "")[:12] or None
|
|
660
|
+
# 全文件指纹:重复视图检测依赖它(前 1MB 会假阳性,见 _file_sha1 注释)
|
|
661
|
+
out["sha1_full_12"] = (_file_sha1(p) or "")[:12] or None
|
|
662
|
+
except Exception as e:
|
|
663
|
+
out["why"] = "文件探测异常: %s: %s" % (type(e).__name__, e)
|
|
664
|
+
return out
|
|
665
|
+
if not out["exists"]:
|
|
666
|
+
out["why"] = "文件不存在于磁盘(可能是「假装截了图」)"
|
|
667
|
+
return out
|
|
668
|
+
if not out["is_file"]:
|
|
669
|
+
out["why"] = "路径不是普通文件(可能是目录/符号链接)"
|
|
670
|
+
return out
|
|
671
|
+
if out["size_bytes"] <= 0:
|
|
672
|
+
out["why"] = "文件是 0 字节(空文件,等于没截到图)"
|
|
673
|
+
return out
|
|
674
|
+
if out["size_bytes"] <= MIN_SHOT_BYTES:
|
|
675
|
+
out["why"] = ("文件只有 %d 字节,小于 %d 字节下限 —— "
|
|
676
|
+
"极可能不是真实截图" % (out["size_bytes"], MIN_SHOT_BYTES))
|
|
677
|
+
return out
|
|
678
|
+
# ── ★ 内容格式校验:必须是真实位图,不能是随便写的填充字节 ──────────
|
|
679
|
+
# 支持的两种取证通道产物:PNG(屏幕抓取)/ BMP(SW 内置渲染)。
|
|
680
|
+
# 这一步拦掉「往文件里写点垃圾冒充截图」。
|
|
681
|
+
try:
|
|
682
|
+
with open(p, "rb") as f:
|
|
683
|
+
head = f.read(16)
|
|
684
|
+
except Exception as e:
|
|
685
|
+
out["why"] = "读取文件头失败: %r" % (e,)
|
|
686
|
+
return out
|
|
687
|
+
out["magic"] = head[:4].hex()
|
|
688
|
+
if head.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
689
|
+
out["format"] = "PNG"
|
|
690
|
+
elif head.startswith(b"BM"):
|
|
691
|
+
out["format"] = "BMP"
|
|
692
|
+
elif head.startswith(b"\xff\xd8\xff"):
|
|
693
|
+
out["format"] = "JPEG"
|
|
694
|
+
elif head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
695
|
+
out["format"] = "WEBP"
|
|
696
|
+
else:
|
|
697
|
+
out["why"] = ("文件头 magic=%s 不是已知位图格式(PNG/BMP/JPEG/WEBP)"
|
|
698
|
+
"—— 怀疑是伪造的截图文件" % head[:4].hex())
|
|
699
|
+
return out
|
|
700
|
+
out["ok"] = True
|
|
701
|
+
out["why"] = "文件真实存在、大小合格、且是合法 %s 位图" % out["format"]
|
|
702
|
+
return out
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _default_out_dir(part_path=None, tag="visual"):
|
|
706
|
+
"""默认输出目录:工作目录/visual_check/<零件名>_<时间戳>。"""
|
|
707
|
+
base = os.getcwd()
|
|
708
|
+
stem = _safe_name(os.path.splitext(os.path.basename(part_path or ""))[0]
|
|
709
|
+
or tag)
|
|
710
|
+
out = os.path.join(base, "visual_check", "%s_%s" % (stem, time.strftime("%Y%m%d_%H%M%S")))
|
|
711
|
+
os.makedirs(out, exist_ok=True)
|
|
712
|
+
return out
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
716
|
+
# 四、A. 三视图截图采集
|
|
717
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
718
|
+
def _normalize_views(views=None):
|
|
719
|
+
"""把 views 参数规整为合法视图名列表(保序去重)。
|
|
720
|
+
|
|
721
|
+
非法视图名【直接拒绝】而不是静默忽略 —— 静默忽略会让调用方以为
|
|
722
|
+
「我要的图都截到了」,实际少了一张,正是本模块要杜绝的假成功。
|
|
723
|
+
"""
|
|
724
|
+
if views is None:
|
|
725
|
+
return list(DEFAULT_VIEWS), []
|
|
726
|
+
if isinstance(views, str):
|
|
727
|
+
raw = [v.strip() for v in views.split(",") if v.strip()]
|
|
728
|
+
else:
|
|
729
|
+
raw = [str(v).strip() for v in views if str(v).strip()]
|
|
730
|
+
good, bad = [], []
|
|
731
|
+
for v in raw:
|
|
732
|
+
key = v.lower()
|
|
733
|
+
if key in VIEW_VNAME:
|
|
734
|
+
key = "iso" if key == "isometric" else key
|
|
735
|
+
if key not in good:
|
|
736
|
+
good.append(key)
|
|
737
|
+
else:
|
|
738
|
+
bad.append(v)
|
|
739
|
+
return good, bad
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _shot_path(out_dir, part_path, view, round_no=None):
|
|
743
|
+
"""截图落盘的基础路径:DSH_<零件名>__<视图>.png(文件名可区分视图)。
|
|
744
|
+
|
|
745
|
+
⚠️ 这只是【基础路径】。真实扩展名由取证通道决定:
|
|
746
|
+
· SaveBMP 通道 → 实际写 .bmp(且 SaveBMP 会忽略传入的扩展名,
|
|
747
|
+
只认 .bmp 才不会张冠李戴);
|
|
748
|
+
· 屏幕抓取通道 → .png。
|
|
749
|
+
调用方必须采信 `_grab_screenshot` 回的 `path`,不要想当然用这个基础路径。
|
|
750
|
+
|
|
751
|
+
round_no 非空时落到 out_dir/round_<N>/ 子目录 ——
|
|
752
|
+
★ 这是必须的:闭环每轮都要重新截图,如果共用同一个文件名,
|
|
753
|
+
第 2 轮会把第 1 轮的图【覆盖】掉,于是 round 1 的记录指向的磁盘文件
|
|
754
|
+
其实是 round 2 的内容 —— 证据链被悄悄篡改,而 verify 还查不出来。
|
|
755
|
+
分轮目录让每轮证据各自独立、可复核。
|
|
756
|
+
"""
|
|
757
|
+
stem = _safe_name(os.path.splitext(os.path.basename(part_path or ""))[0]
|
|
758
|
+
or "part")
|
|
759
|
+
# 零件名本身常已带 DSH_ 前缀(本工程默认前缀就是 DSH_)——
|
|
760
|
+
# 再拼一次会得到 DSH_DSH_xxx 这种重复前缀,文件名难看且不利于检索。
|
|
761
|
+
if not stem.upper().startswith("DSH_"):
|
|
762
|
+
stem = "DSH_" + stem
|
|
763
|
+
base = out_dir
|
|
764
|
+
if round_no is not None:
|
|
765
|
+
base = os.path.join(out_dir, "round_%d" % int(round_no))
|
|
766
|
+
try:
|
|
767
|
+
os.makedirs(base, exist_ok=True)
|
|
768
|
+
except Exception:
|
|
769
|
+
base = out_dir # 建不了子目录就退回原目录(不致命)
|
|
770
|
+
return os.path.join(base, "%s__%s.png" % (stem, view))
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _apply_view(m, view):
|
|
774
|
+
"""★ 真正驱动 SolidWorks 切到指定视图。
|
|
775
|
+
|
|
776
|
+
【核实过的调用链(不是猜的)】
|
|
777
|
+
· swapi.py:5076-5090 `set_view_iso()` → `self.model.ShowNamedView2(
|
|
778
|
+
VIEW_ISO_NAME, VIEW_ISO_ID)`,即 `ShowNamedView2("*Isometric", 7)`,
|
|
779
|
+
随后 `ViewZoomtofit2()` + `ActiveView.ZoomByFactor(1.2)`。
|
|
780
|
+
· swapi.py:5220-5230 `_normal_to(view_name)` → `self.model.ShowNamedView2(
|
|
781
|
+
view_name, 0)` 再 `ViewZoomtofit2()`。
|
|
782
|
+
· swapi.py:5211-5218 `_PLANE_VIEW` 给出 "*Front"/"*Top"/"*Right" 等 VName。
|
|
783
|
+
|
|
784
|
+
本函数按上面既有写法实现:等轴测走 set_view_iso();其余标准视图
|
|
785
|
+
走 ShowNamedView2(vname, VIEW_ID[view]) + ViewZoomtofit2()。
|
|
786
|
+
|
|
787
|
+
★ 注意:这里【不照抄】swapi `_normal_to` 的 vid=0 —— 实机实测证明
|
|
788
|
+
vid=0 是无效调用,图像不会切换(详见 VIEW_ID 上的实测记录)。
|
|
789
|
+
"""
|
|
790
|
+
vname = VIEW_VNAME[view]
|
|
791
|
+
vid = VIEW_ID.get(view)
|
|
792
|
+
res = {"view": view, "vname": vname, "view_id": vid,
|
|
793
|
+
"method": None, "zoom_ok": False, "error": None}
|
|
794
|
+
model = getattr(m, "model", None)
|
|
795
|
+
if vid is None:
|
|
796
|
+
res["error"] = ("视图 %r 没有经过实测的 ViewId —— 拒绝用 0 兜底"
|
|
797
|
+
"(实测 vid=0 不切换视角,会得到重复的假三视图)"
|
|
798
|
+
% view)
|
|
799
|
+
return res
|
|
800
|
+
|
|
801
|
+
# ── ① 等轴测:直接用 swapi 已验证过的 set_view_iso() ──────────────
|
|
802
|
+
if view == "iso" and hasattr(m, "set_view_iso"):
|
|
803
|
+
try:
|
|
804
|
+
m.set_view_iso()
|
|
805
|
+
res["method"] = "SWModel.set_view_iso() [swapi.py:5076]"
|
|
806
|
+
res["zoom_ok"] = True
|
|
807
|
+
res["window"] = wait_window_stable(m)
|
|
808
|
+
_settle()
|
|
809
|
+
return res
|
|
810
|
+
except Exception as e:
|
|
811
|
+
res["error"] = "set_view_iso 失败: %r" % (e,)
|
|
812
|
+
# 失败则继续往下走通用路径
|
|
813
|
+
|
|
814
|
+
# ── ② 通用路径:ShowNamedView2(vname, vid) + ViewZoomtofit2() ─────
|
|
815
|
+
if model is None:
|
|
816
|
+
res["error"] = "SWModel.model 为空,无法切换视图"
|
|
817
|
+
return res
|
|
818
|
+
try:
|
|
819
|
+
model.ShowNamedView2(vname, vid)
|
|
820
|
+
res["method"] = "IModelDoc2.ShowNamedView2(%r, %d)" % (vname, vid)
|
|
821
|
+
except Exception as e:
|
|
822
|
+
res["error"] = "ShowNamedView2(%r, %d) 失败: %r" % (vname, vid, e)
|
|
823
|
+
return res
|
|
824
|
+
# 居中缩放:与 swapi `_normal_to`(5226-5229 行)同一口径 —— 只做
|
|
825
|
+
# ViewZoomtofit2,**不**再叠加 ZoomByFactor。理由(实机教训):
|
|
826
|
+
# 叠加 1.2 倍会把零件放到超出视口,三视图就看不到完整轮廓了,
|
|
827
|
+
# 而"看完整形状"正是本闭环的目的。
|
|
828
|
+
#
|
|
829
|
+
# ── 【实机修复】Zoomtofit 必须连做两次、中间静置 ────────────────────
|
|
830
|
+
# 实机现象(2026-10-12):打开文档后【第一张】视图常常取景不对
|
|
831
|
+
# (零件被放大到只剩局部,或仍是切换过程中的过渡帧),
|
|
832
|
+
# 而后面的视图正常。根因是 SW 首次 ViewZoomtofit2 时还在重建/重绘,
|
|
833
|
+
# 缩放基于旧的视口范围算出来。
|
|
834
|
+
# 修复:先静置等重建完,再连做两次 zoom-to-fit(第二次基于已稳定的
|
|
835
|
+
# 视口重新算),每次之间静置。这对第一张图尤其关键。
|
|
836
|
+
_settle()
|
|
837
|
+
zok = False
|
|
838
|
+
zerr = None
|
|
839
|
+
for _try in range(2):
|
|
840
|
+
try:
|
|
841
|
+
model.ViewZoomtofit2()
|
|
842
|
+
zok = True
|
|
843
|
+
except Exception as e:
|
|
844
|
+
zerr = repr(e)
|
|
845
|
+
try:
|
|
846
|
+
model.ActiveView.ZoomByFactor(0.9) # swapi.py:5098 兜底口径
|
|
847
|
+
zok = True
|
|
848
|
+
res["zoom_fallback"] = "ActiveView.ZoomByFactor(0.9) [swapi.py:5098]"
|
|
849
|
+
except Exception as e2:
|
|
850
|
+
zerr = "%r / %r" % (e, e2)
|
|
851
|
+
_settle()
|
|
852
|
+
res["zoom_ok"] = zok
|
|
853
|
+
if zerr and not zok:
|
|
854
|
+
res["zoom_error"] = zerr
|
|
855
|
+
# 缩放后等窗口尺寸稳定再截图(避免抓到窗口最大化动画的过渡帧)
|
|
856
|
+
res["window"] = wait_window_stable(m)
|
|
857
|
+
_settle() # 再静置,保证截到的是终态取景
|
|
858
|
+
return res
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _settle(seconds=None):
|
|
862
|
+
"""静置等待 SW 重绘(失败绝不抛异常)。"""
|
|
863
|
+
try:
|
|
864
|
+
time.sleep(VIEW_SETTLE_SEC if seconds is None else float(seconds))
|
|
865
|
+
except Exception:
|
|
866
|
+
pass
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def wait_window_stable(model, timeout=None, polls=None):
|
|
870
|
+
"""确保 SolidWorks 主窗口【已最大化且尺寸稳定】,然后才允许截图。
|
|
871
|
+
|
|
872
|
+
实机教训(2026-10-12,SW2025 SP5.0):
|
|
873
|
+
· `swapi.get_sw()` 最后会调 `_show_main_window()`,但 SW 主窗口
|
|
874
|
+
在刚启动的一段时间里仍是 1707x1019(未最大化),之后才变成
|
|
875
|
+
2560x1528(最大化)。最大化动画期间抓屏 → 得到尺寸异常、
|
|
876
|
+
取景错误的图(实测同一轮第一张 1707x1019、后三张 2560x1528,
|
|
877
|
+
且第一张根本没有正确显示零件轮廓)。
|
|
878
|
+
· 只看"矩形连续 N 次不变"不够 —— 未最大化的窗口本身也是稳定的,
|
|
879
|
+
于是"稳定"判定通过,却仍抓到旧取景。
|
|
880
|
+
因此本函数做两件事:
|
|
881
|
+
① MAXIMIZE_BEFORE_SHOT 时主动 ShowWindow(SW_MAXIMIZE) 并等
|
|
882
|
+
IsZoomed() 为真;
|
|
883
|
+
② 再轮询窗口矩形,连续 WINDOW_STABLE_POLLS 次不变才算稳定。
|
|
884
|
+
|
|
885
|
+
Returns: {"stable","maximized","rect","waited_s","reason",...}
|
|
886
|
+
"""
|
|
887
|
+
out = {"stable": False, "maximized": None, "rect": None,
|
|
888
|
+
"waited_s": 0.0, "reason": ""}
|
|
889
|
+
if model is None:
|
|
890
|
+
out["reason"] = "model 为空,跳过窗口稳定检查"
|
|
891
|
+
return out
|
|
892
|
+
try:
|
|
893
|
+
import ctypes
|
|
894
|
+
from ctypes import wintypes
|
|
895
|
+
user32 = ctypes.windll.user32
|
|
896
|
+
except Exception as e:
|
|
897
|
+
out["reason"] = "无法加载 user32(非 Windows?): %r" % (e,)
|
|
898
|
+
return out
|
|
899
|
+
hwnd = None
|
|
900
|
+
# ── 【实机修复】必须盯【主窗口】,不能盯视口窗口 ─────────────────────
|
|
901
|
+
# 实测(2026-10-12):`ActiveView.GetViewHWnd`(视口)在一段时间内
|
|
902
|
+
# 稳定在 1707x870,但 swapi 截图取的是【主窗口】矩形 —— 主窗口此时
|
|
903
|
+
# 还没被最大化(1707x1019),过一会儿才变成 2560x1528。
|
|
904
|
+
# 盯错窗口 → "稳定"判定通过,却仍抓到未最大化时的旧取景。
|
|
905
|
+
# 因此用 swapi 自己的 `_find_main_hwnd()`(swapi.py:2741)定位主窗口,
|
|
906
|
+
# 保证"等稳定"和"截图"盯的是同一个窗口。
|
|
907
|
+
swapi, _err = _try_import_swapi()
|
|
908
|
+
if swapi is not None:
|
|
909
|
+
try:
|
|
910
|
+
_main = swapi._find_main_hwnd()
|
|
911
|
+
hwnd = int(_main) if _main else None
|
|
912
|
+
out["hwnd_source"] = "swapi._find_main_hwnd() [swapi.py:2741]"
|
|
913
|
+
except Exception as e:
|
|
914
|
+
out["hwnd_source_error"] = repr(e)
|
|
915
|
+
if not hwnd:
|
|
916
|
+
try:
|
|
917
|
+
hwnd = int(model.model.ActiveView.GetViewHWnd)
|
|
918
|
+
out["hwnd_source"] = "ActiveView.GetViewHWnd(视口,兜底)"
|
|
919
|
+
except Exception:
|
|
920
|
+
try:
|
|
921
|
+
hwnd = int(model.model.ActiveView.GetViewHWnd())
|
|
922
|
+
out["hwnd_source"] = "ActiveView.GetViewHWnd(视口,兜底)"
|
|
923
|
+
except Exception:
|
|
924
|
+
hwnd = None
|
|
925
|
+
out["hwnd"] = hwnd
|
|
926
|
+
if not hwnd:
|
|
927
|
+
out["reason"] = "拿不到主窗口 HWND,跳过窗口稳定检查"
|
|
928
|
+
return out
|
|
929
|
+
# ── ① 主动最大化(未最大化时第一张图必然是错的取景)──────────────
|
|
930
|
+
if MAXIMIZE_BEFORE_SHOT:
|
|
931
|
+
try:
|
|
932
|
+
out["was_zoomed"] = bool(user32.IsZoomed(hwnd))
|
|
933
|
+
if not out["was_zoomed"]:
|
|
934
|
+
user32.ShowWindow(hwnd, 9) # SW_RESTORE(swapi.py:2808 同款)
|
|
935
|
+
user32.ShowWindow(hwnd, 3) # SW_MAXIMIZE(swapi.py:2809 同款)
|
|
936
|
+
_mz0 = time.time()
|
|
937
|
+
while time.time() - _mz0 < tmo:
|
|
938
|
+
if user32.IsZoomed(hwnd):
|
|
939
|
+
break
|
|
940
|
+
time.sleep(0.2)
|
|
941
|
+
out["maximize_waited_s"] = round(time.time() - _mz0, 2)
|
|
942
|
+
out["maximized"] = bool(user32.IsZoomed(hwnd))
|
|
943
|
+
except Exception as e:
|
|
944
|
+
out["maximized"] = None
|
|
945
|
+
out["maximize_error"] = repr(e)
|
|
946
|
+
tmo = WINDOW_STABLE_TIMEOUT_SEC if timeout is None else float(timeout)
|
|
947
|
+
need = WINDOW_STABLE_POLLS if polls is None else int(polls)
|
|
948
|
+
t0 = time.time()
|
|
949
|
+
last = None
|
|
950
|
+
same = 0
|
|
951
|
+
while time.time() - t0 < tmo:
|
|
952
|
+
try:
|
|
953
|
+
rc = wintypes.RECT()
|
|
954
|
+
user32.GetWindowRect(hwnd, ctypes.byref(rc))
|
|
955
|
+
cur = (rc.left, rc.top, rc.right, rc.bottom)
|
|
956
|
+
except Exception:
|
|
957
|
+
break
|
|
958
|
+
if cur == last:
|
|
959
|
+
same += 1
|
|
960
|
+
if same >= need:
|
|
961
|
+
out["stable"] = True
|
|
962
|
+
out["rect"] = list(cur)
|
|
963
|
+
out["waited_s"] = round(time.time() - t0, 2)
|
|
964
|
+
out["reason"] = "窗口尺寸已连续 %d 次不变" % need
|
|
965
|
+
return out
|
|
966
|
+
else:
|
|
967
|
+
same = 0
|
|
968
|
+
last = cur
|
|
969
|
+
time.sleep(0.25)
|
|
970
|
+
out["rect"] = list(last) if last else None
|
|
971
|
+
out["waited_s"] = round(time.time() - t0, 2)
|
|
972
|
+
out["reason"] = ("等待 %.1fs 仍未观测到窗口尺寸稳定(继续截图,"
|
|
973
|
+
"但该图尺寸可能处于窗口变形过程中)" % out["waited_s"])
|
|
974
|
+
return out
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
#: 截图窗口标题里必须出现的关键字(不区分大小写)。
|
|
978
|
+
# ★ 这是本模块对 swapi 返回值的【独立复核】,不是重复劳动 ——
|
|
979
|
+
# 实机教训(2026-10-12):swapi.screenshot 返回 ok=True 且
|
|
980
|
+
# verified_foreground=True,但那张图里【同时叠着 DSH Web GUI】,
|
|
981
|
+
# 因为 DSH GUI 是置顶窗口,抓屏把两边的像素都抓进来了。
|
|
982
|
+
# 靠 swapi 的 ok 字段无法发现这种情况;这里用窗口标题做二次判据:
|
|
983
|
+
# 标题里必须出现 SOLIDWORKS,否则我们无法确认这张图拍的是 SW。
|
|
984
|
+
SHOT_TITLE_MUST_CONTAIN = "SOLIDWORKS"
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _shot_is_trustworthy(shot):
|
|
988
|
+
"""独立复核一张截图是否可信 —— 不看 swapi 的 ok,只看硬证据。
|
|
989
|
+
|
|
990
|
+
Returns: (trustworthy: bool, why: str)
|
|
991
|
+
"""
|
|
992
|
+
if not isinstance(shot, dict):
|
|
993
|
+
return False, "screenshot 返回不是对象"
|
|
994
|
+
if not shot.get("ok"):
|
|
995
|
+
return False, "screenshot 自报 ok=False"
|
|
996
|
+
title = str(shot.get("window_title") or "")
|
|
997
|
+
if not title:
|
|
998
|
+
return False, ("screenshot 未回报 window_title —— 无法确认拍的是 "
|
|
999
|
+
"SolidWorks 窗口(可能是别的窗口叠在上面)")
|
|
1000
|
+
if SHOT_TITLE_MUST_CONTAIN.lower() not in title.lower():
|
|
1001
|
+
return False, ("screenshot 的 window_title=%r 里不含 %r —— "
|
|
1002
|
+
"疑似抓到了别的窗口(如 DSH GUI 叠在 SW 上)"
|
|
1003
|
+
% (title, SHOT_TITLE_MUST_CONTAIN))
|
|
1004
|
+
return True, "窗口标题含 %r,确认拍的是 SolidWorks" % SHOT_TITLE_MUST_CONTAIN
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _grab_via_savebmp(m, path, width=1600, height=900):
|
|
1008
|
+
"""★ 首选路径:用 SolidWorks 内置渲染导出位图(SaveBMP),不经屏幕抓取。
|
|
1009
|
+
|
|
1010
|
+
【核实来源】swapi.py:5182-5192 `export_image()` 就是这个调用:
|
|
1011
|
+
self.zoom_to_fit(); ok = self.model.SaveBMP(path, width, height)
|
|
1012
|
+
|
|
1013
|
+
为什么首选它(实机教训,2026-10-12):
|
|
1014
|
+
swapi.screenshot 是【屏幕抓取】—— DSH GUI 置顶叠在 SW 上时,
|
|
1015
|
+
抓到的图里同时有 DSH 界面;窗口标题检查只能发现「完全抓错窗口」,
|
|
1016
|
+
无法发现「标题对但内容被遮挡」。SaveBMP 由 SW 自己渲染,
|
|
1017
|
+
**完全没有遮挡问题**,是更可靠的取证方式。
|
|
1018
|
+
|
|
1019
|
+
⚠️ 两个必须处理的真实陷阱(都已实测确认):
|
|
1020
|
+
① **SaveBMP 忽略扩展名**:传 .png / .jpg 路径,写出来的仍是
|
|
1021
|
+
BMP 字节(实测三种扩展名产出的文件字节数完全相同、
|
|
1022
|
+
magic 都是 42 4D = 'BM')。因此本模块**只传 .bmp 路径**,
|
|
1023
|
+
并在落盘后校验 magic,绝不把 BMP 字节冒充成 PNG。
|
|
1024
|
+
② 它依赖当前缩放/视图状态,所以调用方需先切好视图并 Zoomtofit。
|
|
1025
|
+
|
|
1026
|
+
Returns: {"ok","path","size_bytes","sha1_12","via","error"?}
|
|
1027
|
+
"""
|
|
1028
|
+
out = {"ok": False, "path": path,
|
|
1029
|
+
"via": "SWModel.model.SaveBMP [与 swapi.py:5189 同款调用]"}
|
|
1030
|
+
model = getattr(m, "model", None)
|
|
1031
|
+
if model is None:
|
|
1032
|
+
out["error"] = "SWModel.model 为空,无法 SaveBMP"
|
|
1033
|
+
return out
|
|
1034
|
+
try:
|
|
1035
|
+
if os.path.exists(path):
|
|
1036
|
+
os.remove(path)
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass
|
|
1039
|
+
try:
|
|
1040
|
+
ret = model.SaveBMP(path, int(width), int(height))
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
out["error"] = "SaveBMP 抛异常: %r" % (e,)
|
|
1043
|
+
return out
|
|
1044
|
+
out["savebmp_return"] = ret
|
|
1045
|
+
probe = file_probe(path)
|
|
1046
|
+
out["size_bytes"] = probe["size_bytes"]
|
|
1047
|
+
out["sha1_12"] = probe["sha1_12"]
|
|
1048
|
+
# ★ 全文件指纹:重复视图检测必须用它(前 1MB 在 BMP 上会假阳性)
|
|
1049
|
+
out["sha1_full_12"] = probe.get("sha1_full_12")
|
|
1050
|
+
if not probe["ok"]:
|
|
1051
|
+
out["error"] = "SaveBMP 未产出可用文件: %s" % probe["why"]
|
|
1052
|
+
return out
|
|
1053
|
+
# ★ 校验真实文件格式:必须是 BMP(magic 'BM' = 42 4D)
|
|
1054
|
+
try:
|
|
1055
|
+
with open(path, "rb") as f:
|
|
1056
|
+
magic = f.read(2)
|
|
1057
|
+
except Exception as e:
|
|
1058
|
+
out["error"] = "读取导出文件失败: %r" % (e,)
|
|
1059
|
+
return out
|
|
1060
|
+
out["magic"] = magic.hex()
|
|
1061
|
+
if magic != b"BM":
|
|
1062
|
+
out["error"] = ("SaveBMP 产出的文件 magic=%s 不是 BMP —— "
|
|
1063
|
+
"格式不符合预期,拒绝采用" % magic.hex())
|
|
1064
|
+
return out
|
|
1065
|
+
out["ok"] = True
|
|
1066
|
+
out["format"] = "BMP(SolidWorks 内置渲染,无屏幕遮挡问题)"
|
|
1067
|
+
return out
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _grab_screenshot(m, path, prefer_savebmp=True):
|
|
1071
|
+
"""截取当前视图到 path,返回统一的证据结构。
|
|
1072
|
+
|
|
1073
|
+
两条路径,**优先 SaveBMP(SW 自渲染,无遮挡风险)**:
|
|
1074
|
+
① SaveBMP(`_grab_via_savebmp`)→ 产出 .bmp;
|
|
1075
|
+
② 回退 swapi.screenshot 屏幕抓取 → 产出 .png,
|
|
1076
|
+
并做窗口标题独立复核(`_shot_is_trustworthy`)。
|
|
1077
|
+
|
|
1078
|
+
【核实过的返回结构(swapi.py:5103-5180 screenshot)】
|
|
1079
|
+
成功 → {"ok": True, "path", "size": "WxH", "activated",
|
|
1080
|
+
"verified_foreground", "window_title", "note"}
|
|
1081
|
+
失败 → {"ok": False, "error": ...}(可能带 "activation"/"hint")
|
|
1082
|
+
注意:swapi 在无法把 SW 激活到前台时【故意返回 ok=False】,
|
|
1083
|
+
因为它宁愿失败也不愿抓到 DSH GUI 的画面 —— 本模块必须尊重这个语义,
|
|
1084
|
+
把 ok=False 如实上报,绝不兜底成「假装成功」。
|
|
1085
|
+
"""
|
|
1086
|
+
# ── ① 首选:SW 内置渲染导出 ───────────────────────────────────────
|
|
1087
|
+
if prefer_savebmp:
|
|
1088
|
+
bmp_path = os.path.splitext(path)[0] + ".bmp"
|
|
1089
|
+
r = _grab_via_savebmp(m, bmp_path)
|
|
1090
|
+
r["trustworthy"] = bool(r.get("ok"))
|
|
1091
|
+
r["trust_note"] = ("SaveBMP 由 SolidWorks 自己渲染,"
|
|
1092
|
+
"不存在窗口遮挡问题" if r.get("ok")
|
|
1093
|
+
else r.get("error"))
|
|
1094
|
+
if r.get("ok"):
|
|
1095
|
+
r["source"] = "savebmp"
|
|
1096
|
+
return r
|
|
1097
|
+
fallback_reason = r.get("error")
|
|
1098
|
+
else:
|
|
1099
|
+
fallback_reason = "调用方要求跳过 SaveBMP"
|
|
1100
|
+
|
|
1101
|
+
# ── ② 回退:swapi 屏幕抓取 ────────────────────────────────────────
|
|
1102
|
+
try:
|
|
1103
|
+
r = m.screenshot(path=path)
|
|
1104
|
+
except Exception as e:
|
|
1105
|
+
return {"ok": False, "error": "screenshot 抛异常: %r" % (e,),
|
|
1106
|
+
"source": "screenshot", "savebmp_error": fallback_reason,
|
|
1107
|
+
"via": "SWModel.screenshot [swapi.py:5103]"}
|
|
1108
|
+
if not isinstance(r, dict):
|
|
1109
|
+
return {"ok": False, "error": "screenshot 返回非 dict: %r" % (r,),
|
|
1110
|
+
"source": "screenshot", "savebmp_error": fallback_reason,
|
|
1111
|
+
"via": "SWModel.screenshot [swapi.py:5103]"}
|
|
1112
|
+
r = dict(r)
|
|
1113
|
+
r["source"] = "screenshot"
|
|
1114
|
+
r["savebmp_error"] = fallback_reason
|
|
1115
|
+
r.setdefault("via", "SWModel.screenshot [swapi.py:5103]")
|
|
1116
|
+
# ── ★ 独立复核:即使 swapi 说 ok=True,也要确认拍的是 SW 窗口 ──────
|
|
1117
|
+
# 实机教训:DSH GUI 置顶时,swapi 会返回 ok=True/verified_foreground=True
|
|
1118
|
+
# 但图里叠着 DSH 界面。那种图无法用来"看模型建成什么样",
|
|
1119
|
+
# 因此这里把它降级为不可信,并附上原因供上层判断。
|
|
1120
|
+
ok_trust, why = _shot_is_trustworthy(r)
|
|
1121
|
+
r["trustworthy"] = bool(ok_trust)
|
|
1122
|
+
r["trust_note"] = why
|
|
1123
|
+
if r.get("ok") and not ok_trust:
|
|
1124
|
+
r["swapi_ok"] = True
|
|
1125
|
+
r["ok"] = False
|
|
1126
|
+
r["error"] = ("swapi 自报截图成功,但独立复核判定不可信:%s" % why)
|
|
1127
|
+
r["hint"] = ("请把 SolidWorks 窗口置于最前(不要被 DSH GUI 等窗口遮挡)"
|
|
1128
|
+
"后重试;本模块宁可判失败也不接受一张看不清模型的图。"
|
|
1129
|
+
"(SaveBMP 路径也不可用:%s)" % fallback_reason)
|
|
1130
|
+
return r
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _open_part_model(part_path):
|
|
1134
|
+
"""打开零件/装配体,返回 (SWModel, meta, error)。
|
|
1135
|
+
|
|
1136
|
+
失败一律返回 (None, meta, 错误说明) —— 调用方据此返回结构化失败,
|
|
1137
|
+
**绝不**编造截图路径。装配体/零件按扩展名区分 doc_type:
|
|
1138
|
+
.sldprt=1 / .sldasm=2 / .slddrw=3(swapi.py:12252 同一张映射表)。
|
|
1139
|
+
"""
|
|
1140
|
+
meta = {"part": os.path.abspath(part_path) if part_path else None,
|
|
1141
|
+
"opened": False, "sw_available": False, "doc_type": None,
|
|
1142
|
+
"via": None, "swapi_error": None}
|
|
1143
|
+
if not part_path:
|
|
1144
|
+
return None, meta, "part_path 为空"
|
|
1145
|
+
if not os.path.exists(part_path):
|
|
1146
|
+
return None, meta, "零件文件不存在: %s" % part_path
|
|
1147
|
+
swapi, err = _try_import_swapi()
|
|
1148
|
+
if swapi is None:
|
|
1149
|
+
meta["swapi_error"] = err
|
|
1150
|
+
return None, meta, ("swapi 不可用(%s)—— 无 SolidWorks/无 pywin32 时"
|
|
1151
|
+
"无法采集真实截图" % err)
|
|
1152
|
+
ext = os.path.splitext(part_path)[1].lower()
|
|
1153
|
+
doc_type = {".sldprt": 1, ".sldasm": 2, ".slddrw": 3}.get(ext, 1)
|
|
1154
|
+
meta["doc_type"] = doc_type
|
|
1155
|
+
try:
|
|
1156
|
+
sw = swapi.get_sw()
|
|
1157
|
+
except Exception as e:
|
|
1158
|
+
return None, meta, "连接 SolidWorks 失败: %r" % (e,)
|
|
1159
|
+
meta["sw_available"] = True
|
|
1160
|
+
try:
|
|
1161
|
+
r = swapi.SWModel.open_part(sw, os.path.abspath(part_path), doc_type)
|
|
1162
|
+
except Exception as e:
|
|
1163
|
+
return None, meta, "open_part 抛异常: %r" % (e,)
|
|
1164
|
+
meta["via"] = "SWModel.open_part(sw, path, doc_type) [swapi.py:12235]"
|
|
1165
|
+
if not isinstance(r, dict) or not r.get("ok"):
|
|
1166
|
+
return None, meta, "打开文档失败: %s" % (
|
|
1167
|
+
(r or {}).get("error") or r)
|
|
1168
|
+
doc = r.get("doc")
|
|
1169
|
+
if doc is None:
|
|
1170
|
+
return None, meta, "open_part 返回 ok 但 doc 为空"
|
|
1171
|
+
meta["opened"] = True
|
|
1172
|
+
meta["title"] = r.get("title")
|
|
1173
|
+
try:
|
|
1174
|
+
m = swapi.SWModel(sw, doc)
|
|
1175
|
+
except Exception as e:
|
|
1176
|
+
return None, meta, "构造 SWModel 失败: %r" % (e,)
|
|
1177
|
+
return m, meta, None
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def capture_views(part_path, out_dir, views=None, model=None, round_no=None,
|
|
1181
|
+
verify_projection=False, expect_bbox_mm=None):
|
|
1182
|
+
"""采集三视图(+等轴测)截图。
|
|
1183
|
+
|
|
1184
|
+
Args:
|
|
1185
|
+
part_path: 零件/装配体路径(.sldprt / .sldasm)
|
|
1186
|
+
out_dir: 截图落盘目录(不存在会自动创建)
|
|
1187
|
+
views: 视图列表,默认 ["front","top","right","iso"]
|
|
1188
|
+
model: 【可选】已打开的 SWModel。闭环每轮复用它,避免重复
|
|
1189
|
+
OpenDoc 把同一零件打开多次(既慢又可能让 SW 弹窗)。
|
|
1190
|
+
为空时本函数自行打开。
|
|
1191
|
+
round_no: 【可选】轮次号。非空时截图落到 out_dir/round_<N>/,
|
|
1192
|
+
保证多轮证据互不覆盖。
|
|
1193
|
+
verify_projection: 【可选】是否做「投影面验算」——
|
|
1194
|
+
量出每个正投影视图的轮廓宽高比,与由真实包围盒算出的
|
|
1195
|
+
理论值比对,证明"拍对了面"而不只是"三张图不一样"。
|
|
1196
|
+
⚠️ 需要可见轮廓与深色/洋红像素(PIL 可用);
|
|
1197
|
+
包围盒退化为平面(某轴长度为 0)时该项会被如实跳过。
|
|
1198
|
+
expect_bbox_mm: 【可选】用于验算的包围盒六元组。给了就用它算理论
|
|
1199
|
+
比值;没给且 verify_projection=True 时,本函数会自己
|
|
1200
|
+
回读一次真实包围盒(并记录来源)。
|
|
1201
|
+
Returns:
|
|
1202
|
+
{
|
|
1203
|
+
"ok": bool, # 所有请求视图都截到 + 无重复 + 投影验算过
|
|
1204
|
+
"part": 绝对路径,
|
|
1205
|
+
"out_dir": 绝对路径,
|
|
1206
|
+
"views": {view: {"ok","path","size_bytes","sha1_12","vname",
|
|
1207
|
+
"view_id","method","shot":{...取证通道原始返回}}},
|
|
1208
|
+
"opened": bool, "sw_available": bool,
|
|
1209
|
+
"requested": [...], "captured": [...], "failed": [...],
|
|
1210
|
+
"invalid_views": [...],
|
|
1211
|
+
"duplicate_views": [[重复的视图组], ...],
|
|
1212
|
+
"projection_check": {...} | 不存在(未开启时),
|
|
1213
|
+
"error": 失败总因(ok=False 时一定有),
|
|
1214
|
+
"note": ...
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
★ 失败模式(绝不假装成功):
|
|
1218
|
+
· 零件不存在 / swapi 不可用 / SW 连不上 / 文档打不开 → ok=False,
|
|
1219
|
+
views 里不出现任何伪造路径;
|
|
1220
|
+
· 视图切不过去 → 该视图 ok=False 并带 error,不会落到「上一张图」上;
|
|
1221
|
+
· 取证通道返回 ok=False(如 SW 没能激活到前台)→ 该视图 ok=False,
|
|
1222
|
+
即使文件恰好存在也不认(因为内容不可信);
|
|
1223
|
+
· ★ 不同视图产出【同一张图】→ ok=False(视图根本没切换,
|
|
1224
|
+
详见 VIEW_ID 里 vid=0 的实测记录);
|
|
1225
|
+
· ★ 投影面验算不过 → ok=False(图不一样但拍错了面)。
|
|
1226
|
+
"""
|
|
1227
|
+
part_abs = os.path.abspath(part_path) if part_path else None
|
|
1228
|
+
out_dir = os.path.abspath(out_dir) if out_dir else _default_out_dir(part_path)
|
|
1229
|
+
out = {"ok": False, "part": part_abs, "out_dir": out_dir,
|
|
1230
|
+
"views": {}, "opened": False, "sw_available": False,
|
|
1231
|
+
"requested": [], "captured": [], "failed": [],
|
|
1232
|
+
"invalid_views": [], "error": None, "round": round_no,
|
|
1233
|
+
"reused_open_model": bool(model is not None),
|
|
1234
|
+
"generated_at": _now_str(),
|
|
1235
|
+
"enforced_by": "visual_check.capture_views"}
|
|
1236
|
+
want, bad = _normalize_views(views)
|
|
1237
|
+
out["requested"] = want
|
|
1238
|
+
out["invalid_views"] = bad
|
|
1239
|
+
out["views_source"] = ("默认三视图+等轴测" if views is None
|
|
1240
|
+
else "调用方指定")
|
|
1241
|
+
if bad:
|
|
1242
|
+
out["error"] = ("非法视图名 %s;合法视图:%s"
|
|
1243
|
+
% ("、".join(bad), "、".join(sorted(VIEW_VNAME))))
|
|
1244
|
+
return out
|
|
1245
|
+
if not want:
|
|
1246
|
+
out["error"] = "views 为空 —— 没有要采集的视图"
|
|
1247
|
+
return out
|
|
1248
|
+
try:
|
|
1249
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1250
|
+
except Exception as e:
|
|
1251
|
+
out["error"] = "无法创建输出目录 %s: %r" % (out_dir, e)
|
|
1252
|
+
return out
|
|
1253
|
+
|
|
1254
|
+
m = model
|
|
1255
|
+
if m is None:
|
|
1256
|
+
m, meta, err = _open_part_model(part_abs)
|
|
1257
|
+
out["opened"] = bool(meta.get("opened"))
|
|
1258
|
+
out["sw_available"] = bool(meta.get("sw_available"))
|
|
1259
|
+
out["open_meta"] = meta
|
|
1260
|
+
if m is None:
|
|
1261
|
+
out["error"] = err
|
|
1262
|
+
for v in want:
|
|
1263
|
+
out["views"][v] = {"ok": False, "path": None, "view": v,
|
|
1264
|
+
"error": err}
|
|
1265
|
+
out["failed"].append(v)
|
|
1266
|
+
return out
|
|
1267
|
+
else:
|
|
1268
|
+
# 复用已打开的模型:不能声称"本次打开了文档",如实标注复用
|
|
1269
|
+
out["opened"] = True
|
|
1270
|
+
out["sw_available"] = True
|
|
1271
|
+
out["open_meta"] = {"opened": True, "via": "复用调用方传入的 SWModel",
|
|
1272
|
+
"reused": True}
|
|
1273
|
+
|
|
1274
|
+
for v in want:
|
|
1275
|
+
rec = {"view": v, "ok": False, "path": None, "error": None}
|
|
1276
|
+
sw_res = _apply_view(m, v)
|
|
1277
|
+
rec.update({k: sw_res.get(k) for k in
|
|
1278
|
+
("vname", "view_id", "method", "zoom_ok")})
|
|
1279
|
+
if sw_res.get("error"):
|
|
1280
|
+
rec["error"] = "视图切换失败: %s" % sw_res["error"]
|
|
1281
|
+
out["views"][v] = rec
|
|
1282
|
+
out["failed"].append(v)
|
|
1283
|
+
continue
|
|
1284
|
+
path = _shot_path(out_dir, part_abs, v, round_no=round_no)
|
|
1285
|
+
# 先删掉同名旧文件:否则「截图失败但旧文件还在」会被误判成成功。
|
|
1286
|
+
# (同时清掉另一条路径可能留下的另一种扩展名,避免张冠李戴)
|
|
1287
|
+
for _stale in (path, os.path.splitext(path)[0] + ".bmp"):
|
|
1288
|
+
try:
|
|
1289
|
+
if os.path.exists(_stale):
|
|
1290
|
+
os.remove(_stale)
|
|
1291
|
+
except Exception:
|
|
1292
|
+
pass
|
|
1293
|
+
shot = _grab_screenshot(m, path)
|
|
1294
|
+
rec["shot"] = shot
|
|
1295
|
+
# ★ 实际落盘文件由 shot 决定(SaveBMP 走 .bmp,屏幕抓取走 .png)——
|
|
1296
|
+
# 必须采信真实路径,不能想当然写回请求的 path。
|
|
1297
|
+
real_path = shot.get("path") or path
|
|
1298
|
+
rec["path"] = real_path
|
|
1299
|
+
rec["shot_source"] = shot.get("source")
|
|
1300
|
+
probe = file_probe(real_path)
|
|
1301
|
+
rec["size_bytes"] = probe["size_bytes"]
|
|
1302
|
+
rec["sha1_12"] = probe["sha1_12"]
|
|
1303
|
+
# ★ 全文件指纹:重复视图检测用它(见 _file_sha1 里 1MB 假阳性的实测记录)
|
|
1304
|
+
rec["sha1_full_12"] = probe.get("sha1_full_12")
|
|
1305
|
+
rec["file_ok"] = bool(probe["ok"])
|
|
1306
|
+
if not shot.get("ok"):
|
|
1307
|
+
# ★ 截图通道明确说「不可信」→ 本模块也必须判为失败,
|
|
1308
|
+
# 即使文件碰巧存在(可能是上一次的残留)。
|
|
1309
|
+
rec["ok"] = False
|
|
1310
|
+
rec["error"] = "截图失败: %s" % (
|
|
1311
|
+
shot.get("error") or shot.get("note") or shot)
|
|
1312
|
+
rec["hint"] = shot.get("hint")
|
|
1313
|
+
elif not probe["ok"]:
|
|
1314
|
+
rec["ok"] = False
|
|
1315
|
+
rec["error"] = "截图文件不可用: %s" % probe["why"]
|
|
1316
|
+
else:
|
|
1317
|
+
rec["ok"] = True
|
|
1318
|
+
if rec["ok"]:
|
|
1319
|
+
out["captured"].append(v)
|
|
1320
|
+
else:
|
|
1321
|
+
out["failed"].append(v)
|
|
1322
|
+
out["views"][v] = rec
|
|
1323
|
+
|
|
1324
|
+
# ── ★ 重复视图检测:三视图绝不能是同一张图 ──────────────────────────
|
|
1325
|
+
# 实机教训(2026-10-12):`ShowNamedView2(name, 0)` 不切换视角,
|
|
1326
|
+
# 于是 front/top/right 会拍成【字节完全相同】的图,看起来"采到了 3 张",
|
|
1327
|
+
# 实际只有 1 个视角 —— 这种假三视图完全无法用于"看建模建成什么样"。
|
|
1328
|
+
# 这里用 sha1 做代码级拦截:只要两张不同的视图产出相同内容,即判失败。
|
|
1329
|
+
#
|
|
1330
|
+
# ⚠️【必须用全文件指纹 sha1_full_12,不能用 sha1_12】━━━━━━━━━━━━━━━━━━━
|
|
1331
|
+
# 实测踩到的假阳性(DSH_悬臂支架):
|
|
1332
|
+
# SaveBMP 的 BMP 是 4320054 字节,像素【自底向上】存储,文件前段是
|
|
1333
|
+
# 图像【底部】的行。front 与 top 整体完全不同(全文件 sha1
|
|
1334
|
+
# 55c0009dec75 / 52951e5ae8d1),但底部都是空白 → 前 1MB 恰好相同
|
|
1335
|
+
# (都是 350a1b10f2db)→ 重复检测把一次**完全正常**的采集判成失败,
|
|
1336
|
+
# 还误报成"ViewId 传了 0"(其实 ViewId 是对的,视角也确实切了)。
|
|
1337
|
+
# 因此这里改用全文件指纹;sha1_12 仅保留作"文件是否被碰过"的轻量指纹。
|
|
1338
|
+
digests = {}
|
|
1339
|
+
for v in out["captured"]:
|
|
1340
|
+
_rec_v = out["views"].get(v) or {}
|
|
1341
|
+
d = _rec_v.get("sha1_full_12") or _rec_v.get("sha1_12")
|
|
1342
|
+
if d:
|
|
1343
|
+
digests.setdefault(d, []).append(v)
|
|
1344
|
+
dup_groups = [vs for vs in digests.values() if len(vs) > 1]
|
|
1345
|
+
out["duplicate_views"] = dup_groups
|
|
1346
|
+
if dup_groups:
|
|
1347
|
+
_desc = ";".join("、".join(vs) + "(内容完全相同)" for vs in dup_groups)
|
|
1348
|
+
out["ok"] = False
|
|
1349
|
+
out["error"] = ("检出【重复视图】:%s —— 说明视图没有真正切换"
|
|
1350
|
+
"(常见根因是 ShowNamedView2 的 ViewId 传了 0)。"
|
|
1351
|
+
"三视图必须是不同视角,拒绝用重复图冒充三视图。" % _desc)
|
|
1352
|
+
out["note"] = "采集失败:三视图内容重复,无法用于看形状。"
|
|
1353
|
+
return out
|
|
1354
|
+
|
|
1355
|
+
out["ok"] = (not out["failed"]) and bool(out["captured"])
|
|
1356
|
+
if not out["ok"]:
|
|
1357
|
+
out["error"] = ("以下视图未成功截图: %s(绝不返回不存在的路径当成功)"
|
|
1358
|
+
% "、".join(out["failed"]))
|
|
1359
|
+
out["note"] = ("三视图用于比对轮廓/孔位,等轴测图用于看立体形状 —— "
|
|
1360
|
+
"两者都截到、且内容互不重复才算采集完成。" if out["ok"] else
|
|
1361
|
+
"采集未完成,见 failed/error。")
|
|
1362
|
+
|
|
1363
|
+
# ── ★ 投影面验算(可选但强烈建议):证明"拍对了面",不只是"图不一样"──
|
|
1364
|
+
# 需要真实包围盒才能算出理论比值;调用方给了 expect_bbox_mm 就做,
|
|
1365
|
+
# 否则如实标注"未验算",绝不假装验过。
|
|
1366
|
+
if verify_projection:
|
|
1367
|
+
bbox = expect_bbox_mm
|
|
1368
|
+
if bbox is None:
|
|
1369
|
+
# 自己回读一次真实包围盒(有模型时)
|
|
1370
|
+
try:
|
|
1371
|
+
geo = read_actual_geometry(m)
|
|
1372
|
+
bbox = geo.get("bbox_mm") if geo.get("ok") else None
|
|
1373
|
+
out["projection_bbox_source"] = geo.get("sources", {}).get("bbox")
|
|
1374
|
+
except Exception as e:
|
|
1375
|
+
bbox = None
|
|
1376
|
+
out["projection_bbox_error"] = repr(e)
|
|
1377
|
+
proj = check_view_projection(out["views"], bbox)
|
|
1378
|
+
out["projection_check"] = proj
|
|
1379
|
+
# ★ 只有【验算做过且失败】才拦;"没验成"(缺包围盒/PIL 缺失/
|
|
1380
|
+
# 退化尺寸)如实标注未验算,不误判为拍错面。
|
|
1381
|
+
if out["ok"] and proj.get("failed") and not proj.get("ok"):
|
|
1382
|
+
out["ok"] = False
|
|
1383
|
+
out["error"] = ("三视图投影面验算未通过:%s"
|
|
1384
|
+
"(图都拍到了,但内容不是正确的三视图)"
|
|
1385
|
+
% proj.get("note"))
|
|
1386
|
+
out["note"] = "采集失败:三视图未通过投影面验算。"
|
|
1387
|
+
elif not proj.get("verified"):
|
|
1388
|
+
out.setdefault("warnings", []).append(
|
|
1389
|
+
"投影面验算未完成:%s(已如实标注为未验算,未据此判定成败)"
|
|
1390
|
+
% proj.get("note"))
|
|
1391
|
+
return out
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1395
|
+
# 五、B. ★ 视觉检查闭环(看图 → 矫正 → 继续看 → 继续矫正)
|
|
1396
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
1397
|
+
_AXIS_NAMES = ("x", "y", "z")
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def parse_expect(expect):
|
|
1401
|
+
"""解析 expect —— 接受 dict,或 "@文件.json" / JSON 字符串 / 文件路径。
|
|
1402
|
+
|
|
1403
|
+
Returns: (expect_dict_or_None, error_or_None)
|
|
1404
|
+
"""
|
|
1405
|
+
if expect is None:
|
|
1406
|
+
return None, "expect 为空 —— 闭环必须有期望几何(来自零件参数表)"
|
|
1407
|
+
if isinstance(expect, dict):
|
|
1408
|
+
return expect, None
|
|
1409
|
+
if not isinstance(expect, str):
|
|
1410
|
+
return None, "expect 类型不支持: %r" % (type(expect),)
|
|
1411
|
+
s = expect.strip()
|
|
1412
|
+
if s.startswith("@"):
|
|
1413
|
+
s = s[1:].strip()
|
|
1414
|
+
if s.startswith("{") or s.startswith("["):
|
|
1415
|
+
try:
|
|
1416
|
+
d = json.loads(s)
|
|
1417
|
+
except Exception as e:
|
|
1418
|
+
return None, "expect JSON 解析失败: %s" % e
|
|
1419
|
+
return (d, None) if isinstance(d, dict) else \
|
|
1420
|
+
(None, "expect JSON 必须是对象,收到 %s" % type(d).__name__)
|
|
1421
|
+
if os.path.exists(s):
|
|
1422
|
+
d, err = read_json(s)
|
|
1423
|
+
if err:
|
|
1424
|
+
return None, "expect 文件读取失败(%s): %s" % (s, err)
|
|
1425
|
+
return (d, None) if isinstance(d, dict) else \
|
|
1426
|
+
(None, "expect 文件内容必须是对象,收到 %s" % type(d).__name__)
|
|
1427
|
+
return None, "expect 既不是 JSON 也不是存在的文件路径: %r" % (expect,)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def normalize_expect(expect):
|
|
1431
|
+
"""把 expect 规整成可比对的形式。
|
|
1432
|
+
|
|
1433
|
+
支持的 expect 形态:
|
|
1434
|
+
{"bbox_mm": [xmin,xmax,ymin,ymax,zmin,zmax], "features":[...], "note":...}
|
|
1435
|
+
{"bbox_mm": {"x":[min,max], "y":[...], "z":[...]}} (等价写法)
|
|
1436
|
+
{"bbox_mm": {"xmin":..,"xmax":..,"ymin":..,"ymax":..,...}} (等价写法)
|
|
1437
|
+
|
|
1438
|
+
Returns: (norm_or_None, error_or_None)
|
|
1439
|
+
"""
|
|
1440
|
+
if not isinstance(expect, dict):
|
|
1441
|
+
return None, "expect 必须是对象"
|
|
1442
|
+
norm = {"raw": expect, "note": expect.get("note"),
|
|
1443
|
+
"bbox_mm": None, "features": expect.get("features") or [],
|
|
1444
|
+
"tolerance_mm": None}
|
|
1445
|
+
bbox = expect.get("bbox_mm", expect.get("bbox"))
|
|
1446
|
+
if bbox is None:
|
|
1447
|
+
return norm, None # 没有包围盒期望 → 只查特征存在性
|
|
1448
|
+
vals = None
|
|
1449
|
+
if isinstance(bbox, (list, tuple)):
|
|
1450
|
+
if len(bbox) != 6:
|
|
1451
|
+
return None, ("bbox_mm 必须是 6 个数字 "
|
|
1452
|
+
"[xmin,xmax,ymin,ymax,zmin,zmax],收到 %d 个" % len(bbox))
|
|
1453
|
+
vals = list(bbox)
|
|
1454
|
+
elif isinstance(bbox, dict):
|
|
1455
|
+
if all(k in bbox for k in ("xmin", "xmax", "ymin", "ymax",
|
|
1456
|
+
"zmin", "zmax")):
|
|
1457
|
+
vals = [bbox["xmin"], bbox["xmax"], bbox["ymin"], bbox["ymax"],
|
|
1458
|
+
bbox["zmin"], bbox["zmax"]]
|
|
1459
|
+
elif all(k in bbox for k in ("x", "y", "z")):
|
|
1460
|
+
vals = []
|
|
1461
|
+
for ax in ("x", "y", "z"):
|
|
1462
|
+
pair = bbox[ax]
|
|
1463
|
+
if not isinstance(pair, (list, tuple)) or len(pair) != 2:
|
|
1464
|
+
return None, "bbox_mm.%s 必须是 [min,max] 两元素数组" % ax
|
|
1465
|
+
vals.extend([pair[0], pair[1]])
|
|
1466
|
+
else:
|
|
1467
|
+
return None, ("bbox_mm 对象里必须是 {xmin..zmax} 或 {x:[min,max],...};"
|
|
1468
|
+
"收到键 %s" % sorted(bbox.keys()))
|
|
1469
|
+
else:
|
|
1470
|
+
return None, "bbox_mm 类型不支持: %s" % type(bbox).__name__
|
|
1471
|
+
try:
|
|
1472
|
+
norm["bbox_mm"] = [round(float(v), 6) for v in vals]
|
|
1473
|
+
except Exception as e:
|
|
1474
|
+
return None, "bbox_mm 含非数字项: %s" % e
|
|
1475
|
+
for i in range(3):
|
|
1476
|
+
lo, hi = norm["bbox_mm"][2 * i], norm["bbox_mm"][2 * i + 1]
|
|
1477
|
+
if hi < lo:
|
|
1478
|
+
return None, ("bbox_mm 的 %s 轴 min(%s) > max(%s) —— 参数表写反了"
|
|
1479
|
+
% (_AXIS_NAMES[i], lo, hi))
|
|
1480
|
+
tol = expect.get("tolerance_mm", expect.get("tol_mm"))
|
|
1481
|
+
try:
|
|
1482
|
+
norm["tolerance_mm"] = (None if tol is None
|
|
1483
|
+
else max(0.0, float(tol)))
|
|
1484
|
+
except Exception:
|
|
1485
|
+
return None, "tolerance_mm 必须是数字"
|
|
1486
|
+
return norm, None
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def read_actual_geometry(m):
|
|
1490
|
+
"""★ 真实回读几何 —— 闭环的判据来源。
|
|
1491
|
+
|
|
1492
|
+
【核实过的 API(不是猜的)】
|
|
1493
|
+
· swapi.py:7730-7767 `SWModel.body_box_mm()`
|
|
1494
|
+
→ 返回 [xmin,xmax,ymin,ymax,zmin,zmax](mm),多实体取并集;
|
|
1495
|
+
取不到返回 None(文档明确写「调用方【不得】用设计域代替」)。
|
|
1496
|
+
· swapi.py:7699-7714 `SWModel._bbox_extent_mm(axis)`
|
|
1497
|
+
→ 单轴边长(mm),取不到返回 None。
|
|
1498
|
+
· swapi.py:4781-4880 `SWModel.massprops(safe=True)`
|
|
1499
|
+
→ {"ok","volume_mm3","surface_area_mm2","mass_kg",
|
|
1500
|
+
"density_kg_m3","center_of_mass_mm","volume_source","mass_source"}
|
|
1501
|
+
|
|
1502
|
+
★ 关键:包围盒取不到时返回 ok=False —— 绝不用"设计域/期望值"顶上,
|
|
1503
|
+
否则闭环会变成"拿期望去比期望",必然假通过。
|
|
1504
|
+
|
|
1505
|
+
Returns:
|
|
1506
|
+
{"ok":bool, "bbox_mm":[...]|None, "extents_mm":{x,y,z},
|
|
1507
|
+
"mass":{...}|None, "sources":{...}, "error":str|None}
|
|
1508
|
+
"""
|
|
1509
|
+
out = {"ok": False, "bbox_mm": None, "extents_mm": {},
|
|
1510
|
+
"mass": None, "sources": {}, "error": None}
|
|
1511
|
+
if m is None:
|
|
1512
|
+
out["error"] = "SWModel 为空,无法回读几何"
|
|
1513
|
+
return out
|
|
1514
|
+
bbox = None
|
|
1515
|
+
try:
|
|
1516
|
+
bbox = m.body_box_mm()
|
|
1517
|
+
except Exception as e:
|
|
1518
|
+
out["error"] = "body_box_mm() 抛异常: %r" % (e,)
|
|
1519
|
+
out["sources"]["bbox"] = "SWModel.body_box_mm() [swapi.py:7730]"
|
|
1520
|
+
if bbox is None:
|
|
1521
|
+
out["error"] = (out["error"] or
|
|
1522
|
+
"body_box_mm() 返回 None —— 文档里没有任何实体,"
|
|
1523
|
+
"或包围盒退化。不得用设计域代替(swapi.py:7734 口径)。")
|
|
1524
|
+
return out
|
|
1525
|
+
try:
|
|
1526
|
+
out["bbox_mm"] = [round(float(v), 4) for v in bbox]
|
|
1527
|
+
except Exception as e:
|
|
1528
|
+
out["error"] = "body_box_mm() 返回值无法解析为数字: %r (%r)" % (e, bbox)
|
|
1529
|
+
return out
|
|
1530
|
+
# 单轴边长:优先用 _bbox_extent_mm(swapi 的专用方法),失败则从包围盒推
|
|
1531
|
+
for i, ax in enumerate(_AXIS_NAMES):
|
|
1532
|
+
v = None
|
|
1533
|
+
if hasattr(m, "_bbox_extent_mm"):
|
|
1534
|
+
try:
|
|
1535
|
+
v = m._bbox_extent_mm(i)
|
|
1536
|
+
except Exception:
|
|
1537
|
+
v = None
|
|
1538
|
+
if v is None:
|
|
1539
|
+
v = out["bbox_mm"][2 * i + 1] - out["bbox_mm"][2 * i]
|
|
1540
|
+
out["sources"]["extent_" + ax] = "由 body_box_mm 推导"
|
|
1541
|
+
else:
|
|
1542
|
+
out["sources"]["extent_" + ax] = "SWModel._bbox_extent_mm(%d) [swapi.py:7699]" % i
|
|
1543
|
+
out["extents_mm"][ax] = round(float(v), 4)
|
|
1544
|
+
# 质量属性(辅助证据,失败不影响判据)
|
|
1545
|
+
if hasattr(m, "massprops"):
|
|
1546
|
+
try:
|
|
1547
|
+
mp = m.massprops(safe=True)
|
|
1548
|
+
if isinstance(mp, dict) and mp.get("ok"):
|
|
1549
|
+
out["mass"] = {k: mp.get(k) for k in
|
|
1550
|
+
("volume_mm3", "mass_kg", "surface_area_mm2",
|
|
1551
|
+
"density_kg_m3", "volume_source", "mass_source")}
|
|
1552
|
+
out["sources"]["mass"] = "SWModel.massprops(safe=True) [swapi.py:4781]"
|
|
1553
|
+
except Exception as e:
|
|
1554
|
+
out["mass"] = {"error": repr(e)}
|
|
1555
|
+
out["ok"] = True
|
|
1556
|
+
return out
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def compare_bbox(actual6, expect6, tol_mm=None):
|
|
1560
|
+
"""逐面比对两个包围盒六元组,产出偏差 + 矫正建议。
|
|
1561
|
+
|
|
1562
|
+
比对哪些量:6 个面(xmin/xmax/ymin/ymax/zmin/zmax)逐个比。
|
|
1563
|
+
为什么比"面"而不是只比"尺寸":零件整体偏移(位置错)也必须被抓出来,
|
|
1564
|
+
只比 3 个边长会漏掉"尺寸对但装错位置"的情形。
|
|
1565
|
+
|
|
1566
|
+
Returns:
|
|
1567
|
+
{"matched":bool, "tolerance_mm":float, "deltas_mm":{face:delta},
|
|
1568
|
+
"max_abs_delta_mm":float, "failed_faces":[...], "deltas_by_axis":{...},
|
|
1569
|
+
"correction": {...}|None}
|
|
1570
|
+
"""
|
|
1571
|
+
tol = BBOX_TOL_MM if tol_mm is None else float(tol_mm)
|
|
1572
|
+
faces = ("xmin", "xmax", "ymin", "ymax", "zmin", "zmax")
|
|
1573
|
+
deltas, failed = {}, []
|
|
1574
|
+
for i, f in enumerate(faces):
|
|
1575
|
+
try:
|
|
1576
|
+
d = round(float(actual6[i]) - float(expect6[i]), 4)
|
|
1577
|
+
except Exception:
|
|
1578
|
+
d = None
|
|
1579
|
+
deltas[f] = d
|
|
1580
|
+
if d is None or abs(d) > tol:
|
|
1581
|
+
failed.append(f)
|
|
1582
|
+
# 按轴归并:轴级偏差 = 两个面里绝对值更大的那个(决定要不要改)
|
|
1583
|
+
by_axis = {}
|
|
1584
|
+
for i, ax in enumerate(_AXIS_NAMES):
|
|
1585
|
+
lo_f, hi_f = faces[2 * i], faces[2 * i + 1]
|
|
1586
|
+
d_lo, d_hi = deltas[lo_f], deltas[hi_f]
|
|
1587
|
+
cands = [d for d in (d_lo, d_hi) if d is not None]
|
|
1588
|
+
worst = max(cands, key=abs) if cands else None
|
|
1589
|
+
by_axis[ax] = {
|
|
1590
|
+
"min_face": lo_f, "max_face": hi_f,
|
|
1591
|
+
"delta_min_mm": d_lo, "delta_max_mm": d_hi,
|
|
1592
|
+
"worst_delta_mm": worst,
|
|
1593
|
+
"actual_size_mm": (round(float(actual6[2 * i + 1]) - float(actual6[2 * i]), 4)
|
|
1594
|
+
if actual6 else None),
|
|
1595
|
+
"expect_size_mm": (round(float(expect6[2 * i + 1]) - float(expect6[2 * i]), 4)
|
|
1596
|
+
if expect6 else None),
|
|
1597
|
+
}
|
|
1598
|
+
by_axis[ax]["size_delta_mm"] = (
|
|
1599
|
+
None if (by_axis[ax]["actual_size_mm"] is None
|
|
1600
|
+
or by_axis[ax]["expect_size_mm"] is None)
|
|
1601
|
+
else round(by_axis[ax]["actual_size_mm"] - by_axis[ax]["expect_size_mm"], 4))
|
|
1602
|
+
vals = [abs(v) for v in deltas.values() if v is not None]
|
|
1603
|
+
maxd = round(max(vals), 4) if vals else None
|
|
1604
|
+
res = {"matched": not failed, "tolerance_mm": tol, "deltas_mm": deltas,
|
|
1605
|
+
"max_abs_delta_mm": maxd, "failed_faces": failed,
|
|
1606
|
+
"deltas_by_axis": by_axis, "correction": None}
|
|
1607
|
+
if not failed:
|
|
1608
|
+
return res
|
|
1609
|
+
# ── 矫正建议:挑偏差最大的轴,说明差多少、往哪边改 ──────────────────
|
|
1610
|
+
axis = None
|
|
1611
|
+
best = -1.0
|
|
1612
|
+
for ax in _AXIS_NAMES:
|
|
1613
|
+
w = by_axis[ax]["worst_delta_mm"]
|
|
1614
|
+
if w is not None and abs(w) > best:
|
|
1615
|
+
best, axis = abs(w), ax
|
|
1616
|
+
aw = by_axis.get(axis) or {}
|
|
1617
|
+
size_d = aw.get("size_delta_mm")
|
|
1618
|
+
worst_d = aw.get("worst_delta_mm")
|
|
1619
|
+
if size_d is not None and abs(size_d) > tol:
|
|
1620
|
+
direction = "增大" if size_d < 0 else "减小"
|
|
1621
|
+
hint = ("%s 轴【尺寸】偏差 %+.4f mm(实测 %.4f / 期望 %.4f):"
|
|
1622
|
+
"该轴整体要%s %.4f mm。"
|
|
1623
|
+
% (axis.upper(), size_d, aw.get("actual_size_mm") or 0.0,
|
|
1624
|
+
aw.get("expect_size_mm") or 0.0, direction, abs(size_d)))
|
|
1625
|
+
else:
|
|
1626
|
+
direction = "正向" if (worst_d or 0) > 0 else "负向"
|
|
1627
|
+
hint = ("%s 轴【位置】偏差 %+.4f mm(面 %s):形状尺寸基本对,"
|
|
1628
|
+
"但整体沿 %s 轴%s偏移了 %.4f mm,需平移校正。"
|
|
1629
|
+
% (axis.upper(), worst_d or 0.0,
|
|
1630
|
+
aw.get("min_face") if abs(aw.get("delta_min_mm") or 0)
|
|
1631
|
+
>= abs(aw.get("delta_max_mm") or 0) else aw.get("max_face"),
|
|
1632
|
+
axis.upper(), direction, abs(worst_d or 0.0)))
|
|
1633
|
+
res["correction"] = {
|
|
1634
|
+
"axis": axis,
|
|
1635
|
+
"delta_mm": worst_d,
|
|
1636
|
+
"size_delta_mm": size_d,
|
|
1637
|
+
"failed_faces": failed,
|
|
1638
|
+
"hint": hint,
|
|
1639
|
+
"suggest": ("按上表修改参数后重建,然后【再走一轮看图比对】"
|
|
1640
|
+
"(闭环未 PASS 不得放行)。"),
|
|
1641
|
+
}
|
|
1642
|
+
return res
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def compare_features(actual_features, expect_features):
|
|
1646
|
+
"""比对特征存在性。expect features 支持字符串或 {"name":..,"count":..}。"""
|
|
1647
|
+
exp = []
|
|
1648
|
+
for f in (expect_features or []):
|
|
1649
|
+
if isinstance(f, dict):
|
|
1650
|
+
exp.append((str(f.get("name") or f.get("type") or "?"),
|
|
1651
|
+
f.get("count")))
|
|
1652
|
+
else:
|
|
1653
|
+
exp.append((str(f), None))
|
|
1654
|
+
if not exp:
|
|
1655
|
+
return {"checked": False, "missing": [], "matched": True,
|
|
1656
|
+
"note": "expect 未给 features,跳过特征存在性检查(不视为失败)"}
|
|
1657
|
+
have = {}
|
|
1658
|
+
for f in (actual_features or []):
|
|
1659
|
+
if isinstance(f, dict):
|
|
1660
|
+
nm = str(f.get("name") or f.get("type") or "")
|
|
1661
|
+
else:
|
|
1662
|
+
nm = str(f)
|
|
1663
|
+
if nm:
|
|
1664
|
+
have[nm] = have.get(nm, 0) + 1
|
|
1665
|
+
missing = []
|
|
1666
|
+
for nm, cnt in exp:
|
|
1667
|
+
n = have.get(nm, 0)
|
|
1668
|
+
if cnt is None:
|
|
1669
|
+
if n <= 0:
|
|
1670
|
+
missing.append({"feature": nm, "expect_count": ">=1",
|
|
1671
|
+
"actual_count": n})
|
|
1672
|
+
elif n != int(cnt):
|
|
1673
|
+
missing.append({"feature": nm, "expect_count": int(cnt),
|
|
1674
|
+
"actual_count": n})
|
|
1675
|
+
return {"checked": True, "missing": missing, "matched": not missing,
|
|
1676
|
+
"expect": [{"feature": a, "count": b} for a, b in exp],
|
|
1677
|
+
"actual": have}
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _collect_document_features(m):
|
|
1681
|
+
"""尽力从文档里取一份「特征名清单」(取不到就返回 None,不编造)。
|
|
1682
|
+
|
|
1683
|
+
实现方式与 swapi 既有遍历写法一致(swapi.py:7591-7605 用
|
|
1684
|
+
`FirstFeature` + `GetNextFeature()` + `GetTypeName2()`)。
|
|
1685
|
+
|
|
1686
|
+
── 【实机修复】FirstFeature 必须【按属性取】,不能当方法调 ──────────
|
|
1687
|
+
实机实测(2026-10-12,SW2025 SP5.0,late binding):
|
|
1688
|
+
· `model.FirstFeature()` → com_error(-2147352573, '找不到成员。')
|
|
1689
|
+
· `model.FirstFeature` → 正常返回 COMObject
|
|
1690
|
+
早期 SW 版本 / 静态绑定下它是方法,所以这里【两种都试】,
|
|
1691
|
+
任一种成功即可 —— 跨版本都能取到特征树。
|
|
1692
|
+
另:`f.GetNextFeature` 同样可能属性/方法两种形态,一并兼容。
|
|
1693
|
+
"""
|
|
1694
|
+
model = getattr(m, "model", None)
|
|
1695
|
+
if model is None:
|
|
1696
|
+
return None
|
|
1697
|
+
|
|
1698
|
+
def _is_feature_like(obj):
|
|
1699
|
+
"""判断 obj 是不是一个 Feature 对象(而不是绑定方法/None)。
|
|
1700
|
+
|
|
1701
|
+
★ 这是必需的鸭子类型判据:老版本 SW 下 `model.FirstFeature` 是
|
|
1702
|
+
【绑定方法】,它「不是 None」,若只判非空就会把方法本身当成
|
|
1703
|
+
特征节点,于是 Name/GetNextFeature 全取不到 → 静默返回 None,
|
|
1704
|
+
特征存在性检查就永远形同虚设(这正是回归测试用例13 抓到的缺陷)。
|
|
1705
|
+
"""
|
|
1706
|
+
if obj is None:
|
|
1707
|
+
return False
|
|
1708
|
+
try:
|
|
1709
|
+
return hasattr(obj, "Name") and hasattr(obj, "GetTypeName2")
|
|
1710
|
+
except Exception:
|
|
1711
|
+
return False
|
|
1712
|
+
|
|
1713
|
+
first = None
|
|
1714
|
+
# ① 先按【属性】取(本机 SW2025 实测:FirstFeature 是属性)
|
|
1715
|
+
try:
|
|
1716
|
+
cand = model.FirstFeature
|
|
1717
|
+
if _is_feature_like(cand):
|
|
1718
|
+
first = cand
|
|
1719
|
+
except Exception:
|
|
1720
|
+
pass
|
|
1721
|
+
# ② 属性取不到 → 按【方法】调用(老版本 / 静态绑定)
|
|
1722
|
+
if first is None:
|
|
1723
|
+
try:
|
|
1724
|
+
cand = model.FirstFeature()
|
|
1725
|
+
if _is_feature_like(cand):
|
|
1726
|
+
first = cand
|
|
1727
|
+
except Exception:
|
|
1728
|
+
pass
|
|
1729
|
+
if first is None:
|
|
1730
|
+
return None
|
|
1731
|
+
out = []
|
|
1732
|
+
f = first
|
|
1733
|
+
guard = 0
|
|
1734
|
+
while f is not None and guard < 5000:
|
|
1735
|
+
guard += 1
|
|
1736
|
+
try:
|
|
1737
|
+
nm = f.Name
|
|
1738
|
+
tn = None
|
|
1739
|
+
try:
|
|
1740
|
+
tn = f.GetTypeName2()
|
|
1741
|
+
except Exception:
|
|
1742
|
+
pass
|
|
1743
|
+
if nm:
|
|
1744
|
+
out.append({"name": str(nm),
|
|
1745
|
+
"type": (str(tn) if tn else None)})
|
|
1746
|
+
except Exception:
|
|
1747
|
+
pass
|
|
1748
|
+
# GetNextFeature 同样可能是属性或方法,用同一套鸭子类型判据
|
|
1749
|
+
nxt = None
|
|
1750
|
+
try:
|
|
1751
|
+
cand = f.GetNextFeature
|
|
1752
|
+
if _is_feature_like(cand):
|
|
1753
|
+
nxt = cand
|
|
1754
|
+
elif callable(cand):
|
|
1755
|
+
cand2 = cand()
|
|
1756
|
+
nxt = cand2 if _is_feature_like(cand2) else None
|
|
1757
|
+
except Exception:
|
|
1758
|
+
nxt = None
|
|
1759
|
+
if nxt is None:
|
|
1760
|
+
break
|
|
1761
|
+
f = nxt
|
|
1762
|
+
return out or None
|
|
1763
|
+
|
|
1764
|
+
|
|
1765
|
+
def check_and_correct(part_path, expect, max_rounds=MAX_ROUNDS_DEFAULT,
|
|
1766
|
+
out_dir=None):
|
|
1767
|
+
"""★ 核心:「看图 → 矫正 → 继续看 → 继续矫正」的强制闭环。
|
|
1768
|
+
|
|
1769
|
+
每一轮的流程:
|
|
1770
|
+
① 采集三视图 + 等轴测图(capture_views,真实调用 SW 切视图并截图);
|
|
1771
|
+
② 【真实回读几何】(read_actual_geometry → body_box_mm 六元组);
|
|
1772
|
+
③ 与 expect 比对(包围盒逐面容差 + 特征存在性);★ 判据是几何,不是「截了图」
|
|
1773
|
+
④ 一致 → verdict=PASS,结束;不一致 → 产出 correction(矫正指令),
|
|
1774
|
+
记录本轮,进入下一轮;
|
|
1775
|
+
⑤ 轮数用尽仍不一致 → final_verdict=FAIL,如实列出还差什么。
|
|
1776
|
+
|
|
1777
|
+
Args:
|
|
1778
|
+
part_path: 零件/装配体路径
|
|
1779
|
+
expect: 期望几何 dict(或 JSON / @文件)
|
|
1780
|
+
max_rounds: 最大轮数(默认 MAX_ROUNDS_DEFAULT=3;
|
|
1781
|
+
超过 MAX_ROUNDS_HARD_CAP 会被夹紧,防止绕过纠偏语义)
|
|
1782
|
+
out_dir: 留痕目录(默认工作目录下 visual_check/<零件>_<时间戳>)
|
|
1783
|
+
Returns:
|
|
1784
|
+
{"ok","rounds":[...],"final_verdict","note","evidence_file",...}
|
|
1785
|
+
rounds[i] 结构见模块文档;每轮都含 views / actual_bbox_mm /
|
|
1786
|
+
deltas_mm / verdict / correction。
|
|
1787
|
+
"""
|
|
1788
|
+
out_dir = os.path.abspath(out_dir) if out_dir else _default_out_dir(part_path)
|
|
1789
|
+
try:
|
|
1790
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
1791
|
+
except Exception as e:
|
|
1792
|
+
return {"ok": False, "final_verdict": VERDICT_ERROR, "rounds": [],
|
|
1793
|
+
"out_dir": out_dir,
|
|
1794
|
+
"error": "无法创建输出目录: %r" % (e,),
|
|
1795
|
+
"enforced_by": "visual_check.check_and_correct"}
|
|
1796
|
+
# max_rounds 规整:非法值夹紧到合法区间(不允许用 0/负数/超大值绕过闭环)
|
|
1797
|
+
try:
|
|
1798
|
+
mr = int(max_rounds)
|
|
1799
|
+
except Exception:
|
|
1800
|
+
mr = MAX_ROUNDS_DEFAULT
|
|
1801
|
+
note_rounds = []
|
|
1802
|
+
if mr < 1:
|
|
1803
|
+
mr = 1
|
|
1804
|
+
note_rounds.append("max_rounds < 1 已夹紧为 1(闭环至少看一轮)")
|
|
1805
|
+
if mr > MAX_ROUNDS_HARD_CAP:
|
|
1806
|
+
note_rounds.append("max_rounds=%s 超过硬上限 %d,已夹紧"
|
|
1807
|
+
% (max_rounds, MAX_ROUNDS_HARD_CAP))
|
|
1808
|
+
mr = MAX_ROUNDS_HARD_CAP
|
|
1809
|
+
|
|
1810
|
+
exp_raw, err = parse_expect(expect)
|
|
1811
|
+
if exp_raw is None:
|
|
1812
|
+
return {"ok": False, "final_verdict": VERDICT_ERROR, "rounds": [],
|
|
1813
|
+
"out_dir": out_dir, "error": err,
|
|
1814
|
+
"enforced_by": "visual_check.check_and_correct"}
|
|
1815
|
+
exp, err = normalize_expect(exp_raw)
|
|
1816
|
+
if exp is None:
|
|
1817
|
+
return {"ok": False, "final_verdict": VERDICT_ERROR, "rounds": [],
|
|
1818
|
+
"out_dir": out_dir, "error": "expect 不合法: %s" % err,
|
|
1819
|
+
"enforced_by": "visual_check.check_and_correct"}
|
|
1820
|
+
|
|
1821
|
+
loops = {"schema": "dsh-engineering/visual-loop@1",
|
|
1822
|
+
"part": os.path.abspath(part_path) if part_path else None,
|
|
1823
|
+
"out_dir": out_dir, "max_rounds": mr,
|
|
1824
|
+
"tolerance_mm": (exp["tolerance_mm"]
|
|
1825
|
+
if exp["tolerance_mm"] is not None else BBOX_TOL_MM),
|
|
1826
|
+
"expect": exp_raw, "expect_normalized": {
|
|
1827
|
+
"bbox_mm": exp["bbox_mm"], "features": exp["features"]},
|
|
1828
|
+
"started_at": _now_str(), "rounds": [],
|
|
1829
|
+
"final_verdict": None, "ok": False,
|
|
1830
|
+
"rounds_notes": note_rounds,
|
|
1831
|
+
"geometry_source": ("SWModel.body_box_mm() 真实回读 [swapi.py:7730]"),
|
|
1832
|
+
"judged_by": ("真实几何回读 vs expect(截图仅作证据,"
|
|
1833
|
+
"不作为通过依据)")}
|
|
1834
|
+
evidence = os.path.join(out_dir, LOOP_FILE)
|
|
1835
|
+
# 先落一份"开始"痕迹,即使中途异常也有据可查
|
|
1836
|
+
atomic_write_json(evidence, loops)
|
|
1837
|
+
|
|
1838
|
+
m, meta, open_err = _open_part_model(part_path)
|
|
1839
|
+
loops["open_meta"] = meta
|
|
1840
|
+
if m is None:
|
|
1841
|
+
loops["final_verdict"] = VERDICT_ERROR
|
|
1842
|
+
loops["ok"] = False
|
|
1843
|
+
loops["error"] = open_err
|
|
1844
|
+
loops["note"] = ("无法打开零件,闭环未开始 —— 绝不假装看过了。"
|
|
1845
|
+
"(%s)" % open_err)
|
|
1846
|
+
loops["finished_at"] = _now_str()
|
|
1847
|
+
atomic_write_json(evidence, loops)
|
|
1848
|
+
loops["evidence_file"] = evidence
|
|
1849
|
+
return loops
|
|
1850
|
+
|
|
1851
|
+
for rnd in range(1, mr + 1):
|
|
1852
|
+
rec = {"round": rnd, "at": _now_str(), "verdict": VERDICT_MISMATCH,
|
|
1853
|
+
"views": {}, "actual_bbox_mm": None, "deltas_mm": {},
|
|
1854
|
+
"correction": None, "error": None}
|
|
1855
|
+
# ① 采图(复用已打开的模型 + 分轮目录,避免覆盖上一轮证据)
|
|
1856
|
+
# verify_projection=True:每一轮都验算"三视图是否拍对了面"。
|
|
1857
|
+
#
|
|
1858
|
+
# ⚠️ 【语义修正·重要】这里**必须传 None**,让 capture_views 自己
|
|
1859
|
+
# 回读【真实】包围盒来算理论比值 —— 不能传 exp["bbox_mm"]。
|
|
1860
|
+
# 原因:投影验算问的是"相机拍的是不是正确的那个面",
|
|
1861
|
+
# 参照物是**模型实际尺寸**(渲染出来的就是它)。
|
|
1862
|
+
# 若拿【期望】尺寸去算理论比值,那么在"模型与期望不符"
|
|
1863
|
+
# (恰恰是本闭环要检测的主场景)时,投影验算会跟着一起失败,
|
|
1864
|
+
# 把两个互相独立的结论搅在一起:
|
|
1865
|
+
# · 几何比对 → 模型尺寸对不对
|
|
1866
|
+
# · 投影验算 → 图拍得对不对
|
|
1867
|
+
# 混用会让"模型尺寸错"被误报成"视图拍错了",误导矫正方向。
|
|
1868
|
+
views = capture_views(part_path, out_dir, views=list(DEFAULT_VIEWS),
|
|
1869
|
+
model=m, round_no=rnd,
|
|
1870
|
+
verify_projection=True,
|
|
1871
|
+
expect_bbox_mm=None)
|
|
1872
|
+
rec["views"] = {
|
|
1873
|
+
"ok": views.get("ok"),
|
|
1874
|
+
"out_dir": views.get("out_dir"),
|
|
1875
|
+
"captured": views.get("captured"),
|
|
1876
|
+
"failed": views.get("failed"),
|
|
1877
|
+
"error": views.get("error"),
|
|
1878
|
+
"duplicate_views": views.get("duplicate_views"),
|
|
1879
|
+
"projection_check": views.get("projection_check"),
|
|
1880
|
+
"shots": {v: {"ok": (rec_v or {}).get("ok"),
|
|
1881
|
+
"path": (rec_v or {}).get("path"),
|
|
1882
|
+
"size_bytes": (rec_v or {}).get("size_bytes"),
|
|
1883
|
+
"sha1_12": (rec_v or {}).get("sha1_12"),
|
|
1884
|
+
"vname": (rec_v or {}).get("vname"),
|
|
1885
|
+
"view_id": (rec_v or {}).get("view_id"),
|
|
1886
|
+
"method": (rec_v or {}).get("method"),
|
|
1887
|
+
"shot_source": (rec_v or {}).get("shot_source"),
|
|
1888
|
+
"error": (rec_v or {}).get("error")}
|
|
1889
|
+
for v, rec_v in (views.get("views") or {}).items()},
|
|
1890
|
+
}
|
|
1891
|
+
# ② 真实回读几何
|
|
1892
|
+
geo = read_actual_geometry(m)
|
|
1893
|
+
rec["geometry"] = {"ok": geo["ok"], "sources": geo["sources"],
|
|
1894
|
+
"mass": geo["mass"], "error": geo["error"]}
|
|
1895
|
+
if not geo["ok"]:
|
|
1896
|
+
rec["verdict"] = VERDICT_ERROR
|
|
1897
|
+
rec["error"] = geo["error"]
|
|
1898
|
+
rec["correction"] = {
|
|
1899
|
+
"axis": None, "delta_mm": None,
|
|
1900
|
+
"hint": ("回读不到几何(%s)—— 无法判定,不得视为通过。"
|
|
1901
|
+
"先确认零件里有实体(IBody2)再重建。" % geo["error"])}
|
|
1902
|
+
loops["rounds"].append(rec)
|
|
1903
|
+
atomic_write_json(evidence, loops)
|
|
1904
|
+
continue
|
|
1905
|
+
rec["actual_bbox_mm"] = geo["bbox_mm"]
|
|
1906
|
+
rec["actual_extents_mm"] = geo["extents_mm"]
|
|
1907
|
+
|
|
1908
|
+
# ③ 比对
|
|
1909
|
+
cmp_ok = True
|
|
1910
|
+
if exp["bbox_mm"] is not None:
|
|
1911
|
+
cmp = compare_bbox(geo["bbox_mm"], exp["bbox_mm"],
|
|
1912
|
+
exp["tolerance_mm"])
|
|
1913
|
+
rec["deltas_mm"] = cmp["deltas_mm"]
|
|
1914
|
+
rec["deltas_by_axis"] = cmp["deltas_by_axis"]
|
|
1915
|
+
rec["max_abs_delta_mm"] = cmp["max_abs_delta_mm"]
|
|
1916
|
+
rec["failed_faces"] = cmp["failed_faces"]
|
|
1917
|
+
rec["tolerance_mm"] = cmp["tolerance_mm"]
|
|
1918
|
+
cmp_ok = cmp["matched"]
|
|
1919
|
+
if not cmp_ok:
|
|
1920
|
+
rec["correction"] = cmp["correction"]
|
|
1921
|
+
else:
|
|
1922
|
+
rec["deltas_mm"] = {}
|
|
1923
|
+
rec["tolerance_mm"] = None
|
|
1924
|
+
rec["note"] = "expect 未给 bbox_mm,本轮只做特征存在性检查"
|
|
1925
|
+
|
|
1926
|
+
feats = _collect_document_features(m)
|
|
1927
|
+
fcmp = compare_features(feats, exp["features"])
|
|
1928
|
+
rec["feature_check"] = fcmp
|
|
1929
|
+
if fcmp["checked"] and not fcmp["matched"]:
|
|
1930
|
+
cmp_ok = False
|
|
1931
|
+
if rec["correction"] is None:
|
|
1932
|
+
rec["correction"] = {
|
|
1933
|
+
"axis": None, "delta_mm": None,
|
|
1934
|
+
"hint": ("缺少期望特征:%s —— 补齐这些特征后重建,再走一轮看图比对。"
|
|
1935
|
+
% "、".join(str(x.get("feature")) for x in fcmp["missing"])),
|
|
1936
|
+
"missing_features": fcmp["missing"]}
|
|
1937
|
+
else:
|
|
1938
|
+
rec["correction"]["missing_features"] = fcmp["missing"]
|
|
1939
|
+
elif not fcmp["checked"] and feats is None:
|
|
1940
|
+
# 特征清单读不到:如实标注,绝不当作"特征都在"
|
|
1941
|
+
rec["feature_check"]["note"] = (
|
|
1942
|
+
(fcmp.get("note") or "") +
|
|
1943
|
+
" / 另:文档特征树读取失败,本轮未能核对特征存在性")
|
|
1944
|
+
|
|
1945
|
+
# ④ 判定
|
|
1946
|
+
if cmp_ok:
|
|
1947
|
+
rec["verdict"] = VERDICT_PASS
|
|
1948
|
+
rec["correction"] = None
|
|
1949
|
+
else:
|
|
1950
|
+
rec["verdict"] = VERDICT_MISMATCH
|
|
1951
|
+
loops["rounds"].append(rec)
|
|
1952
|
+
atomic_write_json(evidence, loops)
|
|
1953
|
+
if rec["verdict"] == VERDICT_PASS:
|
|
1954
|
+
break
|
|
1955
|
+
|
|
1956
|
+
# ── ⑤ 汇总 ─────────────────────────────────────────────────────────
|
|
1957
|
+
verdicts = [r.get("verdict") for r in loops["rounds"]]
|
|
1958
|
+
passed = VERDICT_PASS in verdicts
|
|
1959
|
+
if passed:
|
|
1960
|
+
loops["ok"] = True
|
|
1961
|
+
loops["final_verdict"] = VERDICT_PASS
|
|
1962
|
+
_n = verdicts.index(VERDICT_PASS) + 1
|
|
1963
|
+
loops["passed_round"] = _n
|
|
1964
|
+
loops["note"] = ("第 %d 轮几何与期望一致,闭环通过(看图→矫正→继续看→"
|
|
1965
|
+
"继续矫正已完成)。" % _n)
|
|
1966
|
+
else:
|
|
1967
|
+
loops["ok"] = False
|
|
1968
|
+
# 轮次用尽仍不一致 → FAIL;若全是 ERROR(连几何都读不到)也判 FAIL,
|
|
1969
|
+
# 但用 final_verdict=FAIL 并如实说明「是读不到而不是不一致」。
|
|
1970
|
+
loops["final_verdict"] = VERDICT_FAIL
|
|
1971
|
+
miss = []
|
|
1972
|
+
last = loops["rounds"][-1] if loops["rounds"] else {}
|
|
1973
|
+
if last.get("failed_faces"):
|
|
1974
|
+
miss.append("包围盒仍有 %d 个面超差(%s),最大偏差 %s mm"
|
|
1975
|
+
% (len(last["failed_faces"]),
|
|
1976
|
+
"、".join(last["failed_faces"]),
|
|
1977
|
+
last.get("max_abs_delta_mm")))
|
|
1978
|
+
if (last.get("feature_check") or {}).get("missing"):
|
|
1979
|
+
miss.append("仍缺少特征:%s"
|
|
1980
|
+
% "、".join(str(x.get("feature"))
|
|
1981
|
+
for x in last["feature_check"]["missing"]))
|
|
1982
|
+
if not miss:
|
|
1983
|
+
miss.append("未能取得可用几何/特征判据(%s)"
|
|
1984
|
+
% (last.get("error") or "详见各轮 error"))
|
|
1985
|
+
loops["blockers"] = miss
|
|
1986
|
+
loops["note"] = ("已用满 %d 轮仍未与期望一致 —— 如实判 FAIL,"
|
|
1987
|
+
"不谎报成功。还差:%s" % (mr, ";".join(miss)))
|
|
1988
|
+
loops["finished_at"] = _now_str()
|
|
1989
|
+
loops["rounds_used"] = len(loops["rounds"])
|
|
1990
|
+
loops["evidence_file"] = evidence
|
|
1991
|
+
loops["enforced_by"] = "visual_check.check_and_correct"
|
|
1992
|
+
|
|
1993
|
+
# ── ★ 看图证据自检:即使几何 PASS,也要把"图到底有没有看成功"讲清楚 ──
|
|
1994
|
+
# 分层语义(很重要):
|
|
1995
|
+
# · 闭环的 ok/final_verdict 判的是【几何是否收敛】;
|
|
1996
|
+
# · 截图是【证据】,它的成立与否由 verify_visual_evidence 判,
|
|
1997
|
+
# 并由 enforce_visual_gate 作为放行的必要条件。
|
|
1998
|
+
# 但绝不能因为"几何对了"就把截图失败悄悄吞掉 —— 这里显式落一条
|
|
1999
|
+
# visual_evidence_ok + warnings,让上层一眼看见"这一轮其实没看到图"。
|
|
2000
|
+
ev = verify_visual_evidence(loops, out_dir=out_dir)
|
|
2001
|
+
loops["visual_evidence_ok"] = bool(ev.get("ok"))
|
|
2002
|
+
loops["visual_evidence"] = {
|
|
2003
|
+
"ok": ev.get("ok"), "checked_rounds": ev.get("checked_rounds"),
|
|
2004
|
+
"checked_shots": ev.get("checked_shots"),
|
|
2005
|
+
"total_bytes": ev.get("total_bytes"),
|
|
2006
|
+
"missing_count": len(ev.get("missing") or []),
|
|
2007
|
+
"bad_count": len(ev.get("bad") or []),
|
|
2008
|
+
"conclusion": ev.get("conclusion")}
|
|
2009
|
+
if not ev.get("ok"):
|
|
2010
|
+
warn = ("⚠️ 几何判定已完成,但【看图证据不成立】:%s "
|
|
2011
|
+
"—— 未通过 enforce_visual_gate 的必要条件,不得据此放行。"
|
|
2012
|
+
% (ev.get("conclusion") or ev.get("error") or "无截图记录"))
|
|
2013
|
+
loops.setdefault("warnings", []).append(warn)
|
|
2014
|
+
if loops.get("ok"):
|
|
2015
|
+
loops["note"] = (loops.get("note") or "") + " " + warn
|
|
2016
|
+
atomic_write_json(evidence, loops)
|
|
2017
|
+
return loops
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2021
|
+
# 六、C. 「看图真的发生过」的代码级证明
|
|
2022
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2023
|
+
def view_label_cn(view):
|
|
2024
|
+
"""取视图的中文标签(如 front → 前视)。"""
|
|
2025
|
+
return VIEW_LABEL_CN.get(view, view)
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
def view_labels():
|
|
2029
|
+
"""返回 视图名 → 中文标签 的映射(供调用方展示)。"""
|
|
2030
|
+
return {k: VIEW_LABEL_CN.get(k, k) for k in VIEW_VNAME}
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
def verify_visual_evidence(loop_record, out_dir=None):
|
|
2034
|
+
"""校验「看图」这件事真的发生过(每轮引用的截图必须真实存在且非空)。
|
|
2035
|
+
|
|
2036
|
+
强制点(缺一不可):
|
|
2037
|
+
① loop_record 里必须有 rounds,且至少 1 轮;
|
|
2038
|
+
② 每轮必须至少有 1 张截图记录,且每张图的路径必须真实存在于磁盘;
|
|
2039
|
+
③ 文件必须是普通文件且 size > 0(并 > MIN_SHOT_BYTES 字节);
|
|
2040
|
+
④ 每张图的 sha1 必须能算出来(内容可读)。
|
|
2041
|
+
|
|
2042
|
+
任一条不满足 → ok=False,并逐条指出是【哪一轮、哪张图、什么毛病】。
|
|
2043
|
+
这是用户「必须要让模型自己看到他建的是什么样子的东西」的代码级证明。
|
|
2044
|
+
|
|
2045
|
+
Args:
|
|
2046
|
+
loop_record: check_and_correct 的返回值,或 visual_loop.json 的路径
|
|
2047
|
+
out_dir: 可选;record 里没有绝对路径时用作相对路径基准
|
|
2048
|
+
Returns:
|
|
2049
|
+
{"ok","checked_rounds","checked_shots","missing":[...],
|
|
2050
|
+
"bad":[...],"total_bytes","detail":[...]}
|
|
2051
|
+
"""
|
|
2052
|
+
out = {"ok": False, "checked_rounds": 0, "checked_shots": 0,
|
|
2053
|
+
"missing": [], "bad": [], "total_bytes": 0, "detail": [],
|
|
2054
|
+
"source": None}
|
|
2055
|
+
rec = loop_record
|
|
2056
|
+
if isinstance(rec, str):
|
|
2057
|
+
out["source"] = os.path.abspath(rec)
|
|
2058
|
+
rec, err = read_json(rec)
|
|
2059
|
+
if rec is None:
|
|
2060
|
+
out["error"] = "读取闭环记录失败(%s): %s" % (loop_record, err)
|
|
2061
|
+
return out
|
|
2062
|
+
if not isinstance(rec, dict):
|
|
2063
|
+
out["error"] = "闭环记录必须是对象或 JSON 路径,收到 %s" % type(rec).__name__
|
|
2064
|
+
return out
|
|
2065
|
+
rounds = rec.get("rounds")
|
|
2066
|
+
if not isinstance(rounds, list) or not rounds:
|
|
2067
|
+
out["error"] = ("闭环记录里没有 rounds —— 没有「看过图」的痕迹,"
|
|
2068
|
+
"视为未看图")
|
|
2069
|
+
return out
|
|
2070
|
+
out["checked_rounds"] = len(rounds)
|
|
2071
|
+
|
|
2072
|
+
def _resolve(p):
|
|
2073
|
+
if not p:
|
|
2074
|
+
return p
|
|
2075
|
+
if os.path.isabs(p):
|
|
2076
|
+
return p
|
|
2077
|
+
base = out_dir or rec.get("out_dir") or os.getcwd()
|
|
2078
|
+
return os.path.join(base, p)
|
|
2079
|
+
|
|
2080
|
+
for r in rounds:
|
|
2081
|
+
rno = (r or {}).get("round")
|
|
2082
|
+
views = (r or {}).get("views") or {}
|
|
2083
|
+
shots = views.get("shots") or {}
|
|
2084
|
+
# 兼容两种写法:shots 字典(本模块产出)/ 直接给路径列表
|
|
2085
|
+
items = []
|
|
2086
|
+
if isinstance(shots, dict):
|
|
2087
|
+
items = [(k, v) for k, v in shots.items()]
|
|
2088
|
+
elif isinstance(shots, list):
|
|
2089
|
+
items = [(None, v) for v in shots]
|
|
2090
|
+
if not items and isinstance(views.get("paths"), dict):
|
|
2091
|
+
items = [(k, v) for k, v in views["paths"].items()]
|
|
2092
|
+
if not items:
|
|
2093
|
+
out["missing"].append({
|
|
2094
|
+
"round": rno,
|
|
2095
|
+
"why": "该轮没有任何截图记录(views.shots 为空)—— "
|
|
2096
|
+
"这一轮等于「没看图」",
|
|
2097
|
+
})
|
|
2098
|
+
continue
|
|
2099
|
+
for vname, v in items:
|
|
2100
|
+
vname = vname or (v or {}).get("view")
|
|
2101
|
+
p = _resolve((v or {}).get("path") if isinstance(v, dict) else v)
|
|
2102
|
+
probe = file_probe(p)
|
|
2103
|
+
d = {"round": rno, "view": vname, "path": probe["path"],
|
|
2104
|
+
"exists": probe["exists"], "size_bytes": probe["size_bytes"],
|
|
2105
|
+
"ok": bool(probe["ok"]), "why": probe["why"]}
|
|
2106
|
+
out["detail"].append(d)
|
|
2107
|
+
out["checked_shots"] += 1
|
|
2108
|
+
out["total_bytes"] += int(probe["size_bytes"] or 0)
|
|
2109
|
+
if not probe["ok"]:
|
|
2110
|
+
entry = {"round": rno, "view": vname,
|
|
2111
|
+
"path": probe["path"],
|
|
2112
|
+
"size_bytes": probe["size_bytes"],
|
|
2113
|
+
"why": probe["why"]}
|
|
2114
|
+
if not probe["exists"]:
|
|
2115
|
+
out["missing"].append(entry)
|
|
2116
|
+
else:
|
|
2117
|
+
out["bad"].append(entry)
|
|
2118
|
+
out["ok"] = (not out["missing"]) and (not out["bad"]) \
|
|
2119
|
+
and out["checked_shots"] > 0
|
|
2120
|
+
if out["ok"]:
|
|
2121
|
+
out["conclusion"] = ("已核实 %d 轮 / %d 张截图真实存在且非空"
|
|
2122
|
+
"(合计 %d 字节)—— 「看过图」有磁盘证据。"
|
|
2123
|
+
% (out["checked_rounds"], out["checked_shots"],
|
|
2124
|
+
out["total_bytes"]))
|
|
2125
|
+
else:
|
|
2126
|
+
out["conclusion"] = ("「看过图」的证据不成立:缺失 %d 张、异常 %d 张。"
|
|
2127
|
+
"详见 missing/bad。"
|
|
2128
|
+
% (len(out["missing"]), len(out["bad"])))
|
|
2129
|
+
return out
|
|
2130
|
+
|
|
2131
|
+
|
|
2132
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2133
|
+
# 七、C2. 与既有流程衔接(只读调用,不改别人的文件)
|
|
2134
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2135
|
+
def _expect_to_dims(expect_norm, loop_record):
|
|
2136
|
+
"""把 expect/实测包围盒折算成 mode2_flow 上报习惯里的 dims 口径。"""
|
|
2137
|
+
dims = {}
|
|
2138
|
+
bbox = None
|
|
2139
|
+
if isinstance(expect_norm, dict):
|
|
2140
|
+
bbox = expect_norm.get("bbox_mm")
|
|
2141
|
+
if not bbox:
|
|
2142
|
+
rounds = (loop_record or {}).get("rounds") or []
|
|
2143
|
+
for r in reversed(rounds):
|
|
2144
|
+
if r.get("actual_bbox_mm"):
|
|
2145
|
+
bbox = r["actual_bbox_mm"]
|
|
2146
|
+
break
|
|
2147
|
+
if isinstance(bbox, list) and len(bbox) == 6:
|
|
2148
|
+
dims = {
|
|
2149
|
+
"x_mm": round(float(bbox[1]) - float(bbox[0]), 4),
|
|
2150
|
+
"y_mm": round(float(bbox[3]) - float(bbox[2]), 4),
|
|
2151
|
+
"z_mm": round(float(bbox[5]) - float(bbox[4]), 4),
|
|
2152
|
+
"bbox_mm": [round(float(v), 4) for v in bbox],
|
|
2153
|
+
"source": "visual_check(实测/期望包围盒)",
|
|
2154
|
+
}
|
|
2155
|
+
return dims
|
|
2156
|
+
|
|
2157
|
+
|
|
2158
|
+
def _projection_summary(loop_record):
|
|
2159
|
+
"""从闭环记录里摘出「投影面验算」结论(供报告/上报引用)。
|
|
2160
|
+
|
|
2161
|
+
取最后一轮里 `views.projection_check` 的结果;没有就返回 None
|
|
2162
|
+
(表示当时未开启验算 —— 如实留空,不假装验过)。
|
|
2163
|
+
"""
|
|
2164
|
+
try:
|
|
2165
|
+
rounds = (loop_record or {}).get("rounds") or []
|
|
2166
|
+
for r in reversed(rounds):
|
|
2167
|
+
pc = ((r.get("views") or {}).get("projection_check"))
|
|
2168
|
+
if pc:
|
|
2169
|
+
return {"ok": pc.get("ok"), "verified": pc.get("verified"),
|
|
2170
|
+
"tolerance": pc.get("tolerance"),
|
|
2171
|
+
"checked": [x.get("view") for x in
|
|
2172
|
+
(pc.get("checked") or [])],
|
|
2173
|
+
"failed": [x.get("view") for x in
|
|
2174
|
+
(pc.get("failed") or [])],
|
|
2175
|
+
"note": pc.get("note")}
|
|
2176
|
+
except Exception:
|
|
2177
|
+
pass
|
|
2178
|
+
return None
|
|
2179
|
+
|
|
2180
|
+
|
|
2181
|
+
def report_visual_result(room, part_name, loop_record, work_dir=None,
|
|
2182
|
+
credentials=None, adjacent=None):
|
|
2183
|
+
"""把视觉闭环结论登记进模式二计划文件;mode2_flow 不可用时【降级并标注】。
|
|
2184
|
+
|
|
2185
|
+
只做两件事,且都【只读地】使用 mode2_flow 的公开 API
|
|
2186
|
+
(load_plan / save_plan / record_prime_report),不修改其源码:
|
|
2187
|
+
· 往 plan["notes"] 追加一条视觉闭环记录(append_note 语义);
|
|
2188
|
+
· 若调用方给了 room/part_name,再尝试走 record_prime_report 的字段习惯
|
|
2189
|
+
登记一条 prime report(缺必填字段时会被它拒绝,本函数如实转述)。
|
|
2190
|
+
|
|
2191
|
+
降级路径:mode2_flow 不在 / 计划读不出来 → 只写本地 JSON 证据文件
|
|
2192
|
+
`visual_report.json`(在 loop_record 所在目录),并显式标注 degraded=True。
|
|
2193
|
+
|
|
2194
|
+
Args:
|
|
2195
|
+
credentials: 【可选】三道防线凭据 {material, physics, domain}。
|
|
2196
|
+
⚠️ 这三条线**不是**视觉闭环的产出 —— 必须由建模小屋/上游提供。
|
|
2197
|
+
不传时本函数会把它们如实登记为「未提供」,**不**编造占位链接
|
|
2198
|
+
(旧实现填的"见材料防线报告"会被误读成已提供凭据)。
|
|
2199
|
+
adjacent: 【可选】该零件直接接触/配合的零件名列表(可为空列表)。
|
|
2200
|
+
"""
|
|
2201
|
+
out = {"ok": False, "degraded": True, "mode2_flow_available": False,
|
|
2202
|
+
"room": room, "part_name": part_name,
|
|
2203
|
+
"record_file": None, "degraded_file": None,
|
|
2204
|
+
"evidence_verified": None, "error": None,
|
|
2205
|
+
"enforced_by": "visual_check.report_visual_result"}
|
|
2206
|
+
rec = loop_record
|
|
2207
|
+
if isinstance(rec, str):
|
|
2208
|
+
rec, err = read_json(rec)
|
|
2209
|
+
if rec is None:
|
|
2210
|
+
out["error"] = "读取闭环记录失败: %s" % err
|
|
2211
|
+
return out
|
|
2212
|
+
if not isinstance(rec, dict):
|
|
2213
|
+
out["error"] = "loop_record 必须是对象或 JSON 路径"
|
|
2214
|
+
return out
|
|
2215
|
+
out["record_file"] = rec.get("evidence_file")
|
|
2216
|
+
out_dir = rec.get("out_dir") or (os.path.dirname(out["record_file"])
|
|
2217
|
+
if out["record_file"] else os.getcwd())
|
|
2218
|
+
# 先做证据核实(报告里必须带"看图证据是否成立")
|
|
2219
|
+
ev = verify_visual_evidence(rec, out_dir=out_dir)
|
|
2220
|
+
out["evidence_verified"] = {"ok": ev.get("ok"),
|
|
2221
|
+
"shots": ev.get("checked_shots"),
|
|
2222
|
+
"missing": len(ev.get("missing") or []),
|
|
2223
|
+
"bad": len(ev.get("bad") or []),
|
|
2224
|
+
"conclusion": ev.get("conclusion")}
|
|
2225
|
+
payload = {
|
|
2226
|
+
"schema": "dsh-engineering/visual-report@1",
|
|
2227
|
+
"at": _now_str(),
|
|
2228
|
+
"room": room, "part_name": part_name,
|
|
2229
|
+
"part": rec.get("part"),
|
|
2230
|
+
"final_verdict": rec.get("final_verdict"),
|
|
2231
|
+
"rounds_used": rec.get("rounds_used"),
|
|
2232
|
+
"max_rounds": rec.get("max_rounds"),
|
|
2233
|
+
"tolerance_mm": rec.get("tolerance_mm"),
|
|
2234
|
+
"evidence_file": rec.get("evidence_file"),
|
|
2235
|
+
"visual_evidence": out["evidence_verified"],
|
|
2236
|
+
"projection_verified": _projection_summary(rec),
|
|
2237
|
+
"blockers": rec.get("blockers") or [],
|
|
2238
|
+
"dims": _expect_to_dims(rec.get("expect_normalized"), rec),
|
|
2239
|
+
"note": rec.get("note"),
|
|
2240
|
+
}
|
|
2241
|
+
# 无论走哪条路,本地证据文件都落一份(供父代理直接复查)
|
|
2242
|
+
try:
|
|
2243
|
+
local = os.path.join(out_dir or os.getcwd(), "visual_report.json")
|
|
2244
|
+
atomic_write_json(local, payload)
|
|
2245
|
+
out["degraded_file"] = local
|
|
2246
|
+
except Exception as e:
|
|
2247
|
+
out["error"] = "写本地证据失败: %r" % (e,)
|
|
2248
|
+
|
|
2249
|
+
m2, m2err = _try_import_mode2()
|
|
2250
|
+
if m2 is None:
|
|
2251
|
+
out["ok"] = True
|
|
2252
|
+
out["degraded"] = True
|
|
2253
|
+
out["degraded_reason"] = ("mode2_flow 不可用(%s)—— 已降级为"
|
|
2254
|
+
"只写本地 JSON 证据文件,未登记进模式二计划。"
|
|
2255
|
+
% m2err)
|
|
2256
|
+
return out
|
|
2257
|
+
out["mode2_flow_available"] = True
|
|
2258
|
+
try:
|
|
2259
|
+
plan = m2.load_plan(work_dir)
|
|
2260
|
+
except Exception as e:
|
|
2261
|
+
plan = {"_load_error": repr(e)}
|
|
2262
|
+
if isinstance(plan, dict) and plan.get("_load_error"):
|
|
2263
|
+
out["ok"] = True
|
|
2264
|
+
out["degraded"] = True
|
|
2265
|
+
out["degraded_reason"] = ("mode2 计划文件不可读(%s)—— 已降级为本地证据。"
|
|
2266
|
+
% plan["_load_error"])
|
|
2267
|
+
return out
|
|
2268
|
+
try:
|
|
2269
|
+
note = {
|
|
2270
|
+
"kind": "visual_loop",
|
|
2271
|
+
"room": room, "part_name": part_name, "part": rec.get("part"),
|
|
2272
|
+
"final_verdict": rec.get("final_verdict"),
|
|
2273
|
+
"rounds_used": rec.get("rounds_used"),
|
|
2274
|
+
"evidence_file": rec.get("evidence_file"),
|
|
2275
|
+
"visual_evidence_ok": out["evidence_verified"]["ok"],
|
|
2276
|
+
"blockers": rec.get("blockers") or [],
|
|
2277
|
+
"at": payload["at"],
|
|
2278
|
+
}
|
|
2279
|
+
notes = plan.get("notes")
|
|
2280
|
+
if not isinstance(notes, list):
|
|
2281
|
+
notes = []
|
|
2282
|
+
notes.append(note)
|
|
2283
|
+
plan["notes"] = notes[-200:]
|
|
2284
|
+
save_res = m2.save_plan(plan, work_dir)
|
|
2285
|
+
out["plan_path"] = (save_res or {}).get("path")
|
|
2286
|
+
out["plan_note"] = note
|
|
2287
|
+
if not (save_res or {}).get("ok"):
|
|
2288
|
+
out["degraded"] = True
|
|
2289
|
+
out["degraded_reason"] = ("写模式二计划失败(%s)—— 已保留本地证据。"
|
|
2290
|
+
% (save_res or {}).get("error"))
|
|
2291
|
+
else:
|
|
2292
|
+
out["degraded"] = False
|
|
2293
|
+
except Exception as e:
|
|
2294
|
+
out["degraded"] = True
|
|
2295
|
+
out["degraded_reason"] = "登记进 mode2 计划异常(%r)—— 已保留本地证据。" % (e,)
|
|
2296
|
+
|
|
2297
|
+
# ── 可选:按 record_prime_report 的字段习惯再登记一条 ────────────────
|
|
2298
|
+
# 只在 room/part_name 都给了、且它确实提供该函数时尝试;
|
|
2299
|
+
# 缺必填字段会被它拒绝 —— 本函数【如实转述】它的拒绝原因,不掩盖。
|
|
2300
|
+
#
|
|
2301
|
+
# ⚠️ 【如实性修正】三道防线凭据(material/physics/domain)**不是**
|
|
2302
|
+
# 本模块的职责 —— 视觉闭环只证明"看过图且几何对上"。
|
|
2303
|
+
# 原实现填的是"见材料防线报告"这类**看起来像链接、实际不是**的
|
|
2304
|
+
# 占位文字,会让总理子代理误以为凭据已给。
|
|
2305
|
+
# 现在:调用方给了 credentials 就用调用方的;没给就显式写成
|
|
2306
|
+
# "未提供(不由 visual_check 产出)",绝不伪装成已提供。
|
|
2307
|
+
if room and part_name and hasattr(m2, "record_prime_report"):
|
|
2308
|
+
try:
|
|
2309
|
+
if isinstance(credentials, dict) and credentials:
|
|
2310
|
+
cred = {k: credentials.get(k) for k in
|
|
2311
|
+
("material", "physics", "domain")}
|
|
2312
|
+
else:
|
|
2313
|
+
cred = {k: "未提供(不由 visual_check 产出,需建模小屋自行上报)"
|
|
2314
|
+
for k in ("material", "physics", "domain")}
|
|
2315
|
+
rep = {"part_name": part_name, "room": room,
|
|
2316
|
+
"file": rec.get("part") or "",
|
|
2317
|
+
"dims": payload["dims"] or {"note": "visual_check 未取得尺寸"},
|
|
2318
|
+
"credentials": cred,
|
|
2319
|
+
"adjacent": list(adjacent) if adjacent else [],
|
|
2320
|
+
"visual_loop": {"final_verdict": rec.get("final_verdict"),
|
|
2321
|
+
"evidence_file": rec.get("evidence_file"),
|
|
2322
|
+
"visual_evidence_ok": out["evidence_verified"]["ok"],
|
|
2323
|
+
"projection_verified": payload.get(
|
|
2324
|
+
"projection_verified")}}
|
|
2325
|
+
pr = m2.record_prime_report(rep, work_dir)
|
|
2326
|
+
out["prime_report"] = {"ok": (pr or {}).get("ok"),
|
|
2327
|
+
"error": (pr or {}).get("error"),
|
|
2328
|
+
"note": (pr or {}).get("note")}
|
|
2329
|
+
if not cred.get("material") or "未提供" in str(cred.get("material")):
|
|
2330
|
+
out.setdefault("warnings", []).append(
|
|
2331
|
+
"三道防线凭据未由调用方提供,已如实标注为「未提供」—— "
|
|
2332
|
+
"总理子代理如需凭据请让建模小屋另行上报。")
|
|
2333
|
+
if (pr or {}).get("ok"):
|
|
2334
|
+
out["degraded"] = out["degraded"] and False
|
|
2335
|
+
except Exception as e:
|
|
2336
|
+
out["prime_report"] = {"ok": False, "error": repr(e)}
|
|
2337
|
+
out["ok"] = True
|
|
2338
|
+
return out
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2342
|
+
# 八、D. ★ 建模后强制闭环节点(房间收尾必须走的门)
|
|
2343
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2344
|
+
def enforce_visual_gate(part_path, expect, out_dir=None,
|
|
2345
|
+
max_rounds=MAX_ROUNDS_DEFAULT, room=None):
|
|
2346
|
+
"""建模成功之后【必须】走的视觉门。
|
|
2347
|
+
|
|
2348
|
+
判定顺序(任一环节不过 → passed=False,上游据此拒绝放行):
|
|
2349
|
+
① 跑 check_and_correct 闭环;
|
|
2350
|
+
② 闭环 final_verdict 必须 == PASS;
|
|
2351
|
+
③ 「看图证据」必须经 verify_visual_evidence 核实成立
|
|
2352
|
+
(每张截图真实存在且非空)—— 光"几何对"不够,还【必须看过图】;
|
|
2353
|
+
④ 结果落盘到 out_dir/visual_gate.json。
|
|
2354
|
+
|
|
2355
|
+
Returns:
|
|
2356
|
+
{"ok","passed","evidence_file","loop_file","blockers","enforced_by",
|
|
2357
|
+
"final_verdict","rounds_used","visual_evidence", "expect",...}
|
|
2358
|
+
· passed=False 时 ok=False,blockers 列出未通过的全部原因。
|
|
2359
|
+
· enforced_by 恒为 "visual_check.enforce_visual_gate"(表明是代码强制,
|
|
2360
|
+
不是提示词约定)。
|
|
2361
|
+
"""
|
|
2362
|
+
out_dir = os.path.abspath(out_dir) if out_dir else _default_out_dir(part_path)
|
|
2363
|
+
out = {"ok": False, "passed": False, "blockers": [],
|
|
2364
|
+
"evidence_file": None, "loop_file": None,
|
|
2365
|
+
"enforced_by": "visual_check.enforce_visual_gate",
|
|
2366
|
+
"at": _now_str(), "part": (os.path.abspath(part_path)
|
|
2367
|
+
if part_path else None)}
|
|
2368
|
+
try:
|
|
2369
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
2370
|
+
except Exception as e:
|
|
2371
|
+
out["blockers"].append("无法创建输出目录 %s: %r" % (out_dir, e))
|
|
2372
|
+
out["error"] = out["blockers"][-1]
|
|
2373
|
+
return out
|
|
2374
|
+
gate_file = os.path.join(out_dir, GATE_FILE)
|
|
2375
|
+
out["evidence_file"] = gate_file
|
|
2376
|
+
|
|
2377
|
+
# ── 【Bug⑨】看图闭环是长任务:跑之前先推进一次 stage ─────────────────
|
|
2378
|
+
# 闭环可能要跑 3~10 轮(切视图→截图→回读几何→矫正),期间房间的
|
|
2379
|
+
# room-report 会一直停在 modeling,主代理分不清"在矫正"还是"已僵死"。
|
|
2380
|
+
# 本调用严格幂等、失败不阻断(详见 push_room_validating 的契约)。
|
|
2381
|
+
_stage_push = push_room_validating(
|
|
2382
|
+
room, "看图矫正中(visual_check.enforce_visual_gate: %s)"
|
|
2383
|
+
% os.path.basename(str(part_path or "")))
|
|
2384
|
+
|
|
2385
|
+
loop = check_and_correct(part_path, expect, max_rounds=max_rounds,
|
|
2386
|
+
out_dir=out_dir)
|
|
2387
|
+
out["loop_file"] = loop.get("evidence_file")
|
|
2388
|
+
out["final_verdict"] = loop.get("final_verdict")
|
|
2389
|
+
out["rounds_used"] = loop.get("rounds_used")
|
|
2390
|
+
out["expect"] = loop.get("expect")
|
|
2391
|
+
out["note"] = loop.get("note")
|
|
2392
|
+
if not loop.get("ok"):
|
|
2393
|
+
out["blockers"].append(
|
|
2394
|
+
"视觉闭环未通过(final_verdict=%s):%s"
|
|
2395
|
+
% (loop.get("final_verdict"), loop.get("note") or loop.get("error")))
|
|
2396
|
+
for b in (loop.get("blockers") or []):
|
|
2397
|
+
out["blockers"].append("闭环残留问题: %s" % b)
|
|
2398
|
+
# ③ 看图证据核实
|
|
2399
|
+
ev = verify_visual_evidence(loop, out_dir=out_dir)
|
|
2400
|
+
out["stage_push"] = _stage_push
|
|
2401
|
+
out["visual_evidence"] = {
|
|
2402
|
+
"ok": ev.get("ok"), "checked_rounds": ev.get("checked_rounds"),
|
|
2403
|
+
"checked_shots": ev.get("checked_shots"),
|
|
2404
|
+
"total_bytes": ev.get("total_bytes"),
|
|
2405
|
+
"missing": ev.get("missing"), "bad": ev.get("bad"),
|
|
2406
|
+
"error": ev.get("error"),
|
|
2407
|
+
"conclusion": ev.get("conclusion")}
|
|
2408
|
+
if not ev.get("ok"):
|
|
2409
|
+
# conclusion 在"连 rounds 都没有"时为空 —— 退化成 error,避免出现
|
|
2410
|
+
# 「看图证据不成立:None」这种看不出所以然的提示。
|
|
2411
|
+
_why = ev.get("conclusion") or ev.get("error") or "闭环未产出任何截图记录"
|
|
2412
|
+
out["blockers"].append("看图证据不成立:%s" % _why)
|
|
2413
|
+
# ③b 投影面验算(若闭环里做过):图都在、但拍错了面也必须拦。
|
|
2414
|
+
# 注意区分「验算失败」与「没验成」—— 后者不拦,只如实记录。
|
|
2415
|
+
proj = _projection_summary(loop)
|
|
2416
|
+
out["projection_verified"] = proj
|
|
2417
|
+
if proj is not None and proj.get("failed") and not proj.get("ok"):
|
|
2418
|
+
out["blockers"].append(
|
|
2419
|
+
"三视图投影面验算未通过:%s(拍到的图不是正确的三视图)"
|
|
2420
|
+
% proj.get("note"))
|
|
2421
|
+
elif proj is not None and not proj.get("ok"):
|
|
2422
|
+
out.setdefault("warnings", []).append(
|
|
2423
|
+
"投影面验算未完成:%s" % proj.get("note"))
|
|
2424
|
+
out["passed"] = (bool(loop.get("ok")) and bool(ev.get("ok"))
|
|
2425
|
+
and not (proj is not None and proj.get("failed")
|
|
2426
|
+
and not proj.get("ok")))
|
|
2427
|
+
out["ok"] = out["passed"]
|
|
2428
|
+
if out["passed"]:
|
|
2429
|
+
out["note"] = ("建模后视觉门通过:已看 %d 轮 / %d 张截图,"
|
|
2430
|
+
"第 %s 轮几何与期望一致%s。"
|
|
2431
|
+
% (ev.get("checked_rounds"), ev.get("checked_shots"),
|
|
2432
|
+
loop.get("passed_round"),
|
|
2433
|
+
",且三视图投影面验算通过" if proj else
|
|
2434
|
+
"(投影面验算未开启)"))
|
|
2435
|
+
else:
|
|
2436
|
+
out["note"] = ("视觉门未通过 —— 上游房间收尾【不得放行】。原因见 blockers。")
|
|
2437
|
+
try:
|
|
2438
|
+
atomic_write_json(gate_file, out)
|
|
2439
|
+
except Exception as e:
|
|
2440
|
+
out["blockers"].append("写门证据失败: %r" % (e,))
|
|
2441
|
+
out["ok"] = out["passed"] = False
|
|
2442
|
+
return out
|
|
2443
|
+
|
|
2444
|
+
|
|
2445
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2446
|
+
# 九、CLI
|
|
2447
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2448
|
+
def _build_parser():
|
|
2449
|
+
p = argparse.ArgumentParser(
|
|
2450
|
+
prog="visual_check.py",
|
|
2451
|
+
description=("建模后视觉闭环:三视图+等轴测截图 → 真实回读几何比对 → "
|
|
2452
|
+
"矫正 → 继续看 → 继续矫正(全部为代码级强制)。"),
|
|
2453
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2454
|
+
epilog=("示例:\n"
|
|
2455
|
+
" python visual_check.py capture --part DSH_底座.SLDPRT --out .\\shots\n"
|
|
2456
|
+
" python visual_check.py loop --part A.SLDPRT --expect \"{\\\"bbox_mm\\\":[-50,50,-30,30,0,40]}\"\n"
|
|
2457
|
+
" python visual_check.py loop --part A.SLDPRT --expect @expect.json --max-rounds 4\n"
|
|
2458
|
+
" python visual_check.py verify --record .\\visual_check\\A_20250101_120000\\visual_loop.json\n"
|
|
2459
|
+
" python visual_check.py gate --part A.SLDPRT --expect @expect.json\n"))
|
|
2460
|
+
sub = p.add_subparsers(dest="cmd", metavar="<命令>")
|
|
2461
|
+
|
|
2462
|
+
c1 = sub.add_parser("capture", help="采集三视图 + 等轴测截图")
|
|
2463
|
+
c1.add_argument("--part", required=True, help="零件/装配体路径(.sldprt/.sldasm)")
|
|
2464
|
+
c1.add_argument("--out", required=True, help="截图输出目录")
|
|
2465
|
+
c1.add_argument("--views", default=",".join(DEFAULT_VIEWS),
|
|
2466
|
+
help="视图列表,逗号分隔,默认 front,top,right,iso")
|
|
2467
|
+
c1.add_argument("--verify-projection", action="store_true",
|
|
2468
|
+
dest="verify_projection",
|
|
2469
|
+
help="开启三视图投影面验算(量轮廓宽高比与理论值比对,"
|
|
2470
|
+
"证明拍对了面而不只是图不一样)")
|
|
2471
|
+
c1.add_argument("--expect", default=None,
|
|
2472
|
+
help="可选:提供期望 bbox 供投影验算算理论比值"
|
|
2473
|
+
"(不提供则由模块自行回读真实包围盒)")
|
|
2474
|
+
|
|
2475
|
+
c2 = sub.add_parser("loop", help="跑「看图→矫正」闭环(真实回读几何判定)")
|
|
2476
|
+
c2.add_argument("--part", required=True, help="零件/装配体路径")
|
|
2477
|
+
c2.add_argument("--expect", required=True,
|
|
2478
|
+
help='期望几何 JSON,或 @文件.json 路径')
|
|
2479
|
+
c2.add_argument("--out", default=None, help="留痕目录(默认自动生成)")
|
|
2480
|
+
c2.add_argument("--max-rounds", type=int, default=MAX_ROUNDS_DEFAULT,
|
|
2481
|
+
dest="max_rounds",
|
|
2482
|
+
help="最大轮数,默认 %d(硬上限 %d)"
|
|
2483
|
+
% (MAX_ROUNDS_DEFAULT, MAX_ROUNDS_HARD_CAP))
|
|
2484
|
+
c2.add_argument("--room", default=None,
|
|
2485
|
+
help="所属房间名(可选)。给了才推进 room-report 的 stage")
|
|
2486
|
+
|
|
2487
|
+
c3 = sub.add_parser("verify", help="校验「看图」是否真的发生过(截图存在且非空)")
|
|
2488
|
+
c3.add_argument("--record", required=True, help="visual_loop.json 路径")
|
|
2489
|
+
c3.add_argument("--out", default=None, help="相对路径基准目录(可选)")
|
|
2490
|
+
|
|
2491
|
+
c4 = sub.add_parser("gate", help="建模后强制视觉门(闭环 + 证据核实)")
|
|
2492
|
+
c4.add_argument("--part", required=True, help="零件/装配体路径")
|
|
2493
|
+
c4.add_argument("--expect", required=True, help="期望几何 JSON 或 @文件")
|
|
2494
|
+
c4.add_argument("--out", default=None, help="留痕目录(默认自动生成)")
|
|
2495
|
+
c4.add_argument("--max-rounds", type=int, default=MAX_ROUNDS_DEFAULT,
|
|
2496
|
+
dest="max_rounds", help="最大轮数,默认 %d" % MAX_ROUNDS_DEFAULT)
|
|
2497
|
+
# 【Bug⑨】房间上下文:给了才推进 room-report 的 stage(validating)
|
|
2498
|
+
c4.add_argument("--room", default=None,
|
|
2499
|
+
help="所属房间名(可选)。给了才把 room-report 的 stage "
|
|
2500
|
+
"推进到 validating(主代理据此看出「看图矫正中」);"
|
|
2501
|
+
"不传则取 DSH_ROOM 环境变量,都没有则不动任何房间")
|
|
2502
|
+
|
|
2503
|
+
c5 = sub.add_parser("report", help="把闭环结论登记进模式二计划(不可用时降级)")
|
|
2504
|
+
c5.add_argument("--room", default="", help="上报房间名")
|
|
2505
|
+
c5.add_argument("--part-name", default="", dest="part_name", help="零件名")
|
|
2506
|
+
c5.add_argument("--record", required=True, help="visual_loop.json 路径")
|
|
2507
|
+
c5.add_argument("--work-dir", default=None, dest="work_dir",
|
|
2508
|
+
help="模式二计划所在目录(可选)")
|
|
2509
|
+
c5.add_argument("--credentials", default=None,
|
|
2510
|
+
help="三道防线凭据 JSON,形如 "
|
|
2511
|
+
'\'{"material":"...","physics":"...","domain":"..."}\'。'
|
|
2512
|
+
"不提供则如实登记为「未提供」(本模块不产凭据)")
|
|
2513
|
+
c5.add_argument("--adjacent", default=None,
|
|
2514
|
+
help="直接接触/配合的零件名,逗号分隔(可为空)")
|
|
2515
|
+
return p
|
|
2516
|
+
|
|
2517
|
+
|
|
2518
|
+
def main(argv=None):
|
|
2519
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
2520
|
+
parser = _build_parser()
|
|
2521
|
+
if not argv:
|
|
2522
|
+
parser.print_help()
|
|
2523
|
+
return 2
|
|
2524
|
+
ns = parser.parse_args(argv)
|
|
2525
|
+
|
|
2526
|
+
if ns.cmd == "capture":
|
|
2527
|
+
views = [v for v in (ns.views or "").split(",") if v.strip()]
|
|
2528
|
+
exp_bbox = None
|
|
2529
|
+
if ns.expect:
|
|
2530
|
+
_e, _err = parse_expect(ns.expect)
|
|
2531
|
+
if _e is None:
|
|
2532
|
+
emit({"ok": False, "error": "expect 不合法: %s" % _err,
|
|
2533
|
+
"enforced_by": "visual_check.capture_views"})
|
|
2534
|
+
return 1
|
|
2535
|
+
_n, _nerr = normalize_expect(_e)
|
|
2536
|
+
if _n is None:
|
|
2537
|
+
emit({"ok": False, "error": "expect 不合法: %s" % _nerr,
|
|
2538
|
+
"enforced_by": "visual_check.capture_views"})
|
|
2539
|
+
return 1
|
|
2540
|
+
exp_bbox = _n.get("bbox_mm")
|
|
2541
|
+
res = capture_views(ns.part, ns.out, views=views,
|
|
2542
|
+
verify_projection=bool(ns.verify_projection),
|
|
2543
|
+
expect_bbox_mm=exp_bbox)
|
|
2544
|
+
emit(res)
|
|
2545
|
+
return 0 if res.get("ok") else 1
|
|
2546
|
+
|
|
2547
|
+
if ns.cmd == "loop":
|
|
2548
|
+
# 【Bug⑨】闭环同样长任务:先推进一次 stage(幂等、失败不阻断)
|
|
2549
|
+
push_room_validating(ns.room, "看图闭环中(visual_check loop)")
|
|
2550
|
+
res = check_and_correct(ns.part, ns.expect, max_rounds=ns.max_rounds,
|
|
2551
|
+
out_dir=ns.out)
|
|
2552
|
+
emit(res)
|
|
2553
|
+
return 0 if res.get("ok") else 1
|
|
2554
|
+
|
|
2555
|
+
if ns.cmd == "verify":
|
|
2556
|
+
res = verify_visual_evidence(ns.record, out_dir=ns.out)
|
|
2557
|
+
emit(res)
|
|
2558
|
+
return 0 if res.get("ok") else 1
|
|
2559
|
+
|
|
2560
|
+
if ns.cmd == "gate":
|
|
2561
|
+
res = enforce_visual_gate(ns.part, ns.expect, out_dir=ns.out,
|
|
2562
|
+
max_rounds=ns.max_rounds, room=ns.room)
|
|
2563
|
+
emit(res)
|
|
2564
|
+
return 0 if res.get("passed") else 1
|
|
2565
|
+
|
|
2566
|
+
if ns.cmd == "report":
|
|
2567
|
+
cred = None
|
|
2568
|
+
if ns.credentials:
|
|
2569
|
+
cred, _cerr = parse_expect(ns.credentials) # 复用 JSON/@文件 解析
|
|
2570
|
+
if cred is None:
|
|
2571
|
+
emit({"ok": False,
|
|
2572
|
+
"error": "credentials 不是合法 JSON: %s" % _cerr,
|
|
2573
|
+
"enforced_by": "visual_check.report_visual_result"})
|
|
2574
|
+
return 1
|
|
2575
|
+
adj = None
|
|
2576
|
+
if ns.adjacent is not None:
|
|
2577
|
+
adj = [a.strip() for a in ns.adjacent.split(",") if a.strip()]
|
|
2578
|
+
res = report_visual_result(ns.room, ns.part_name, ns.record,
|
|
2579
|
+
work_dir=ns.work_dir,
|
|
2580
|
+
credentials=cred, adjacent=adj)
|
|
2581
|
+
emit(res)
|
|
2582
|
+
return 0 if res.get("ok") else 1
|
|
2583
|
+
|
|
2584
|
+
parser.print_help()
|
|
2585
|
+
return 2
|
|
2586
|
+
|
|
2587
|
+
|
|
2588
|
+
if __name__ == "__main__":
|
|
2589
|
+
sys.exit(main())
|