Compare commits

...

4 Commits

Author SHA1 Message Date
nikelaluo b4feab24d2 feat: 完善 SCDM-first 参数化编辑交付版 2026-08-20 17:12:01 +08:00
nikelaluo 4e7877e05c feat: 接入 SCDM 优先编辑闭环并支持阵列局部间距
接入 SCDM probe/edit/cache/校验链路,增强孔组、阵列、关系式和参数表交互。

支持阵列相邻段间距、移动意图切换、结果回滚校验,并补充对应回归脚本。
2026-08-19 18:02:47 +08:00
nikelaluo a3eb7e2476 feat: 推进SCDM-first后端接入和大模型编辑优化 2026-08-19 10:28:09 +08:00
nikelaluo 722256a41f feat: 完善参数化关系式与SCDM模型修改验证 2026-08-17 18:53:03 +08:00
34 changed files with 19560 additions and 1906 deletions
+7
View File
@@ -26,13 +26,20 @@ assets/screenshots/
local/ local/
tmp.md tmp.md
tmp_scdm*
data.json data.json
nodes/ nodes/
Analysis-Component/ Analysis-Component/
occt_feature_editor/ occt_feature_editor/
third_party/ third_party/
# Local SCDM experiment outputs; keep committed sample models explicit.
assets/models/*_scdm_probe_*.stp
assets/models/*_scdm_*_inner_*.stp
assets/models/*_scdm_*_outer_*.stp
# Local reference docs; keep them on disk, never commit them. # Local reference docs; keep them on disk, never commit them.
概念.md
Face一级关系专项测试说明.md Face一级关系专项测试说明.md
Creo软件具备哪些建模形式.md Creo软件具备哪些建模形式.md
中文版Creo+4.0从入门到精通.pdf 中文版Creo+4.0从入门到精通.pdf
+388 -1558
View File
File diff suppressed because it is too large Load Diff
@@ -295,6 +295,53 @@ def _verify_slot_summary(root: Path) -> None:
_assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}") _assert(int(info.get("recognition_user_priority", 99)) == 30, f"slot should use slot priority: {info}")
def _verify_split_cylinder_slot_and_hole_guard() -> None:
geom_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
if geom_path.exists():
geom_model = StepModel.load(geom_path)
for slot_face_id in (1360, 1722):
slot_info = geom_model.feature_info(slot_face_id)
_assert(
"" in str(slot_info.get("feature_type") or "")
and "圆柱孔候选" not in str(slot_info.get("feature_type") or ""),
f"geom_extract Face{slot_face_id} should be treated as a slot/groove, not a cylindrical hole: {slot_info}",
)
_assert(
slot_info.get("slot_status") in {"candidate", "blocked"},
f"geom_extract Face{slot_face_id} should keep slot classification fields: {slot_info}",
)
_assert(
"封堵圆柱孔" not in str(slot_info.get("feature_edit_actions") or ""),
f"geom_extract Face{slot_face_id} should not expose cylindrical-hole suppress wording: {slot_info}",
)
_assert(
int(slot_info.get("recognition_user_priority", 99)) == 30,
f"geom_extract Face{slot_face_id} should use slot priority: {slot_info}",
)
through_hole_info = geom_model.feature_info(1591)
_assert(
through_hole_info.get("feature_type") == "圆柱孔候选",
f"geom_extract Face1591 should remain a cylindrical hole guard case: {through_hole_info}",
)
_assert(
int(through_hole_info.get("recognition_user_priority", 99)) == 20,
f"geom_extract Face1591 should keep hole priority: {through_hole_info}",
)
icepak_path = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
if icepak_path.exists():
icepak_model = StepModel.load(icepak_path)
hole_info = icepak_model.feature_info(87)
_assert(
hole_info.get("feature_type") == "圆柱孔候选",
f"ICEPAK Face87 is a split through-hole and should remain a hole: {hole_info}",
)
_assert(
int(hole_info.get("recognition_user_priority", 99)) == 20,
f"ICEPAK Face87 should keep hole priority: {hole_info}",
)
def _verify_boss_summary(root: Path) -> None: def _verify_boss_summary(root: Path) -> None:
path = root / "boss.step" path = root / "boss.step"
_write_boss_model(path) _write_boss_model(path)
@@ -574,6 +621,7 @@ def main() -> int:
_verify_quick_cylinder_recognition(root) _verify_quick_cylinder_recognition(root)
_verify_hole_summary(root) _verify_hole_summary(root)
_verify_slot_summary(root) _verify_slot_summary(root)
_verify_split_cylinder_slot_and_hole_guard()
_verify_boss_summary(root) _verify_boss_summary(root)
_verify_asitus_slot_boss_fillet_hints(root) _verify_asitus_slot_boss_fillet_hints(root)
_verify_torus_summary(root) _verify_torus_summary(root)
+59 -34
View File
@@ -47,27 +47,33 @@ def _verify_readme_mentions(readme: str) -> None:
"当前整体验证基线", "当前整体验证基线",
"不等于 CAD 级完成", "不等于 CAD 级完成",
"Face 阶段的当前验收口径", "Face 阶段的当前验收口径",
"参数化编辑路线", "SCDM-first 主路线",
"用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚", "核心路线只保留下面这一棵树",
"`[x]` 已实现", "SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM",
"`[~]` 部分实现/进行中", "本软件不再把手写一级、二级、三级传播当成新增能力主线",
"`[ ]` 未实现", "`[x]` 已适配",
"`[x]` 不是“所有 CAD 形态都能改”", "`[~]` 部分适配",
"R1 Face 是当前主线的第一阶段收口对象", "`[ ]` 待适配",
"R8 的二级/三级传播、跨特征约束和特征组联动不算在 0~7 完成度里", "[scdm_probe_job.json -> /RunScript 扫描 STEP]",
"STEP/B-Rep 参数化编辑主线", "[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"[不能修改 -> 立即说明原因]", "[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]",
"[一级影响范围 -> 明确显示]", "[公式输入 -> 支持 Face85.直径 = Face87.半径",
"Face 阶段的当前验收口径(R1 已收口)", "[批量联动 -> 多条公式先求值成一组目标参数",
"[关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接",
"[Face 偏移 -> face.offset / OffsetFaces]",
"[槽宽 -> slot.width / OffsetFaces",
"[槽深 -> slot.depth / Move 或 OffsetFaces",
"[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]",
"Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口)",
"已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`", "已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`",
"未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建", "未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建",
"孔/槽阶段的当前验收口径(R2/R3 已收口)", "孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口)",
"已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`", "已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`",
"已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`", "已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`",
"未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动", "未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动",
"2026-08-11,在 `pyocc` 环境下已通过 `python scripts\\verify_first_level_edit_suites.py --stage hole-slot`", "2026-08-11,在 `pyocc` 环境下已通过 `python scripts\\verify_first_level_edit_suites.py --stage hole-slot`",
"覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径", "覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径",
"0~7 其它阶段的当前基线", "本地 OCCT 兜底能力的当前基线",
"verify_first_level_edit_suites.py --quick", "verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face", "verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot", "verify_first_level_edit_suites.py --stage hole-slot",
@@ -92,42 +98,61 @@ def _verify_readme_mentions(readme: str) -> None:
def _verify_roadmap_scope(readme: str) -> None: def _verify_roadmap_scope(readme: str) -> None:
start_marker = "STEP/B-Rep 参数化编辑主线" start_marker = "SCDM-first 主路线"
end_marker = "└── 8. 二级 / 三级关系" end_marker = "└── 8. 交付与兜底边界"
start = readme.find(start_marker) start = readme.find(start_marker)
end = readme.find(end_marker) end = readme.find(end_marker)
_assert(start >= 0 and end > start, "README roadmap should contain a 0~7 active scope before stage 8") _assert(start >= 0 and end > start, "README should contain a single SCDM-first roadmap before fallback boundary")
active_scope = readme[start:end] active_scope = readme[start:end]
deferred_scope = readme[end:] deferred_scope = readme[end:]
required_active_fragments = ( required_active_fragments = (
"├── 0. 先让用户知道“能不能改”", "├── 0. 后端发现与可用性",
"│ ├── [x] [不能修改 -> 立即说明原因]", "│ ├── [x] [自动发现 SpaceClaim.exe -> 缓存路径、来源、版本和验证结果]",
"│ └── [x] [路线图 -> 验收脚本守门]", "├── 1. SCDM 识别与缓存",
"├── 1. 平面 Face,第一条主线", "│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]",
"├── 2. 孔,第二条主线", "│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征", "├── 2. 能力字典、参数表和用户入口",
"├── 4. 凸台 / Boss,从切除特征扩展到加料特征", "├── 3. 关系式与参数联动",
"├── 5. 圆角 / 倒角,从主形体扩展到边修饰", "[公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全]",
"├── 6. Edge 一级编辑,补齐底层直接改边能力", "[添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式]",
"├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力", "[批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务]",
"├── 4. SCDM 参数化建模执行",
"├── 5. 结果校验、回滚和 ID 续接",
"├── 6. 当前已开放或正在开放的 SCDM 能力",
"[阵列间距 -> pattern.spacing / Move,整体阵列保持中心不变并等距重排;安全范围由支撑面动态计算,不针对 Face92 写死]",
"[局部间距 -> pattern.segment_spacing / Move,按“FaceA-FaceB 间距”或“零件A-零件B 间距”修改相邻段;已支持固定前项移动后侧、固定后项移动前侧、两侧均分保持中心、只移动前项、只移动后项]",
"[阵列实例位置 -> pattern.instance_position / Move,只移动当前阵列成员;不自动保持整体阵列等距,真实 STEP 回测待补]",
"[壳体厚度 -> shell.thickness / Move,薄壁两平面配对后固定一侧、移动另一侧;真实 STEP 回测待补]",
"├── 7. 下一批只按 SCDM 能力适配",
"[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]",
) )
for fragment in required_active_fragments: for fragment in required_active_fragments:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}") _assert(fragment in active_scope, f"README active roadmap missing: {fragment}")
forbidden_active_fragments = ( forbidden_active_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"└── 8. 二级 / 三级关系",
"[Face 二级传播", "[Face 二级传播",
"[孔组 ->",
"多槽组 ->", "多槽组 ->",
"二级传播 ->",
"三级传播 ->",
"二级 / 三级关系",
) )
for fragment in forbidden_active_fragments: for fragment in forbidden_active_fragments:
_assert(fragment not in active_scope, f"README 0~7 roadmap should defer this to stage 8: {fragment}") _assert(fragment not in active_scope, f"README SCDM-first roadmap should not keep old route scope: {fragment}")
_assert("[Face 二级传播 -> 孔底/槽底/台阶联动]" in deferred_scope, "README should keep Face deeper propagation in stage 8") _assert("[Analysis Situs -> 只做辅助定位、兜底识别和开源对照]" in deferred_scope, "README should keep Analysis Situs as auxiliary boundary")
_assert("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard") _assert("[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]" in deferred_scope, "README should keep OCCT as fallback boundary")
_assert("[SCDM 之外的新能力 -> 等 SCDM 能力适配完再评估]" in deferred_scope, "README should defer non-SCDM expansion")
forbidden_global_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
)
for fragment in forbidden_global_fragments:
_assert(fragment not in readme, f"README should keep only one SCDM-first route, found old title: {fragment}")
def main() -> int: def main() -> int:
@@ -70,7 +70,13 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Smoke test", ("main.py", "--smoke-test")), ("Smoke test", ("main.py", "--smoke-test")),
("Property editor specs", ("verify_property_editor_specs.py",)), ("Property editor specs", ("verify_property_editor_specs.py",)),
("Property table editor UI", ("verify_property_card_editor_ui.py",)), ("Property table editor UI", ("verify_property_card_editor_ui.py",)),
("Relation formula rules", ("verify_relation_formula_rules.py",)),
("Parametric component export", ("verify_parametric_component_export.py",)), ("Parametric component export", ("verify_parametric_component_export.py",)),
("SCDM backend discovery", ("verify_scdm_backend.py",)),
("SCDM runtime status", ("verify_scdm_status.py",)),
("SCDM probe pipeline", ("verify_scdm_probe_pipeline.py",)),
("SCDM edit runner", ("verify_scdm_edit_runner.py",)),
("SCDM result validator", ("verify_scdm_result_validator.py",)),
("Analysis Situs hole bridge", ("verify_asitus_hole_bridge.py",)), ("Analysis Situs hole bridge", ("verify_asitus_hole_bridge.py",)),
("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)), ("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)),
("Feature recognition priority", ("verify_feature_recognition_summary.py",)), ("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
@@ -80,6 +80,63 @@ def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root:
return str(response.get("message") or "") return str(response.get("message") or "")
def _assert_isolated_hole_resize_disables_stale_external_regions(
model: StepModel,
face_id: int,
diameter: float,
root: Path,
) -> None:
input_path = root / "face87_stale_input.brep"
output_path = root / "face87_stale_output.brep"
request_path = root / "face87_stale_resize_request.json"
model.export_internal_brep(input_path)
request_path.write_text(
json.dumps(
{
"input_path": str(input_path),
"output_path": str(output_path),
"input_format": "brep",
"output_format": "brep",
"operation": "resize_cylindrical_hole",
"args": [face_id, diameter],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
code = run_request(request_path)
response = json.loads(request_path.with_suffix(".response.json").read_text(encoding="utf-8"))
_assert(code == 0 and response.get("ok") is True, f"isolated stale-region resize should pass: {response}")
message = str(response.get("message") or "")
marker = "verified_face="
marker_index = message.find(marker)
_assert(marker_index >= 0, f"isolated resize should report verified_face: {message}")
verified_text = message[marker_index + len(marker):].split(".", 1)[0].strip()
verified_face = int(float(verified_text))
result_model = StepModel.load_internal_brep(output_path)
result_model.filename = MODEL_PATH
stale_region = result_model.face_region_ids(verified_face)
_assert(
len(stale_region) > 1,
f"test fixture should expose the stale external-region bug before marking: {stale_region}",
)
result_model.mark_external_recognition_stale("isolated-edit-result")
current_region = result_model.face_region_ids(verified_face)
_assert(
current_region == [verified_face],
f"isolated edit result should not reuse original STEP Analysis Situs hole groups: {current_region}",
)
result_model.assign_logical_face_region_exclusive(face_id, current_region)
_assert(
result_model.face_ids_for_logical_id(face_id) == current_region,
f"logical Face {face_id} should only attach to the edited hole, not a neighboring hole: "
f"{result_model.face_ids_for_logical_id(face_id)}",
)
def _isolated_hole_axis_move( def _isolated_hole_axis_move(
model: StepModel, model: StepModel,
face_id: int, face_id: int,
@@ -331,6 +388,7 @@ def main() -> int:
with tempfile.TemporaryDirectory(prefix="icepak_face87_isolated_") as temp_dir: with tempfile.TemporaryDirectory(prefix="icepak_face87_isolated_") as temp_dir:
worker_message = _isolated_hole_resize(model, 87, 0.3, Path(temp_dir)) worker_message = _isolated_hole_resize(model, 87, 0.3, Path(temp_dir))
_assert("diameter 0.5 -> 0.3" in worker_message, "Face 87 isolated diameter shrink should complete") _assert("diameter 0.5 -> 0.3" in worker_message, "Face 87 isolated diameter shrink should complete")
_assert_isolated_hole_resize_disables_stale_external_regions(model, 87, 0.3, Path(temp_dir))
with tempfile.TemporaryDirectory(prefix="icepak_face85_axis_isolated_") as temp_dir: with tempfile.TemporaryDirectory(prefix="icepak_face85_axis_isolated_") as temp_dir:
worker_message = _isolated_hole_axis_move(model, 85, axis_move_target, Path(temp_dir)) worker_message = _isolated_hole_axis_move(model, 85, axis_move_target, Path(temp_dir))
+79 -1
View File
@@ -19,13 +19,19 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel from step_editor.model import StepModel
from step_editor.records import OperationRecord
from step_editor.window_actions import WindowActionMixin from step_editor.window_actions import WindowActionMixin
from step_editor.window_state import WindowStateMixin
class _WindowActionProbe(WindowActionMixin): class _WindowActionProbe(WindowActionMixin):
pass pass
class _WindowStateProbe(WindowStateMixin):
pass
def _wire_count(face) -> int: def _wire_count(face) -> int:
count = 0 count = 0
explorer = TopExp_Explorer(face, TopAbs_WIRE) explorer = TopExp_Explorer(face, TopAbs_WIRE)
@@ -145,6 +151,9 @@ def main() -> int:
raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}") raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}")
if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild": if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild":
raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}") raise SystemExit(f"large stepped cap should use local shell rebuild: {plan}")
stepped_isolation = _WindowActionProbe()._isolation_for_plan(plan, "push_pull_face", [face_id, 89.0])
if not stepped_isolation or stepped_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"large stepped cap should use the smooth UI background process: {stepped_isolation}")
started = time.perf_counter() started = time.perf_counter()
result = model.push_pull_face(face_id, 89.0) result = model.push_pull_face(face_id, 89.0)
@@ -233,7 +242,13 @@ def main() -> int:
multi_face_id = _large_multi_boundary_cap_face(multi_model) multi_face_id = _large_multi_boundary_cap_face(multi_model)
multi_logical_id = multi_model.face_region_logical_id(multi_face_id) multi_logical_id = multi_model.face_region_logical_id(multi_face_id)
multi_before_topology = _face_topology_counts(multi_model, multi_face_id) multi_before_topology = _face_topology_counts(multi_model, multi_face_id)
started = time.perf_counter()
multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5) multi_plan = multi_model.push_pull_plan(multi_face_id, 34.5)
multi_plan_elapsed = time.perf_counter() - started
if multi_plan_elapsed > 5.0:
raise SystemExit(
f"multi-boundary cap push/pull plan should be quick: {multi_plan_elapsed:.3f}s; plan={multi_plan}"
)
if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9: if abs(float(multi_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9:
raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}") raise SystemExit(f"multi-boundary cap should start at 57.5: {multi_plan}")
if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9: if abs(float(multi_plan.get("target_plane_position") or 0.0) - 92.0) > 1e-9:
@@ -242,6 +257,22 @@ def main() -> int:
raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}") raise SystemExit(f"multi-boundary cap should be recognized as planar cap: {multi_plan}")
if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild": if multi_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}") raise SystemExit(f"multi-boundary cap should use boundary shell rebuild: {multi_plan}")
multi_isolation = _WindowActionProbe()._isolation_for_plan(multi_plan, "push_pull_face", [multi_face_id, 34.5])
if not multi_isolation or multi_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"multi-boundary cap should use the smooth UI background process: {multi_isolation}")
ui_probe = _WindowActionProbe()
ui_probe.model = multi_model
ui_probe.current_info_values = multi_model.quick_face_info(multi_face_id)
ui_plan = ui_probe._push_pull_plan_for_action(multi_face_id, 34.5)
if bool(ui_plan.get("ui_deferred_model_plan")):
raise SystemExit(f"large multi-boundary UI plan should use the fast local plan now: {ui_plan}")
if ui_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
raise SystemExit(f"large multi-boundary UI plan should use boundary-shell rebuild: {ui_plan}")
ui_isolation = ui_probe._isolation_for_plan(ui_plan, "push_pull_face", [multi_face_id, 34.5])
if not ui_isolation or ui_isolation.get("reason") != "large-model-smooth-ui-isolated-occ-edit":
raise SystemExit(f"large multi-boundary UI plan should use the smooth UI background process: {ui_isolation}")
if ui_probe._edit_preflight_blocker({"parameters": ui_plan}) is not None:
raise SystemExit(f"large multi-boundary UI plan should not be blocked before editing: {ui_plan}")
started = time.perf_counter() started = time.perf_counter()
multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0) multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0)
multi_inward_elapsed = time.perf_counter() - started multi_inward_elapsed = time.perf_counter() - started
@@ -346,6 +377,53 @@ def main() -> int:
multi_after_topology, multi_after_topology,
min_inner_wires=5, min_inner_wires=5,
) )
locator_probe = _WindowStateProbe()
locator_probe.model = multi_model
locator_record = OperationRecord(
summary="test",
detail="test",
operation_name="拉伸/切除平面",
target=f"Face {multi_face_id}",
parameters={
"part_id": multi_plan.get("part_id"),
"solid_id": multi_plan.get("solid_id"),
"surface": "plane",
"outward_direction": multi_plan.get("outward_direction") or multi_plan.get("plane_direction"),
"target_plane_position": multi_plan.get("target_plane_position"),
"bbox_diagonal": multi_plan.get("bbox_diagonal"),
},
result_message=multi_result,
target_kind="face",
target_id=multi_face_id,
target_logical_id=multi_logical_id,
)
started = time.perf_counter()
resolved_after_edit = locator_probe._resolve_record_face_id(locator_record)
locator_elapsed = time.perf_counter() - started
if resolved_after_edit != multi_retained_ids[0] or locator_elapsed > 0.5:
raise SystemExit(
f"large multi-boundary operation history locator should use the fast result Face: "
f"resolved={resolved_after_edit}, expected={multi_retained_ids[0]}, elapsed={locator_elapsed:.3f}s"
)
no_hint_record = OperationRecord(
summary="test",
detail="test",
operation_name="拉伸/切除平面",
target=f"Face {multi_face_id}",
parameters=locator_record.parameters,
result_message="Planar face push/pull completed without result face hint.",
target_kind="face",
target_id=multi_face_id,
target_logical_id=multi_logical_id,
)
started = time.perf_counter()
fallback_after_edit = locator_probe._record_plane_position_face_id(no_hint_record)
fallback_elapsed = time.perf_counter() - started
if fallback_after_edit != multi_retained_ids[0] or fallback_elapsed > 1.0:
raise SystemExit(
f"large multi-boundary fallback locator should use lightweight plane positions: "
f"resolved={fallback_after_edit}, expected={multi_retained_ids[0]}, elapsed={fallback_elapsed:.3f}s"
)
with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") as temp_dir: with tempfile.TemporaryDirectory(prefix="verify_large_multi_boundary_cap_isolated_") as temp_dir:
temp_root = Path(temp_dir) temp_root = Path(temp_dir)
@@ -442,7 +520,7 @@ def main() -> int:
) )
print( print(
"large multi-boundary cap push/pull ok: " "large multi-boundary cap push/pull ok: "
f"face_id={multi_face_id}, elapsed={multi_elapsed:.3f}s, " f"face_id={multi_face_id}, plan_elapsed={multi_plan_elapsed:.3f}s, elapsed={multi_elapsed:.3f}s, "
f"isolated_elapsed={multi_isolated_elapsed:.3f}s, " f"isolated_elapsed={multi_isolated_elapsed:.3f}s, "
f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}" f"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}"
) )
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.relation_formulas import ( # noqa: E402
RelationFormulaError,
Vector3,
evaluate_relation_formula,
parse_relation_formula,
validate_relation_formula_graph,
)
def _parse_many(texts: list[str]):
return [parse_relation_formula(text) for text in texts]
def _assert_ok(texts: list[str]) -> None:
validate_relation_formula_graph(_parse_many(texts))
def _assert_fails(texts: list[str], expected_fragment: str) -> None:
try:
validate_relation_formula_graph(_parse_many(texts))
except RelationFormulaError as exc:
message = str(exc)
if expected_fragment not in message:
raise AssertionError(f"expected {expected_fragment!r} in error message, got {message!r}") from exc
return
raise AssertionError(f"expected relation formulas to fail: {texts!r}")
def _assert_value(text: str, expected: object, values: dict[str, object] | None = None) -> None:
formula = parse_relation_formula(text)
values = dict(values or {})
value = evaluate_relation_formula(formula, lambda ref: values[ref.token])
if isinstance(value, Vector3):
actual = tuple(value.values)
expected_tuple = tuple(expected) # type: ignore[arg-type]
if len(actual) != len(expected_tuple) or any(abs(float(left) - float(right)) > 1.0e-12 for left, right in zip(actual, expected_tuple)):
raise AssertionError(f"expected {expected_tuple!r}, got {actual!r} for {text!r}")
return
if abs(float(value) - float(expected)) > 1.0e-12:
raise AssertionError(f"expected {expected!r}, got {value!r} for {text!r}")
def main() -> int:
_assert_ok(["Face87.直径 = Face87.半径 + 0.1"])
_assert_ok(["Face87.位置 = Face85.位置 + (0, 0, -3.5)"])
_assert_ok(["Face85.直径 = Face87.半径", "Face11.直径 = Face85.半径"])
_assert_ok(["Face87.直径 = 10mm"])
_assert_ok(["Face87.直径 = 1 cm + 2mm"])
_assert_value("Face87.直径 = 1cm + 2毫米", 12.0)
_assert_value("Face87.直径 = .5m / 10", 50.0)
_assert_value(
"Face87.位置 = Face85.位置 + (0mm, 1cm, -0.002m)",
(1.0, 12.0, 1.0),
{"Face85.位置": (1.0, 2.0, 3.0)},
)
_assert_fails(["Face87.直径 = Face87.直径 + 0.1"], "不能引用自身")
_assert_fails(["Face85.直径 = Face87.半径", "Face85.直径 = Face11.半径"], "同一目标参数")
_assert_fails(["Face85.直径 = Face87.直径", "Face87.直径 = Face85.直径"], "循环依赖")
_assert_fails(
[
"Face1.位置 = Face2.位置",
"Face2.位置 = Face3.位置",
"Face3.位置 = Face1.位置",
],
"循环依赖",
)
print("relation formula rules ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+157
View File
@@ -0,0 +1,157 @@
from __future__ import annotations
import ast
import os
import re
import subprocess
import sys
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ( # noqa: E402
SCDM_DISABLE_ENV,
SCDM_PATH_ENV_VARS,
ScdmBackendInfo,
default_scdm_cache_path,
discover_scdm_backend_candidates,
load_scdm_backend_cache,
resolve_scdm_backend,
save_scdm_backend_cache,
scdm_run_script_command,
verify_scdm_backend,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
@contextmanager
def _patched_env(values: dict[str, str | None]) -> Iterator[None]:
original = {key: os.environ.get(key) for key in values}
try:
for key, value in values.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
yield
finally:
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
def _fake_spaceclaim(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("fake", encoding="utf-8")
return path
def _fake_runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
script_args = [item for item in command if item.startswith("/RunScript=")]
_assert(script_args, f"missing /RunScript argument: {command}")
script_path = Path(script_args[0].split("=", 1)[1])
script = script_path.read_text(encoding="utf-8")
match = re.search(r"report_path\s*=\s*(.+)", script)
_assert(match is not None, f"smoke script should define report_path: {script}")
report_path = Path(ast.literal_eval(match.group(1).strip()))
report_path.write_text('{"ok": true, "version": "fake-2022R2", "message": "fake smoke ok"}', encoding="utf-8")
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe")
backend = ScdmBackendInfo(
path=fake_exe,
source="test",
version="v222",
verified_at="2026-08-18T00:00:00Z",
run_script_ok=True,
license_ok=True,
message="cached",
)
cache_path = save_scdm_backend_cache(backend, project_root_override=root)
_assert(cache_path == default_scdm_cache_path(root), f"unexpected cache path: {cache_path}")
loaded = load_scdm_backend_cache(project_root_override=root)
_assert(loaded is not None, "cache should load")
_assert(loaded.path == fake_exe.resolve(strict=False), f"cache should preserve path: {loaded}")
_assert(loaded.run_script_ok is True and loaded.license_ok is True, f"cache should preserve verification: {loaded}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_env_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe")
env_clear = {name: None for name in SCDM_PATH_ENV_VARS}
env_clear[SCDM_DISABLE_ENV] = None
env_clear["STEP_EDITOR_SCDM_EXE"] = str(fake_exe)
with _patched_env(env_clear):
candidates = discover_scdm_backend_candidates(
include_registry=False,
include_common=False,
include_path=False,
)
_assert(len(candidates) == 1, f"env discovery should find exactly one candidate: {candidates}")
_assert(candidates[0].source == "env:STEP_EDITOR_SCDM_EXE", f"bad source: {candidates[0]}")
resolved = resolve_scdm_backend(
project_root_override=root,
validate=False,
include_registry=False,
include_common=False,
include_path=False,
)
_assert(resolved.get("ok") is True, f"env backend should resolve: {resolved}")
_assert(Path(str(resolved.get("path"))) == fake_exe.resolve(strict=False), f"bad resolved path: {resolved}")
_assert(load_scdm_backend_cache(project_root_override=root) is not None, "resolve should write cache")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_common_") as temp:
root = Path(temp)
common_root = root / "Program Files" / "ANSYS Inc"
fake_exe = _fake_spaceclaim(common_root / "v231" / "SCDM" / "SpaceClaim.exe")
candidates = discover_scdm_backend_candidates(
include_env=False,
include_registry=False,
include_common=True,
include_path=False,
common_roots=(common_root,),
)
_assert(candidates and candidates[0].path == fake_exe.resolve(strict=False), f"common discovery failed: {candidates}")
_assert(candidates[0].version == "v231", f"version should be parsed from ANSYS folder: {candidates[0]}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_smoke_") as temp:
root = Path(temp)
fake_exe = _fake_spaceclaim(root / "SpaceClaim.exe")
command = scdm_run_script_command(fake_exe, root / "smoke.py")
_assert(command[0].endswith("SpaceClaim.exe"), f"bad command executable: {command}")
_assert(any(item.startswith("/RunScript=") for item in command), f"bad command script arg: {command}")
smoke = verify_scdm_backend(fake_exe, work_dir=root, runner=_fake_runner)
_assert(smoke.get("ok") is True, f"fake smoke should pass: {smoke}")
_assert(smoke.get("runScriptOk") is True and smoke.get("licenseOk") is True, f"bad smoke flags: {smoke}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_backend_disabled_") as temp:
root = Path(temp)
with _patched_env({SCDM_DISABLE_ENV: "1"}):
resolved = resolve_scdm_backend(project_root_override=root, validate=False)
_assert(resolved.get("ok") is False and resolved.get("reason") == "disabled", f"disable env failed: {resolved}")
missing = verify_scdm_backend(Path("Z:/not-installed/SpaceClaim.exe"))
_assert(missing.get("ok") is False and missing.get("reason") == "missing-exe", f"missing path should be clean: {missing}")
print("scdm backend discovery ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+755
View File
@@ -0,0 +1,755 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_result_validator import ( # noqa: E402
build_scdm_id_mapping,
check_scdm_summary_delta,
check_scdm_unedited_objects,
check_scdm_target,
match_scdm_object_by_signature,
rewrite_scdm_relation_formula_ids,
validate_scdm_edit_result,
)
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _hole(object_id: str, face_id: int, *, diameter: float, center: tuple[float, float, float]) -> dict[str, object]:
return {
"objectId": object_id,
"objectType": "hole",
"geometrySignature": {
"objectType": "hole",
"faceIds": [face_id],
"surfaceType": "cylinder",
"center": list(center),
"axis": [0.0, 0.0, 1.0],
"diameter": diameter,
},
"capabilities": [
{"key": "hole.diameter", "currentValue": diameter},
{"key": "hole.position", "currentValue": list(center)},
],
}
def _feature(
object_id: str,
object_type: str,
face_id: int,
*,
center: tuple[float, float, float],
capability_key: str,
) -> dict[str, object]:
return {
"objectId": object_id,
"objectType": object_type,
"geometrySignature": {
"objectType": object_type,
"faceIds": [face_id],
"center": list(center),
"axis": [0.0, 0.0, 1.0],
},
"capabilities": [
{"key": capability_key, "currentValue": list(center)},
],
}
def _cache(*objects: dict[str, object]) -> dict[str, object]:
return {
"schemaVersion": 1,
"source": "SCDM",
"objects": list(objects),
"diagnostics": {},
}
def _cache_with_summary(summary: dict[str, int], *objects: dict[str, object]) -> dict[str, object]:
cache = _cache(*objects)
cache["diagnostics"] = {"raw_summary": dict(summary)}
return cache
def main() -> int:
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_validate_") as temp:
root = Path(temp)
output_step = root / "result.step"
output_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
before = _cache(
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
after = _cache(
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
before_signature = before["objects"][0]["geometrySignature"] # type: ignore[index]
match = match_scdm_object_by_signature(before_signature, after, capability_key="hole.diameter")
_assert(match.get("status") == "unique", f"changed diameter should still match by center/axis/type: {match}")
_assert(match.get("object", {}).get("objectId") == "hole:90", f"wrong match: {match}")
ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
before_cache=before,
after_cache=after,
capability_key="hole.diameter",
expected_target=0.75,
edited_object_id="hole:85",
brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"},
)
_assert(ok.get("ok") is True, f"validated edit should pass: {ok}")
_assert(ok.get("targetCheck", {}).get("ok") is True, f"target diameter should be checked: {ok}")
_assert(ok.get("topologyCheck", {}).get("ok") is True, f"unchanged objects should be checked: {ok}")
drift_after = _cache(_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)))
drift = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
before_cache=before,
after_cache=drift_after,
capability_key="hole.diameter",
expected_target=0.75,
edited_object_id="hole:85",
)
_assert(drift.get("ok") is False and drift.get("reason") == "unexpected-object-drift", f"missing unrelated hole should fail: {drift}")
direct_drift = check_scdm_unedited_objects(before, drift_after, edited_object_id="hole:85", edited_signature=before_signature)
_assert(direct_drift.get("ok") is False and direct_drift.get("checked") == 1, f"direct drift check should inspect one unedited object: {direct_drift}")
mismatch = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=before_signature,
after_cache=after,
capability_key="hole.diameter",
expected_target=0.9,
)
_assert(mismatch.get("ok") is False and mismatch.get("reason") == "target-mismatch", f"wrong target should fail: {mismatch}")
missing = validate_scdm_edit_result({"ok": True, "output_step": str(root / "missing.step")})
_assert(missing.get("ok") is False and missing.get("reason") == "missing-output-step", f"missing result STEP should fail: {missing}")
mapping = build_scdm_id_mapping(before, after, capability_key="hole.diameter")
_assert(mapping.get("faceIdMap") == {85: 90, 87: 91}, f"face IDs should remap through signatures: {mapping}")
rewritten = rewrite_scdm_relation_formula_ids("Face87.直径 = Face85.半径", mapping)
_assert(rewritten == "Face91.直径 = Face90.半径", f"formula IDs should follow SCDM remap: {rewritten}")
position_after = _cache(_hole("hole:91", 91, diameter=0.5, center=(2.0, 1.0, 6.0)))
position_check = check_scdm_target(position_after["objects"][0], capability_key="hole.position", expected_target=[2.0, 1.0, 6.0])
_assert(position_check.get("ok") is True, f"position target should pass: {position_check}")
before_slot = _cache(_feature("slot:30", "slot", 30, center=(1.0, 2.0, 3.0), capability_key="slot.position"))
after_slot = _cache(_feature("slot:40", "slot", 40, center=(1.0, 2.0, 6.0), capability_key="slot.position"))
slot_before_signature = before_slot["objects"][0]["geometrySignature"] # type: ignore[index]
slot_match = match_scdm_object_by_signature(slot_before_signature, after_slot, capability_key="slot.position")
_assert(slot_match.get("status") == "unique", f"moved slot should match without old center lock: {slot_match}")
slot_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_before_signature,
before_cache=before_slot,
after_cache=after_slot,
capability_key="slot.position",
expected_target=[1.0, 2.0, 6.0],
edited_object_id="slot:30",
)
_assert(slot_ok.get("ok") is True, f"slot.position result should validate target center: {slot_ok}")
_assert(slot_ok.get("targetCheck", {}).get("ok") is True, f"slot target should be checked: {slot_ok}")
before_slot_width = _cache(
{
"objectId": "slot:30",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [30],
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"width": 2.0,
},
"capabilities": [{"key": "slot.width", "currentValue": 2.0}],
}
)
after_slot_width = _cache(
{
"objectId": "slot:40",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [40],
"center": [1.0, 2.0, 3.0],
"axis": [0.0, 0.0, 1.0],
"width": 2.5,
},
"capabilities": [{"key": "slot.width", "currentValue": 2.5}],
}
)
slot_width_before_signature = before_slot_width["objects"][0]["geometrySignature"] # type: ignore[index]
slot_width_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_width_before_signature,
before_cache=before_slot_width,
after_cache=after_slot_width,
capability_key="slot.width",
expected_target=2.5,
edited_object_id="slot:30",
)
_assert(slot_width_ok.get("ok") is True, f"slot.width result should validate target width: {slot_width_ok}")
slot_width_mismatch = check_scdm_target(after_slot_width["objects"][0], capability_key="slot.width", expected_target=2.1) # type: ignore[index]
_assert(slot_width_mismatch.get("ok") is False and slot_width_mismatch.get("reason") == "target-mismatch", f"slot.width mismatch should fail: {slot_width_mismatch}")
before_slot_depth = _cache(
{
"objectId": "slot:31",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [31],
"center": [1.0, 2.0, 3.0],
"depth": 1.5,
"depthAxis": [0.0, 0.0, -1.0],
},
"capabilities": [{"key": "slot.depth", "currentValue": 1.5}],
}
)
after_slot_depth = _cache(
{
"objectId": "slot:41",
"objectType": "slot",
"geometrySignature": {
"objectType": "slot",
"faceIds": [41],
"center": [1.0, 2.0, 3.0],
"depth": 2.0,
"depthAxis": [0.0, 0.0, -1.0],
},
"capabilities": [{"key": "slot.depth", "currentValue": 2.0}],
}
)
slot_depth_before_signature = before_slot_depth["objects"][0]["geometrySignature"] # type: ignore[index]
slot_depth_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=slot_depth_before_signature,
before_cache=before_slot_depth,
after_cache=after_slot_depth,
capability_key="slot.depth",
expected_target=2.0,
edited_object_id="slot:31",
)
_assert(slot_depth_ok.get("ok") is True, f"slot.depth result should validate target depth: {slot_depth_ok}")
slot_depth_mismatch = check_scdm_target(after_slot_depth["objects"][0], capability_key="slot.depth", expected_target=1.5) # type: ignore[index]
_assert(slot_depth_mismatch.get("ok") is False and slot_depth_mismatch.get("reason") == "target-mismatch", f"slot.depth mismatch should fail: {slot_depth_mismatch}")
boss_after = _feature("boss:50", "cylindrical_boss", 50, center=(3.0, 0.0, 2.0), capability_key="boss.position")
boss_check = check_scdm_target(boss_after, capability_key="boss.position", expected_target=[3.0, 0.0, 2.0])
_assert(boss_check.get("ok") is True, f"boss.position target should pass: {boss_check}")
boss_mismatch = check_scdm_target(boss_after, capability_key="boss.position", expected_target=[4.0, 0.0, 2.0])
_assert(boss_mismatch.get("ok") is False and boss_mismatch.get("reason") == "target-mismatch", f"boss.position mismatch should fail: {boss_mismatch}")
before_boss_height = _cache(
{
"objectId": "boss:50",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [50, 51, 52],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 4.0,
},
"capabilities": [{"key": "boss.height", "currentValue": 4.0}],
}
)
after_boss_height = _cache(
{
"objectId": "boss:55",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [55, 56, 57],
"center": [0.0, 0.0, 2.75],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 5.5,
},
"capabilities": [{"key": "boss.height", "currentValue": 5.5}],
}
)
boss_height_signature = before_boss_height["objects"][0]["geometrySignature"] # type: ignore[index]
boss_height_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=boss_height_signature,
before_cache=before_boss_height,
after_cache=after_boss_height,
capability_key="boss.height",
expected_target=5.5,
edited_object_id="boss:50",
)
_assert(boss_height_ok.get("ok") is True, f"boss.height result should validate target height: {boss_height_ok}")
boss_height_mismatch = check_scdm_target(after_boss_height["objects"][0], capability_key="boss.height", expected_target=4.5) # type: ignore[index]
_assert(boss_height_mismatch.get("ok") is False and boss_height_mismatch.get("reason") == "target-mismatch", f"boss.height mismatch should fail: {boss_height_mismatch}")
before_boss_diameter = _cache(
{
"objectId": "boss:60",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [60, 61, 62],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 3.0,
"height": 4.0,
},
"capabilities": [{"key": "boss.diameter", "currentValue": 3.0}],
}
)
after_boss_diameter = _cache(
{
"objectId": "boss:63",
"objectType": "cylindrical_boss",
"geometrySignature": {
"objectType": "cylindrical_boss",
"faceIds": [63, 64, 65],
"center": [0.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"diameter": 4.5,
"height": 4.0,
},
"capabilities": [{"key": "boss.diameter", "currentValue": 4.5}],
}
)
boss_diameter_signature = before_boss_diameter["objects"][0]["geometrySignature"] # type: ignore[index]
boss_diameter_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=boss_diameter_signature,
before_cache=before_boss_diameter,
after_cache=after_boss_diameter,
capability_key="boss.diameter",
expected_target=4.5,
edited_object_id="boss:60",
)
_assert(boss_diameter_ok.get("ok") is True, f"boss.diameter result should validate target diameter: {boss_diameter_ok}")
boss_diameter_mismatch = check_scdm_target(after_boss_diameter["objects"][0], capability_key="boss.diameter", expected_target=3.5) # type: ignore[index]
_assert(boss_diameter_mismatch.get("ok") is False and boss_diameter_mismatch.get("reason") == "target-mismatch", f"boss.diameter mismatch should fail: {boss_diameter_mismatch}")
before_round_radius = _cache(
{
"objectId": "round:70",
"objectType": "round",
"geometrySignature": {
"objectType": "round",
"faceIds": [70],
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.5,
"isConstantRound": True,
},
"capabilities": [{"key": "round.radius", "currentValue": 0.5}],
}
)
after_round_radius = _cache(
{
"objectId": "round:71",
"objectType": "round",
"geometrySignature": {
"objectType": "round",
"faceIds": [71],
"center": [1.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"radius": 0.75,
"isConstantRound": True,
},
"capabilities": [{"key": "round.radius", "currentValue": 0.75}],
}
)
round_radius_signature = before_round_radius["objects"][0]["geometrySignature"] # type: ignore[index]
round_radius_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=round_radius_signature,
before_cache=before_round_radius,
after_cache=after_round_radius,
capability_key="round.radius",
expected_target=0.75,
edited_object_id="round:70",
)
_assert(round_radius_ok.get("ok") is True, f"round.radius result should validate target radius: {round_radius_ok}")
round_radius_mismatch = check_scdm_target(after_round_radius["objects"][0], capability_key="round.radius", expected_target=0.5) # type: ignore[index]
_assert(round_radius_mismatch.get("ok") is False and round_radius_mismatch.get("reason") == "target-mismatch", f"round.radius mismatch should fail: {round_radius_mismatch}")
before_chamfer_distance = _cache(
{
"objectId": "chamfer:80",
"objectType": "chamfer",
"geometrySignature": {
"objectType": "chamfer",
"faceIds": [80],
"center": [2.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"distance": 0.8,
"isEqualDistanceChamfer": True,
},
"capabilities": [{"key": "chamfer.distance", "currentValue": 0.8}],
}
)
after_chamfer_distance = _cache(
{
"objectId": "chamfer:81",
"objectType": "chamfer",
"geometrySignature": {
"objectType": "chamfer",
"faceIds": [81],
"center": [2.0, 0.0, 2.0],
"axis": [0.0, 0.0, 1.0],
"distance": 1.2,
"isEqualDistanceChamfer": True,
},
"capabilities": [{"key": "chamfer.distance", "currentValue": 1.2}],
}
)
chamfer_distance_signature = before_chamfer_distance["objects"][0]["geometrySignature"] # type: ignore[index]
chamfer_distance_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=chamfer_distance_signature,
before_cache=before_chamfer_distance,
after_cache=after_chamfer_distance,
capability_key="chamfer.distance",
expected_target=1.2,
edited_object_id="chamfer:80",
)
_assert(chamfer_distance_ok.get("ok") is True, f"chamfer.distance result should validate target distance: {chamfer_distance_ok}")
chamfer_distance_mismatch = check_scdm_target(after_chamfer_distance["objects"][0], capability_key="chamfer.distance", expected_target=0.8) # type: ignore[index]
_assert(chamfer_distance_mismatch.get("ok") is False and chamfer_distance_mismatch.get("reason") == "target-mismatch", f"chamfer.distance mismatch should fail: {chamfer_distance_mismatch}")
before_pattern_spacing = _cache(
{
"objectId": "pattern:holes",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [85, 87, 89],
"center": [5.0, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
},
"capabilities": [{"key": "pattern.spacing", "currentValue": 5.0}],
}
)
after_pattern_spacing = _cache(
{
"objectId": "pattern:holes-new",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [90, 91, 92],
"center": [7.5, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 7.5,
"pitch": 7.5,
"instanceCount": 3,
"instanceCenters": [[0.0, 0.0, 0.0], [7.5, 0.0, 0.0], [15.0, 0.0, 0.0]],
},
"capabilities": [{"key": "pattern.spacing", "currentValue": 7.5}],
}
)
pattern_spacing_before_signature = before_pattern_spacing["objects"][0]["geometrySignature"] # type: ignore[index]
pattern_spacing_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=pattern_spacing_before_signature,
before_cache=before_pattern_spacing,
after_cache=after_pattern_spacing,
capability_key="pattern.spacing",
expected_target=7.5,
edited_object_id="pattern:holes",
)
_assert(pattern_spacing_ok.get("ok") is True, f"pattern.spacing result should validate target spacing: {pattern_spacing_ok}")
pattern_spacing_mismatch = check_scdm_target(after_pattern_spacing["objects"][0], capability_key="pattern.spacing", expected_target=5.0) # type: ignore[index]
_assert(pattern_spacing_mismatch.get("ok") is False and pattern_spacing_mismatch.get("reason") == "target-mismatch", f"pattern.spacing mismatch should fail: {pattern_spacing_mismatch}")
before_pattern_segment = _cache(
{
"objectId": "pattern:holes",
"objectType": "linear_pattern",
"geometrySignature": {
"objectType": "linear_pattern",
"faceIds": [85, 87, 89],
"center": [5.0, 0.0, 0.0],
"axis": [1.0, 0.0, 0.0],
"spacing": 5.0,
"pitch": 5.0,
"segmentIndex": 1,
"movingSide": "after",
"patternInstances": [
{"sourceObjectId": "a", "center": [0.0, 0.0, 0.0], "faceIds": [85]},
{"sourceObjectId": "b", "center": [5.0, 0.0, 0.0], "faceIds": [87]},
{"sourceObjectId": "c", "center": [10.0, 0.0, 0.0], "faceIds": [89]},
],
},
"capabilities": [{"key": "pattern.segment_spacing", "currentValue": 5.0}],
},
_hole("hole:unrelated", 999, diameter=1.0, center=(100.0, 0.0, 0.0)),
)
after_pattern_segment = _cache(_hole("hole:unrelated-new", 999, diameter=1.0, center=(100.0, 0.0, 0.0)))
pattern_segment_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"result": {
"applied": {
"segmentSpacing": 6.5,
"targetSpacing": 6.5,
"segmentIndex": 1,
"spacingMode": "segment_after",
}
},
},
before_signature=before_pattern_segment["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
pattern_segment_ok.get("ok") is True
and pattern_segment_ok.get("targetCheck", {}).get("spacingMode") == "segment_after",
f"pattern.segment_spacing should validate from the applied edit result even when the old uniform pattern no longer matches: {pattern_segment_ok}",
)
pattern_segment_mismatch = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.0, "segmentIndex": 1, "spacingMode": "segment_after"},
},
before_signature=before_pattern_segment["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
pattern_segment_mismatch.get("ok") is False and pattern_segment_mismatch.get("reason") == "target-mismatch",
f"pattern.segment_spacing mismatch should fail from the applied edit result: {pattern_segment_mismatch}",
)
single_left_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index]
single_left_signature["movingSide"] = "single_left"
single_left_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_left"},
},
before_signature=single_left_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
single_left_ok.get("ok") is True
and single_left_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_left",
f"pattern.segment_spacing should validate move-only-left-instance mode: {single_left_ok}",
)
single_right_signature = dict(before_pattern_segment["objects"][0]["geometrySignature"]) # type: ignore[index]
single_right_signature["movingSide"] = "single_right"
single_right_ok = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_single_right"},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
single_right_ok.get("ok") is True
and single_right_ok.get("targetCheck", {}).get("spacingMode") == "segment_single_right",
f"pattern.segment_spacing should validate move-only-right-instance mode: {single_right_ok}",
)
wrong_mode = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1, "spacingMode": "segment_after"},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
wrong_mode.get("ok") is False and wrong_mode.get("reason") == "pattern-segment-spacing-mode-mismatch",
f"pattern.segment_spacing should fail when SCDM reports a different modeling intent: {wrong_mode}",
)
missing_mode = validate_scdm_edit_result(
{
"ok": True,
"output_step": str(output_step),
"applied": {"segmentSpacing": 6.5, "segmentIndex": 1},
},
before_signature=single_right_signature,
before_cache=before_pattern_segment,
after_cache=after_pattern_segment,
capability_key="pattern.segment_spacing",
expected_target=6.5,
edited_object_id="pattern:holes",
)
_assert(
missing_mode.get("ok") is False and missing_mode.get("reason") == "pattern-segment-spacing-mode-missing",
f"pattern.segment_spacing should require SCDM to report a known modeling intent: {missing_mode}",
)
shell_thickness_ok = check_scdm_target(
{
"objectType": "thin_wall",
"geometrySignature": {"objectType": "thin_wall", "thickness": 1.6},
"capabilities": [{"key": "shell.thickness", "currentValue": 1.6}],
},
capability_key="shell.thickness",
expected_target=1.6,
)
_assert(shell_thickness_ok.get("ok") is True, f"shell.thickness result should validate target thickness: {shell_thickness_ok}")
shell_thickness_mismatch = check_scdm_target(
{
"objectType": "thin_wall",
"geometrySignature": {"objectType": "thin_wall", "thickness": 1.6},
"capabilities": [{"key": "shell.thickness", "currentValue": 1.6}],
},
capability_key="shell.thickness",
expected_target=2.0,
)
_assert(shell_thickness_mismatch.get("ok") is False and shell_thickness_mismatch.get("reason") == "target-mismatch", f"shell.thickness mismatch should fail: {shell_thickness_mismatch}")
summary_before = _cache_with_summary(
{"bodyCount": 13, "objectCount": 554, "faceCount": 158, "edgeCount": 396},
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
)
summary_after_ok = _cache_with_summary(
{"bodyCount": 13, "objectCount": 550, "faceCount": 157, "edgeCount": 390},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
summary_ok = check_scdm_summary_delta(summary_before, summary_after_ok, capability_key="hole.diameter")
_assert(summary_ok.get("ok") is True, f"small summary changes should pass: {summary_ok}")
summary_after_repartition = _cache_with_summary(
{"bodyCount": 9, "objectCount": 548, "faceCount": 157, "edgeCount": 390},
_hole("hole:90", 90, diameter=0.9, center=(0.5, 1.0, 9.5)),
)
summary_repartition = check_scdm_summary_delta(summary_before, summary_after_repartition, capability_key="hole.diameter")
_assert(
summary_repartition.get("ok") is None and summary_repartition.get("reason") == "body-count-repartitioned",
f"SCDM body repartition should be a warning, not a hard failure: {summary_repartition}",
)
repartition_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=summary_before,
after_cache=summary_after_repartition,
capability_key="hole.diameter",
expected_target=0.9,
edited_object_id="hole:85",
brep_validator=lambda path: {"ok": path.is_file(), "reason": "ok"},
)
_assert(
repartition_ok.get("ok") is True
and repartition_ok.get("summaryCheck", {}).get("reason") == "body-count-repartitioned"
and repartition_ok.get("validationWarnings"),
f"target-verified SCDM hole diameter edit should survive body repartition warnings: {repartition_ok}",
)
summary_after_bad = _cache_with_summary(
{"bodyCount": 13, "objectCount": 80, "faceCount": 20, "edgeCount": 45},
_hole("hole:90", 90, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
summary_bad = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=summary_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=summary_before,
after_cache=summary_after_bad,
capability_key="hole.diameter",
expected_target=0.75,
)
_assert(summary_bad.get("ok") is False and summary_bad.get("reason") == "summary-drift", f"large summary drift should fail: {summary_bad}")
summary_fill = check_scdm_summary_delta(summary_before, summary_after_bad, capability_key="feature.fill")
_assert(summary_fill.get("ok") is None and summary_fill.get("reason") == "skipped-command-feature", f"fill should skip summary count guard: {summary_fill}")
fill_before = _cache(
_hole("hole:85", 85, diameter=0.5, center=(0.5, 1.0, 9.5)),
_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)),
)
fill_after = _cache(_hole("hole:87", 87, diameter=0.5, center=(2.0, 1.0, 9.5)))
fill_signature = fill_before["objects"][0]["geometrySignature"] # type: ignore[index]
fill_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
before_cache=fill_before,
after_cache=fill_after,
capability_key="feature.fill",
edited_object_id="hole:85",
)
_assert(fill_ok.get("ok") is True, f"feature.fill should pass when the edited feature disappears: {fill_ok}")
_assert(fill_ok.get("removalCheck", {}).get("ok") is True, f"feature.fill should record removal evidence: {fill_ok}")
fill_still_present = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
before_cache=fill_before,
after_cache=fill_before,
capability_key="feature.fill",
edited_object_id="hole:85",
)
_assert(
fill_still_present.get("ok") is False and fill_still_present.get("reason") == "feature-still-present",
f"feature.fill should fail when the edited feature still matches: {fill_still_present}",
)
fill_no_cache = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=fill_signature,
capability_key="feature.fill",
)
_assert(
fill_no_cache.get("ok") is False and fill_no_cache.get("reason") == "removal-check-unavailable",
f"feature.fill should require a new cache for removal verification: {fill_no_cache}",
)
round_before = _cache(_feature("round:60", "round", 60, center=(1.0, 0.0, 2.0), capability_key="feature.delete_round_or_chamfer"))
round_after = _cache(_feature("hole:85", "hole", 85, center=(5.0, 0.0, 2.0), capability_key="hole.diameter"))
round_delete_ok = validate_scdm_edit_result(
{"ok": True, "output_step": str(output_step)},
before_signature=round_before["objects"][0]["geometrySignature"], # type: ignore[index]
before_cache=round_before,
after_cache=round_after,
capability_key="feature.delete_round_or_chamfer",
edited_object_id="round:60",
)
_assert(round_delete_ok.get("ok") is True, f"round/chamfer delete should pass when the edited feature disappears: {round_delete_ok}")
_assert(round_delete_ok.get("targetCheck", {}).get("reason") == "removed", f"round/chamfer delete should use removal target check: {round_delete_ok}")
ambiguous_after = _cache(
_hole("hole:100", 100, diameter=0.75, center=(0.5, 1.0, 9.5)),
_hole("hole:101", 101, diameter=0.75, center=(0.5, 1.0, 9.5)),
)
ambiguous = match_scdm_object_by_signature(before_signature, ambiguous_after, capability_key="hole.diameter")
_assert(ambiguous.get("status") == "multiple", f"ambiguous matches should be reported: {ambiguous}")
print("scdm result validator ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.scdm_backend import ScdmBackendInfo, save_scdm_backend_cache # noqa: E402
from step_editor.scdm_status import cached_scdm_backend_payload, summarize_scdm_capability_progress, summarize_scdm_runtime # noqa: E402
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def main() -> int:
empty = summarize_scdm_runtime(cache_state="empty")
_assert(empty.get("backendReady") is False, f"empty backend should not be ready: {empty}")
_assert("未配置" in str(empty.get("headline")), f"empty headline should be clear: {empty}")
_assert("导入 STEP" in str(empty.get("detail")), f"empty detail should explain next step: {empty}")
backend = ScdmBackendInfo(
path=Path("D:/softwaresInstallDir/ANSYS Inc/v222/SCDM/SpaceClaim.exe"),
source="common:D:/softwaresInstallDir/ANSYS Inc",
version="v222",
verified_at="2026-08-18T00:00:00Z",
run_script_ok=True,
license_ok=True,
)
feature_cache = {
"objects": [
{"objectId": "face:1", "capabilities": [{"key": "face.offset"}]},
{"objectId": "hole:1", "capabilities": [{"key": "hole.diameter"}, {"key": "hole.position"}]},
]
}
ready = summarize_scdm_runtime(backend=backend, cache_state="ready", feature_cache=feature_cache)
_assert(ready.get("backendReady") is True, f"backend should be ready: {ready}")
_assert("已配置 v222" in str(ready.get("headline")), f"version should be visible: {ready}")
_assert("常见安装目录" in str(ready.get("headline")), f"source should be product text: {ready}")
_assert(ready.get("objectCount") == 2 and ready.get("capabilityCount") == 3, f"cache counts should be summarized: {ready}")
_assert("识别缓存已就绪" in str(ready.get("detail")), f"ready detail should be explicit: {ready}")
_assert("/RunScript:可用" in str(ready.get("tooltip")), f"tooltip should include /RunScript status: {ready}")
running = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="running")
_assert("正在后台识别" in str(running.get("detail")), f"running state should explain background probe: {running}")
failed = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="failed", cache_message="SpaceClaim.exe was not found.")
_assert("识别未启用" in str(failed.get("detail")), f"failed state should be clear: {failed}")
_assert("已有能力" in str(failed.get("detail")), f"failed state should explain fallback: {failed}")
disabled = summarize_scdm_runtime(backend={"disabled": True}, cache_state="ready", feature_cache=feature_cache)
_assert(disabled.get("backendReady") is False, f"disabled backend should not be ready: {disabled}")
_assert("已关闭" in str(disabled.get("headline")), f"disabled state should be clear: {disabled}")
_assert("不会启动" in str(disabled.get("detail")), f"disabled detail should explain behavior: {disabled}")
stale = summarize_scdm_runtime(backend=backend.to_cache(), cache_state="stale", cache_message="模型已重新加载,SCDM cache 已失效。")
_assert("失效" in str(stale.get("detail")), f"stale state should be visible: {stale}")
progress_cache = {
"objects": [
{
"objectId": "face:1",
"capabilities": [{"key": "face.offset", "displayName": "偏移"}],
},
{
"objectId": "hole:1",
"capabilities": [
{"key": "hole.diameter", "displayName": "直径", "blockReason": "SCDM 当前脚本环境缺少 OffsetFaces 命令。"},
{"key": "hole.position", "displayName": "位置"},
{"key": "feature.fill", "displayName": "填孔/删除小特征"},
],
},
],
"diagnostics": {
"face_adjacency": [
{"bodyIndex": 0, "faceOrdinals": [1, 2], "edgeCount": 1},
{"bodyIndex": 0, "faceOrdinals": [2, 3], "edgeCount": 2},
],
"edge_geometry_summary": {
"totalEdgeCount": 12,
"edgeKindCounts": {"linear": 8, "circular": 4},
"circularEdgeCount": 4,
"circularRadiusBuckets": [{"radius": "0.25", "count": 4}],
},
"feature_inventory": {
"objectTypeCounts": {"face": 4, "hole": 2, "edge": 12, "slot": 1},
"surfaceTypeCounts": {"plane": 4, "cylinder": 3},
"operationCounts": {"pull_face_offset": 4, "change_hole_diameter": 2, "change_slot_width": 1},
},
"geometry_candidate_hints": [
{
"capabilityKey": "boss.height",
"displayName": "凸台高度",
"evidenceCount": 3,
"confidence": "low",
},
{
"capabilityKey": "pattern.instance_position",
"displayName": "阵列实例位置",
"evidenceCount": 2,
"confidence": "low",
},
],
"derived_feature_candidates": [
{
"objectId": "derived:linear_pattern:hole-a|hole-b|hole-c",
"objectType": "linear_pattern",
"geometrySignature": {"spacing": 5.0, "instanceCount": 3},
}
],
"planned_not_productized": [],
"discovered_not_productized": [
{"objectType": "mystery_feature"},
{"objectType": "mystery_feature"},
],
},
}
progress = summarize_scdm_capability_progress(
feature_cache=progress_cache,
execution_ready={
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
},
)
summary = progress.get("summary")
_assert(isinstance(summary, dict), f"capability progress should include summary: {progress}")
_assert(summary.get("productized") == 17, f"productized capability count should include S5 plus slot dimensions, boss dimensions, round/chamfer dimensions, pattern spacing, local segment spacing, instance position, shell thickness, Move-based S7 entries and round/chamfer delete: {summary}")
_assert(summary.get("runnerReady") == 17, f"runner-ready capability count should honor UI gate: {summary}")
_assert(summary.get("executableCapabilities") == 3, f"blocked/ungated capabilities should not be executable: {summary}")
_assert(summary.get("plannedDetected") == 0, f"planned S7 detections should be counted: {summary}")
_assert(summary.get("discoveredNotProductized") == 2, f"unknown discoveries should be counted: {summary}")
_assert(summary.get("faceAdjacency") == 2 and summary.get("circularEdges") == 4, f"probe topology evidence should be counted: {summary}")
_assert(
summary.get("inventoryObjectTypes") == 19 and summary.get("inventoryOperationCandidates") == 7,
f"probe feature inventory should be counted: {summary}",
)
_assert(summary.get("geometryHints") == 5, f"geometry candidate hints should be counted: {summary}")
_assert(summary.get("derivedFeatureCandidates") == 1, f"derived S7 candidate count should be visible: {summary}")
productized_lines = "\n".join(str(line) for line in progress.get("productizedLines", []))
planned_lines = "\n".join(str(line) for line in progress.get("plannedLines", []))
evidence_lines = "\n".join(str(line) for line in progress.get("probeEvidence", {}).get("lines", []))
_assert("偏移:已开放" in productized_lines, f"open SCDM capability should be visible: {productized_lines}")
_assert("直径:已开放但被后端阻止" in productized_lines, f"blocked SCDM capability should be explicit: {productized_lines}")
_assert("填孔/删除小特征:已开放" in productized_lines, f"recognized command capability should be visible as open: {productized_lines}")
_assert("槽深:已开放待识别" in productized_lines, f"productized slot.depth should be visible: {productized_lines}")
_assert("凸台直径:已开放待识别" in productized_lines, f"productized boss.diameter should be visible: {productized_lines}")
_assert("凸台高度:已开放待识别" in productized_lines, f"productized boss.height should be visible: {productized_lines}")
_assert("圆角半径:已开放待识别" in productized_lines, f"productized round.radius should be visible: {productized_lines}")
_assert("倒角距离:已开放待识别" in productized_lines, f"productized chamfer.distance should be visible: {productized_lines}")
_assert("阵列间距:已开放待识别" in productized_lines, f"productized pattern.spacing should be visible: {productized_lines}")
_assert("局部间距:已开放待识别" in productized_lines, f"productized pattern.segment_spacing should be visible: {productized_lines}")
_assert("阵列实例位置:已开放待识别" in productized_lines, f"productized pattern.instance_position should be visible: {productized_lines}")
_assert("壳体厚度:已开放待识别" in productized_lines, f"productized shell.thickness should be visible: {productized_lines}")
_assert("阵列实例位置" not in planned_lines, f"pattern.instance_position should no longer be a planned-only line: {planned_lines}")
_assert("Face 邻接 2 组" in evidence_lines and "圆边 4 条" in evidence_lines, f"probe evidence lines should be readable: {evidence_lines}")
_assert("对象分布" in evidence_lines and "命令候选分布" in evidence_lines, f"probe inventory lines should be readable: {evidence_lines}")
_assert("几何候选 凸台高度:3" in evidence_lines, f"probe geometry hint lines should be readable: {evidence_lines}")
_assert("linear_pattern:1" in evidence_lines, f"derived S7 candidate lines should be readable: {evidence_lines}")
with tempfile.TemporaryDirectory(prefix="step_editor_scdm_status_") as temp:
root = Path(temp)
fake_exe = root / "ANSYS Inc" / "v222" / "SCDM" / "SpaceClaim.exe"
fake_exe.parent.mkdir(parents=True, exist_ok=True)
fake_exe.write_text("fake", encoding="utf-8")
saved_backend = ScdmBackendInfo(path=fake_exe, source="manual", version="v222", run_script_ok=True, license_ok=True)
save_scdm_backend_cache(saved_backend, project_root_override=root)
cached = cached_scdm_backend_payload(root)
_assert(isinstance(cached, dict), f"cached backend should load: {cached}")
_assert(str(cached.get("source")) == "manual", f"cached source should be preserved: {cached}")
print("scdm status summary ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4 -2
View File
@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from .model import StepModel
__all__ = ["StepEditorWindow", "StepModel", "main"] __all__ = ["StepEditorWindow", "StepModel", "main"]
def __getattr__(name: str): def __getattr__(name: str):
if name == "StepModel":
from .model import StepModel
return StepModel
if name in {"StepEditorWindow", "main"}: if name in {"StepEditorWindow", "main"}:
from .app import StepEditorWindow, main from .app import StepEditorWindow, main
+108 -16
View File
@@ -173,7 +173,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.orientation_marker_prop = None self.orientation_marker_prop = None
self.step_coordinate_axes_actor = None self.step_coordinate_axes_actor = None
self.hide_edges_during_camera_interaction = False self.hide_edges_during_camera_interaction = False
self.hide_overlays_during_camera_interaction = False
self.edge_visibility_before_camera_interaction: int | None = None self.edge_visibility_before_camera_interaction: int | None = None
self.overlay_visibility_before_camera_interaction: dict[str, int] = {}
self.prefer_fxaa_antialiasing = True self.prefer_fxaa_antialiasing = True
self.fallback_multi_samples = 2 self.fallback_multi_samples = 2
self.interactive_multi_samples = 0 self.interactive_multi_samples = 0
@@ -184,6 +186,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hover_face_actor = None self.hover_face_actor = None
self.hover_edge_actor = None self.hover_edge_actor = None
self.hover_signature: tuple[str, int] | None = None self.hover_signature: tuple[str, int] | None = None
self.large_model_edge_overlay_skipped = False
self.large_model_hover_disabled = False
self.hover_interval_ms = 260 self.hover_interval_ms = 260
self.hover_move_threshold_px = 10 self.hover_move_threshold_px = 10
self.pending_hover_position: tuple[int, int] | None = None self.pending_hover_position: tuple[int, int] | None = None
@@ -224,6 +228,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {} self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {}
self.edge_overlay_polydata_cache: dict[int, object] = {} self.edge_overlay_polydata_cache: dict[int, object] = {}
self.overlay_cache_limit = 160 self.overlay_cache_limit = 160
self.scene_rebuild_in_progress = False
self.show_internal_edges_checkbox: QCheckBox | None = None self.show_internal_edges_checkbox: QCheckBox | None = None
self.scene_isolated = False self.scene_isolated = False
self.undo_stack: list[dict[int, object]] = [] self.undo_stack: list[dict[int, object]] = []
@@ -260,6 +265,35 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.asitus_thread: QThread | None = None self.asitus_thread: QThread | None = None
self.asitus_worker: ScanWorker | None = None self.asitus_worker: ScanWorker | None = None
self.pending_asitus_context: dict[str, object] | None = None self.pending_asitus_context: dict[str, object] | None = None
self.scdm_thread: QThread | None = None
self.scdm_worker: ScanWorker | None = None
self.pending_scdm_context: dict[str, object] | None = None
self.scdm_backend_status: dict[str, object] | None = None
self.scdm_auto_config_prompt_seen = False
self.scdm_auto_config_prompt_active = False
self.scdm_feature_cache: dict[str, object] | None = None
self.scdm_feature_cache_state = "empty"
self.scdm_feature_cache_message = ""
self.scdm_feature_cache_path = ""
self.scdm_edit_runner_ready = {
"face.offset",
"hole.diameter",
"hole.position",
"feature.fill",
"slot.width",
"slot.depth",
"slot.position",
"boss.diameter",
"boss.height",
"boss.position",
"round.radius",
"chamfer.distance",
"feature.delete_round_or_chamfer",
"pattern.spacing",
"pattern.segment_spacing",
"pattern.instance_position",
"shell.thickness",
}
self.load_in_progress = False self.load_in_progress = False
self.load_thread: QThread | None = None self.load_thread: QThread | None = None
self.load_worker: LoadWorker | None = None self.load_worker: LoadWorker | None = None
@@ -287,6 +321,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.property_command_buttons: dict[str, QPushButton] = {} self.property_command_buttons: dict[str, QPushButton] = {}
self.relation_formula_items: list[dict[str, object]] = [] self.relation_formula_items: list[dict[str, object]] = []
self.relation_formula_next_id = 1 self.relation_formula_next_id = 1
self.relation_formula_base_snapshot: dict[object, object] | None = None
self.relation_formula_replay_queue: list[dict[str, object]] = []
self.relation_formula_replay_total = 0
self.relation_formula_replay_done = 0
self.relation_formula_replay_active = False
self.relation_formula_replay_current_id: int | None = None
self.relation_formula_replay_callback_seen = False
self._relation_formula_replay_running_action = False
self._relation_formula_object_label_cache_key: object = None
self._relation_formula_object_label_cache: dict[str, object] = {}
self._build_ui() self._build_ui()
self._build_vtk() self._build_vtk()
@@ -495,10 +539,40 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #bbf7d0; border-color: #bbf7d0;
color: #14532d; color: #14532d;
} }
QPushButton#softwareProgressButton {
background: #f0fdf4;
border: 1px solid #86efac;
border-left: 5px solid #16a34a;
border-radius: 7px;
color: #14532d;
font-weight: 800;
min-height: 30px;
padding: 5px 10px;
text-align: left;
}
QPushButton#softwareProgressButton:hover {
background: #dcfce7;
border-color: #22c55e;
}
QPushButton#softwareProgressButton:pressed {
background: #bbf7d0;
border-color: #16a34a;
padding-top: 6px;
padding-bottom: 4px;
}
QLabel#capabilityHeadline { QLabel#capabilityHeadline {
color: #14532d; color: #14532d;
font-weight: 800; font-weight: 800;
} }
QLabel#scdmBackendStatus {
color: #166534;
font-size: 11px;
font-weight: 700;
}
QLabel#scdmBackendDetail {
color: #3f6212;
font-size: 11px;
}
QLabel#capabilityDetail { QLabel#capabilityDetail {
color: #166534; color: #166534;
font-size: 11px; font-size: 11px;
@@ -995,7 +1069,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
mode_box = QWidget() mode_box = QWidget()
mode_box.setMinimumHeight(62) mode_box.setMinimumHeight(62)
help_tip(mode_box, "决定鼠标点模型时选中零件、Solid、Face、Edge,还是识别几何特征。") help_tip(mode_box, "决定鼠标点模型时选中 Part、Solid、Face、Edge,还是用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征。")
self.mode_section_title = QLabel("选择模式", mode_box) self.mode_section_title = QLabel("选择模式", mode_box)
self.mode_section_title.setObjectName("modeSectionTitle") self.mode_section_title.setObjectName("modeSectionTitle")
self.mode_section_title.setFixedSize(74, 20) self.mode_section_title.setFixedSize(74, 20)
@@ -1030,7 +1104,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mode_combo.setMaximumWidth(112) self.mode_combo.setMaximumWidth(112)
help_tip( help_tip(
self.mode_combo, self.mode_combo,
"选择模式决定鼠标点击模型时要选什么:零件、Solid、Face、Edge,或把 Face 解释成孔/槽/圆角等几何特征候选", "选择模式决定鼠标点击模型时要选什么:Part、Solid、Face、Edge,或用 Feature 模式把点到的 Face 解释成孔、槽、圆角等特征",
) )
self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode())) self.mode_combo.currentIndexChanged.connect(lambda _index: self._on_mode_changed(self._current_selection_mode()))
mode_pick_layout.addWidget(self.mouse_mode_label) mode_pick_layout.addWidget(self.mouse_mode_label)
@@ -1242,6 +1316,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
"特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。", "特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。",
) )
self.property_table.itemChanged.connect(self._on_property_table_item_changed) self.property_table.itemChanged.connect(self._on_property_table_item_changed)
self.property_table.itemSelectionChanged.connect(lambda: self._update_property_apply_state())
object_edit_layout.addWidget(self.property_table) object_edit_layout.addWidget(self.property_table)
self.property_command_summary_label = QLabel("未选择可编辑对象") self.property_command_summary_label = QLabel("未选择可编辑对象")
self.property_command_summary_label.setObjectName("propertyCommandSummary") self.property_command_summary_label.setObjectName("propertyCommandSummary")
@@ -1292,7 +1367,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.relation_formula_box.setObjectName("relationFormulaBox") self.relation_formula_box.setObjectName("relationFormulaBox")
help_tip( help_tip(
self.relation_formula_box, self.relation_formula_box,
"用 FaceID.参数 = 表达式 的形式建立关系式。第一版会先计算公式并回填当前参数表目标值,再执行参数化建模", "用 FaceID.参数 = 表达式 的形式建立关系式。添加后会立即按当前公式组重新计算并修改模型",
) )
relation_layout = QVBoxLayout(self.relation_formula_box) relation_layout = QVBoxLayout(self.relation_formula_box)
relation_layout.setContentsMargins(6, 8, 6, 6) relation_layout.setContentsMargins(6, 8, 6, 6)
@@ -1305,13 +1380,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.relation_formula_input.setPlaceholderText("Face87.直径 = Face85.直径") self.relation_formula_input.setPlaceholderText("Face87.直径 = Face85.直径")
help_tip( help_tip(
self.relation_formula_input, self.relation_formula_input,
"示例:Face87.直径 = Face85.直径,或 Face87.位置 = Face85.位置 + (0, 0, -3.5)。输入 Face87. 后会提示当前可用参数。", "示例:Face87.直径 = Face85.直径,或 Face87.位置 = Face85.位置 + (0, 0, -3.5)。输入 Face87. 后会提示可用参数。",
) )
self.relation_formula_completer_model = QStringListModel(self) self.relation_formula_completer_model = QStringListModel(self)
self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self) self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self)
self.relation_formula_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive) self.relation_formula_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.relation_formula_completer.setFilterMode(Qt.MatchFlag.MatchStartsWith)
self.relation_formula_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) self.relation_formula_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.relation_formula_input.setCompleter(self.relation_formula_completer) self.relation_formula_completer.setMaxVisibleItems(12)
self.relation_formula_completer.activated[str].connect(self._on_relation_formula_completion_activated)
relation_popup = self.relation_formula_completer.popup()
if relation_popup is not None:
relation_popup.setFocusPolicy(Qt.FocusPolicy.NoFocus)
relation_popup.installEventFilter(self)
self.relation_formula_completer.setWidget(self.relation_formula_input)
self.relation_formula_input.installEventFilter(self) self.relation_formula_input.installEventFilter(self)
self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed) self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed)
self.relation_formula_input.returnPressed.connect(self.add_relation_formula) self.relation_formula_input.returnPressed.connect(self.add_relation_formula)
@@ -1330,7 +1412,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
relation_layout.addWidget(self.relation_formula_list) relation_layout.addWidget(self.relation_formula_list)
relation_button_row = QHBoxLayout() relation_button_row = QHBoxLayout()
relation_button_row.setContentsMargins(0, 0, 0, 0) relation_button_row.setContentsMargins(0, 0, 0, 0)
self.import_relation_formula_button = QPushButton("导入公式")
self.import_relation_formula_button.setObjectName("relationFormulaImportButton")
self.import_relation_formula_button.clicked.connect(self.import_relation_formulas)
relation_button_row.addWidget(self.import_relation_formula_button)
self.export_relation_formula_button = QPushButton("导出公式")
self.export_relation_formula_button.setObjectName("relationFormulaExportButton")
self.export_relation_formula_button.clicked.connect(self.export_relation_formulas)
relation_button_row.addWidget(self.export_relation_formula_button)
relation_button_row.addStretch(1) relation_button_row.addStretch(1)
self.toggle_relation_formula_button = QPushButton("停用公式")
self.toggle_relation_formula_button.setObjectName("relationFormulaToggleButton")
self.toggle_relation_formula_button.clicked.connect(self.toggle_selected_relation_formula)
relation_button_row.addWidget(self.toggle_relation_formula_button)
self.remove_relation_formula_button = QPushButton("删除公式") self.remove_relation_formula_button = QPushButton("删除公式")
self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton") self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton")
self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula) self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula)
@@ -1344,7 +1438,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.apply_property_button.setObjectName("parametricModelButton") self.apply_property_button.setObjectName("parametricModelButton")
self.apply_property_button.setMinimumHeight(34) self.apply_property_button.setMinimumHeight(34)
self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.apply_property_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.apply_property_button, "应用当前被修改的参数;多个目标值会按表格顺序依次执行,失败时停止后续修改。") help_tip(self.apply_property_button, "应用当前被修改的数值参数;命令型参数需先选中该行。多个项目会按表格顺序依次执行,失败时停止后续修改。")
self.apply_property_button.clicked.connect(self.apply_current_property_edit) self.apply_property_button.clicked.connect(self.apply_current_property_edit)
self.quick_export_all_button = QPushButton("导出模型") self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton") self.quick_export_all_button.setObjectName("quickExportStepButton")
@@ -1680,16 +1774,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2) edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2)
panel_layout.addWidget(self.object_edit_box) panel_layout.addWidget(self.object_edit_box)
self.current_capability_box = QGroupBox("软件进度") self.current_capability_button = QPushButton("软件进度")
self.current_capability_box.setObjectName("capabilitySection") self.current_capability_button.setObjectName("softwareProgressButton")
capability_layout = QVBoxLayout(self.current_capability_box) self.current_capability_button.setMinimumHeight(32)
capability_layout.setContentsMargins(8, 8, 8, 7) self.current_capability_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
capability_layout.setSpacing(2) help_tip(self.current_capability_button, "点击查看当前参数化能力、SCDM 后端状态和后续实施路线。")
self.current_capability_headline = QLabel("当前支持:Face、孔/槽、Edge、凸台、圆角/倒角、壳体") self.current_capability_button.clicked.connect(self.show_software_progress_dialog)
self.current_capability_headline.setObjectName("capabilityHeadline") panel_layout.addWidget(self.current_capability_button)
self.current_capability_headline.setWordWrap(True) self._update_current_capability_panel()
capability_layout.addWidget(self.current_capability_headline)
panel_layout.addWidget(self.current_capability_box)
if ENABLE_EXPORT_PANEL: if ENABLE_EXPORT_PANEL:
panel_layout.addWidget(export_box) panel_layout.addWidget(export_box)
if ENABLE_VIEW_PANEL: if ENABLE_VIEW_PANEL:
+18 -9
View File
@@ -3452,20 +3452,28 @@ class FeatureMixin:
plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir)) plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir))
if plane_axis_alignment < 0.92: if plane_axis_alignment < 0.92:
continue continue
try: source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
except Exception: v_min = source_v_min
axis_range = { v_max = source_v_max
"v_min": min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())), range_source = "selected-face-v-range-fast"
"v_max": max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())),
}
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
height = max(v_max - v_min, 1e-9) height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point) cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point)
start_distance = abs(cap_parameter - v_min) start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max) end_distance = abs(cap_parameter - v_max)
end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if start_distance > end_tolerance and end_distance > end_tolerance:
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"])
range_source = str(axis_range.get("range_source") or "same-domain-cylinder-faces")
height = max(v_max - v_min, 1e-9)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
except Exception:
pass
if start_distance <= end_distance and start_distance <= end_tolerance: if start_distance <= end_distance and start_distance <= end_tolerance:
outward = _neg_tuple(_dir_tuple(axis_dir)) outward = _neg_tuple(_dir_tuple(axis_dir))
end_label = "start" end_label = "start"
@@ -3487,6 +3495,7 @@ class FeatureMixin:
"cap_axis_parameter": cap_parameter, "cap_axis_parameter": cap_parameter,
"cap_axis_start_parameter": v_min, "cap_axis_start_parameter": v_min,
"cap_axis_end_parameter": v_max, "cap_axis_end_parameter": v_max,
"cap_axis_range_source": range_source,
}, },
) )
) )
+185 -12
View File
@@ -226,6 +226,79 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info.update(_shape_volume_info(self.shape)) info.update(_shape_volume_info(self.shape))
return info return info
def scdm_local_face_signatures(self) -> list[dict[str, object]]:
"""Return cheap geometry hints used to map SCDM raw objects back to local Face IDs."""
signatures: list[dict[str, object]] = []
face_ordinal_by_solid: dict[int, int] = {}
for face_id, face in enumerate(self.faces):
try:
surf = BRepAdaptor_Surface(face)
surface_type = surf.GetType()
except Exception:
continue
solid_id = int(self.face_solid_ids[face_id]) if face_id < len(self.face_solid_ids) else -1
part_id = int(self.face_part_ids[face_id]) if face_id < len(self.face_part_ids) else -1
face_ordinal = face_ordinal_by_solid.get(solid_id, 0)
face_ordinal_by_solid[solid_id] = face_ordinal + 1
signature: dict[str, object] = {
"faceId": int(face_id),
"logicalFaceId": self.face_logical_id(face_id),
"partId": part_id,
"solidId": solid_id,
"bodyIndex": solid_id,
"faceOrdinal": face_ordinal,
"globalFaceOrdinal": int(face_id),
"surfaceType": SURFACE_TYPES.get(surface_type, f"type {surface_type}"),
}
try:
bounds = _shape_bounds_info(face)
signature["bboxMin"] = bounds.get("bbox_min")
signature["bboxMax"] = bounds.get("bbox_max")
signature["bboxSize"] = bounds.get("bbox_size")
except Exception:
pass
try:
if surface_type == GeomAbs_Plane:
plane = surf.Plane()
origin = _point_tuple(plane.Location())
normal = _dir_tuple(plane.Axis().Direction())
signature["center"] = origin
signature["axis"] = normal
signature["planeOffset"] = (
origin[0] * normal[0]
+ origin[1] * normal[1]
+ origin[2] * normal[2]
)
elif surface_type == GeomAbs_Cylinder:
cylinder = surf.Cylinder()
axis = cylinder.Axis()
radius = cylinder.Radius()
signature["center"] = _point_tuple(axis.Location())
signature["axis"] = _dir_tuple(axis.Direction())
signature["radius"] = radius
signature["diameter"] = radius * 2.0
elif surface_type == GeomAbs_Cone:
cone = surf.Cone()
signature["center"] = _point_tuple(cone.Location())
signature["axis"] = _dir_tuple(cone.Axis().Direction())
signature["radius"] = cone.RefRadius()
elif surface_type == GeomAbs_Sphere:
sphere = surf.Sphere()
radius = sphere.Radius()
signature["center"] = _point_tuple(sphere.Location())
signature["radius"] = radius
signature["diameter"] = radius * 2.0
elif surface_type == GeomAbs_Torus:
torus = surf.Torus()
signature["center"] = _point_tuple(torus.Location())
signature["axis"] = _dir_tuple(torus.Axis().Direction())
signature["majorRadius"] = torus.MajorRadius()
signature["minorRadius"] = torus.MinorRadius()
except Exception:
pass
signatures.append(signature)
return signatures
def refresh_topology(self) -> None: def refresh_topology(self) -> None:
self._topology_refresh_generation = int(getattr(self, "_topology_refresh_generation", 0)) + 1 self._topology_refresh_generation = int(getattr(self, "_topology_refresh_generation", 0)) + 1
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()]) self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
@@ -406,6 +479,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"same_domain_v_range", "same_domain_v_range",
"same_domain_height_estimate", "same_domain_height_estimate",
"same_domain_range_source", "same_domain_range_source",
"selected_angular_span",
): ):
if key in hint: if key in hint:
info[key] = hint[key] info[key] = hint[key]
@@ -583,7 +657,33 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if angular_span is None: if angular_span is None:
angular_span = _float_or_none(info.get("angular_span")) or 0.0 angular_span = _float_or_none(info.get("angular_span")) or 0.0
is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92 is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92
selected_span = (
_float_or_none(info.get("selected_angular_span"))
or _float_or_none(info.get("slot_angular_span"))
or _float_or_none(info.get("angular_span"))
or angular_span
)
try:
same_domain_count = int(info.get("same_domain_face_count", 1) or 1)
except (TypeError, ValueError):
same_domain_count = 1
is_blind_split_groove = (
is_full
and same_domain_count > 1
and 1e-6 < selected_span < math.tau * 0.92
and str(info.get("cylinder_end_type") or "") == "blind"
and guess == "hole/groove candidate"
)
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
if is_blind_split_groove:
return {
"feature_type": "圆柱孔/槽候选",
"feature_edit_actions": (
"调整圆柱孔径或槽/凹槽宽度、深度;"
"具体按孔还是按槽会在完整识别时根据周围同类特征确认。"
),
"blind_split_cylindrical_pocket": True,
}
if not is_full: if not is_full:
return { return {
"feature_type": "槽/半孔候选", "feature_type": "槽/半孔候选",
@@ -746,7 +846,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
This intentionally avoids material-side sampling. It only combines This intentionally avoids material-side sampling. It only combines
directly connected co-cylindrical fragments and uses face orientation as directly connected co-cylindrical fragments and uses face orientation as
a hint, so full edit plans still recompute and guard the real feature a hint. It must not trigger Analysis Situs or the internal recognition
graph; full edit plans still recompute and guard the real feature
semantics before changing geometry. semantics before changing geometry.
""" """
radius = _float_or_none(info.get("radius")) or 0.0 radius = _float_or_none(info.get("radius")) or 0.0
@@ -765,7 +866,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
same_domain_note = "快速识别:当前圆柱没有检测到直接相接的同域碎面。" same_domain_note = "快速识别:当前圆柱没有检测到直接相接的同域碎面。"
axis_range: dict[str, object] | None = None axis_range: dict[str, object] | None = None
try: try:
side_face_ids = self.connected_same_domain_face_ids(face_id) or [face_id] side_face_ids = self._connected_cocylindrical_face_ids(face_id) or [face_id]
spans: list[float] = [] spans: list[float] = []
for side_id in side_face_ids: for side_id in side_face_ids:
side_surf = BRepAdaptor_Surface(self.faces[side_id]) side_surf = BRepAdaptor_Surface(self.faces[side_id])
@@ -787,6 +888,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"same_domain_face_count": len(side_face_ids), "same_domain_face_count": len(side_face_ids),
"feature_highlight_face_ids": tuple(side_face_ids), "feature_highlight_face_ids": tuple(side_face_ids),
"angular_span": combined_span, "angular_span": combined_span,
"selected_angular_span": selected_span,
"same_domain_angular_span": combined_span, "same_domain_angular_span": combined_span,
"same_domain_note": same_domain_note, "same_domain_note": same_domain_note,
"is_full_cylinder": combined_span >= math.tau * 0.92, "is_full_cylinder": combined_span >= math.tau * 0.92,
@@ -1044,6 +1146,21 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
except Exception: except Exception:
return None return None
def face_plane_position_along(self, face_id: int, direction: tuple[float, float, float]) -> tuple[float, tuple[float, float, float]] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
if surf.GetType() != GeomAbs_Plane:
return None
plane = surf.Plane()
origin = _point_tuple(plane.Location())
normal = _dir_tuple(plane.Axis().Direction())
value = origin[0] * direction[0] + origin[1] * direction[1] + origin[2] * direction[2]
return float(value), normal
except Exception:
return None
def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]: def _recognition_summary_fields(self, info: dict[str, object]) -> dict[str, object]:
surface = str(info.get("surface") or "") surface = str(info.get("surface") or "")
user_priority = feature_recognition_priority(info) user_priority = feature_recognition_priority(info)
@@ -2724,6 +2841,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
bottom_face_ids = end_faces["bottom_face_ids"] bottom_face_ids = end_faces["bottom_face_ids"]
opening_face_ids = end_faces["opening_face_ids"] opening_face_ids = end_faces["opening_face_ids"]
guess = str(info.get("feature_guess", "cylindrical face")) guess = str(info.get("feature_guess", "cylindrical face"))
selected_angular_span = _float_or_none(info.get("angular_span")) or combined_angular_span
has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"]) has_two_axial_caps = bool(end_faces["start_end_face_ids"] and end_faces["end_end_face_ids"])
support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids)) support_face_ids_for_round = sorted(set(adjacent_face_ids) - set(end_face_ids))
material_toward = str(info.get("material_toward_axis", "") or "") material_toward = str(info.get("material_toward_axis", "") or "")
@@ -2743,13 +2861,37 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"cocylindrical region has material inside its axis and explicit planar caps at both ends" "cocylindrical region has material inside its axis and explicit planar caps at both ends"
) )
domain_info["feature_guess"] = info["feature_guess"] domain_info["feature_guess"] = info["feature_guess"]
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, domain_info) is_blind_split_cylindrical_pocket = (
guess == "hole/groove candidate"
and len(side_face_ids) > 1
and combined_angular_span >= math.tau * 0.92
and 1e-6 < selected_angular_span < math.tau * 0.92
and str(info.get("cylinder_end_type") or "") == "blind"
and bool(bottom_face_ids)
and bool(opening_face_ids)
)
slot_domain_info = dict(domain_info)
if is_blind_split_cylindrical_pocket:
slot_domain_info["angular_span"] = selected_angular_span
slot_domain_info["is_full_cylinder"] = False
slot_info = self._cylindrical_slot_info(face_id, adjacent_face_ids, end_face_ids, slot_domain_info)
fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info) fillet_info = self._cylindrical_existing_fillet_info(face_id, adjacent_face_ids, end_face_ids, domain_info)
try:
slot_pair_candidate_count = int(slot_info.get("slot_pair_candidate_count", 0) or 0)
except (TypeError, ValueError):
slot_pair_candidate_count = 0
is_blind_split_cylindrical_groove = (
is_blind_split_cylindrical_pocket
and slot_pair_candidate_count >= 10
)
guess = str(info.get("feature_guess", "cylindrical face")) guess = str(info.get("feature_guess", "cylindrical face"))
angular_span = combined_angular_span angular_span = selected_angular_span if is_blind_split_cylindrical_groove else combined_angular_span
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
if angular_span < math.tau * 0.92: if is_blind_split_cylindrical_groove:
feature_type = "盲槽/圆柱凹槽候选"
edit_actions = "调整槽/凹槽宽度;调整槽/凹槽深度;调整圆弧长度;调整圆弧角度"
elif angular_span < math.tau * 0.92:
feature_type = "槽/半孔候选" feature_type = "槽/半孔候选"
edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度" edit_actions = "调整圆柱孔径;调整槽/半孔宽度;调整槽/半孔深度;调整槽/半孔圆弧长度;调整槽/半孔圆弧角度;调整槽孔总长度"
else: else:
@@ -2759,7 +2901,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
edit_actions += ";调整盲孔/盲槽深度" edit_actions += ";调整盲孔/盲槽深度"
else: else:
edit_actions += ";孔深调整需要明确盲孔底面" edit_actions += ";孔深调整需要明确盲孔底面"
if angular_span >= math.tau * 0.92: if angular_span >= math.tau * 0.92 and not is_blind_split_cylindrical_groove:
edit_actions += ";封堵圆柱孔" edit_actions += ";封堵圆柱孔"
elif guess == "round/fillet candidate": elif guess == "round/fillet candidate":
fillet_status = str(fillet_info.get("existing_fillet_status") or "") fillet_status = str(fillet_info.get("existing_fillet_status") or "")
@@ -2779,7 +2921,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
else: else:
feature_type = "未明确圆柱特征" feature_type = "未明确圆柱特征"
edit_actions = "可尝试调整圆柱孔径,但风险较高。" edit_actions = "可尝试调整圆柱孔径,但风险较高。"
if str(slot_info.get("slot_status") or "") == "blocked": if str(slot_info.get("slot_status") or "") == "blocked" and (
is_blind_split_cylindrical_groove or angular_span < math.tau * 0.92
):
feature_type = "复杂槽/多槽组候选(暂不支持修改)" feature_type = "复杂槽/多槽组候选(暂不支持修改)"
edit_actions = "只读诊断;当前不开放槽宽、槽深、弧长、弧角或槽轴心修改。" edit_actions = "只读诊断;当前不开放槽宽、槽深、弧长、弧角或槽轴心修改。"
@@ -2811,7 +2955,8 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"same_domain_height_estimate": axis_range["span"], "same_domain_height_estimate": axis_range["span"],
"same_domain_angular_span": combined_angular_span, "same_domain_angular_span": combined_angular_span,
"angular_span": combined_angular_span, "angular_span": combined_angular_span,
"is_full_cylinder": combined_angular_span >= math.tau * 0.92, "is_full_cylinder": combined_angular_span >= math.tau * 0.92 and not is_blind_split_cylindrical_groove,
"blind_split_cylindrical_groove": is_blind_split_cylindrical_groove,
"same_domain_range_source": axis_range["range_source"], "same_domain_range_source": axis_range["range_source"],
"same_domain_face_ids": tuple(side_face_ids), "same_domain_face_ids": tuple(side_face_ids),
"same_domain_face_count": len(side_face_ids), "same_domain_face_count": len(side_face_ids),
@@ -2832,7 +2977,15 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
} }
) )
result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result)) result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result))
if angular_span >= math.tau * 0.92: if is_blind_split_cylindrical_groove:
result["angular_span"] = selected_angular_span
result.setdefault("slot_kind", "partial-cylindrical-groove")
result.setdefault("slot_status", "candidate")
result.setdefault(
"slot_note",
"同域碎面合并后接近完整圆柱,但当前对象只有一个开口和一个底面,按盲槽/圆柱凹槽处理。",
)
elif angular_span >= math.tau * 0.92:
result.update( result.update(
{ {
"slot_kind": "", "slot_kind": "",
@@ -2849,7 +3002,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
} }
) )
scoped_readiness_info = dict(result) scoped_readiness_info = dict(result)
scoped_readiness_info["angular_span"] = combined_angular_span scoped_readiness_info["angular_span"] = angular_span
scoped_readiness_info["height_estimate"] = axis_range["span"] scoped_readiness_info["height_estimate"] = axis_range["span"]
scoped_readiness_info["feature_guess"] = guess scoped_readiness_info["feature_guess"] = guess
if guess == "hole/groove candidate": if guess == "hole/groove candidate":
@@ -2877,7 +3030,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。", "resize_note": "当前对象已识别为圆角/倒圆;请使用已有圆角半径入口,不按孔径重切。",
} }
) )
if str(slot_info.get("slot_status") or "") == "blocked": if str(slot_info.get("slot_status") or "") == "blocked" and (
is_blind_split_cylindrical_groove or angular_span < math.tau * 0.92
):
blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。") blocker = str(slot_info.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。")
result.update( result.update(
{ {
@@ -4663,7 +4818,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
return list(self._internal_hole_region_cache.get(face_id, ())) return list(self._internal_hole_region_cache.get(face_id, ()))
def _can_use_internal_recognition_graph(self) -> bool: def _can_use_internal_recognition_graph(self) -> bool:
return bool(self.faces) return bool(self.faces) and len(self.faces) <= 1000
def _load_internal_hole_regions(self) -> None: def _load_internal_hole_regions(self) -> None:
self._internal_hole_regions_attempted = True self._internal_hole_regions_attempted = True
@@ -4808,6 +4963,24 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._editable_feature_candidates_cache.clear() self._editable_feature_candidates_cache.clear()
self._cylindrical_feature_candidates_cache.clear() self._cylindrical_feature_candidates_cache.clear()
def mark_external_recognition_stale(self, reason: str = "modified-topology") -> None:
self._topology_refresh_generation = max(int(getattr(self, "_topology_refresh_generation", 0) or 0), 2)
self._asitus_hole_regions_attempted = False
self._asitus_hole_regions_loading = False
self._asitus_hole_region_cache.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": str(reason or "modified-topology"),
"message": "Analysis Situs result ignored because the model topology has changed.",
}
)
self._clear_same_domain_dependent_caches()
def _install_asitus_relation_summary(self, result: dict[str, object]) -> None: def _install_asitus_relation_summary(self, result: dict[str, object]) -> None:
faces = result.get("faces", ()) faces = result.get("faces", ())
if isinstance(faces, (tuple, list)): if isinstance(faces, (tuple, list)):
+21 -1
View File
@@ -11298,6 +11298,11 @@ class OperationMixin:
return None return None
source_plane = source_surf.Plane() source_plane = source_surf.Plane()
cap_plane_point = source_plane.Location() cap_plane_point = source_plane.Location()
try:
if len(_explore(self.faces[face_id], TopAbs_WIRE)) > 2:
return None
except Exception:
pass
try: try:
scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id] scope_face_ids = self._connected_coplanar_planar_face_ids(face_id) or [face_id]
except Exception: except Exception:
@@ -11342,11 +11347,26 @@ class OperationMixin:
continue continue
center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center) center_axis_distance = _point_axis_distance(axis_point, axis_dir, cap_center)
source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
axis_range = {
"v_min": source_v_min,
"v_max": source_v_max,
"same_domain_face_ids": (adjacent_id,),
"range_source": "selected-face-v-range-fast",
}
v_min = source_v_min
v_max = source_v_max
old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point)
start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
if start_distance > end_tolerance and end_distance > end_tolerance:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf) axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
v_min = float(axis_range["v_min"]) v_min = float(axis_range["v_min"])
v_max = float(axis_range["v_max"]) v_max = float(axis_range["v_max"])
old_height = max(v_max - v_min, 1e-9) old_height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, cap_plane_point)
start_distance = abs(cap_parameter - v_min) start_distance = abs(cap_parameter - v_min)
end_distance = abs(cap_parameter - v_max) end_distance = abs(cap_parameter - v_max)
end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05) end_tolerance = max(old_height * 0.05, radius * 0.2, tolerance * 10.0, 0.05)
+3 -1
View File
@@ -165,7 +165,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int:
not _is_effectively_full_cylinder(info) not _is_effectively_full_cylinder(info)
and angular_span is not None and angular_span is not None
and angular_span < math.tau * 0.92 and angular_span < math.tau * 0.92
) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or "槽/半孔候选" in feature_type: ) or _text(info.get("slot_kind")) == "partial-cylindrical-groove" or any(
token in feature_type for token in ("槽/半孔候选", "盲槽", "凹槽")
):
return 30 return 30
return 20 return 20
if feature_guess == "boss/outer-round candidate": if feature_guess == "boss/outer-round candidate":
+69
View File
@@ -10,6 +10,18 @@ from typing import Callable, Iterable
RELATION_REF_PATTERN = re.compile( RELATION_REF_PATTERN = re.compile(
r"\b(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[A-Za-z0-9_\u4e00-\u9fff]+)\b" r"\b(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[A-Za-z0-9_\u4e00-\u9fff]+)\b"
) )
RELATION_UNIT_LITERAL_PATTERN = re.compile(
r"(?<![A-Za-z0-9_.])(?P<number>(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)\s*(?P<unit>mm|毫米|cm|厘米|m|米)\b",
re.IGNORECASE,
)
RELATION_UNIT_MULTIPLIERS = {
"mm": 1.0,
"毫米": 1.0,
"cm": 10.0,
"厘米": 10.0,
"m": 1000.0,
"": 1000.0,
}
class RelationFormulaError(ValueError): class RelationFormulaError(ValueError):
@@ -112,7 +124,10 @@ def parse_relation_formula(text: str) -> RelationFormula:
references.append(_ref_from_match(match)) references.append(_ref_from_match(match))
return f"__ref{len(references) - 1}" return f"__ref{len(references) - 1}"
# 用户输入的 Face85.直径 不能直接丢给 eval;先替换成内部占位符,
# 后面只允许这些占位符和白名单 AST 节点参与计算。
safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression) safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression)
safe_expression = _replace_unit_literals(safe_expression)
try: try:
tree = ast.parse(safe_expression, mode="eval") tree = ast.parse(safe_expression, mode="eval")
except SyntaxError as exc: except SyntaxError as exc:
@@ -136,6 +151,8 @@ def evaluate_relation_formula(
namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref)) namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref))
code = compile(formula.safe_expression, "<relation-formula>", "eval") code = compile(formula.safe_expression, "<relation-formula>", "eval")
try: try:
# 这里仍然使用 Python 表达式能力,但 builtins 为空,AST 也已校验过。
# 关系式只承担参数求值,不允许调用函数、访问属性或执行任意代码。
value = eval(code, {"__builtins__": {}}, namespace) value = eval(code, {"__builtins__": {}}, namespace)
except ZeroDivisionError as exc: except ZeroDivisionError as exc:
raise RelationFormulaError("关系式中出现除以 0。") from exc raise RelationFormulaError("关系式中出现除以 0。") from exc
@@ -144,6 +161,46 @@ def evaluate_relation_formula(
return _coerce_formula_value(value) return _coerce_formula_value(value)
def validate_relation_formula_graph(formulas: Iterable[RelationFormula]) -> None:
target_to_formula: dict[str, RelationFormula] = {}
for formula in formulas:
target_token = formula.target.token
if target_token in target_to_formula:
raise RelationFormulaError(f"同一目标参数只能由一条关系式控制:{target_token}")
target_to_formula[target_token] = formula
target_tokens = set(target_to_formula)
graph: dict[str, list[str]] = {}
for target_token, formula in target_to_formula.items():
reference_tokens = [ref.token for ref in formula.references]
if target_token in reference_tokens:
raise RelationFormulaError(f"关系式不能引用自身:{target_token}")
# 只把“由其它公式控制的参数”纳入依赖图;普通测量值由模型/cache 提供,
# 不参与循环依赖判断。
graph[target_token] = [ref_token for ref_token in reference_tokens if ref_token in target_tokens]
visit_state: dict[str, str] = {}
stack: list[str] = []
def visit(token: str) -> None:
state = visit_state.get(token)
if state == "visiting":
start_index = stack.index(token) if token in stack else 0
cycle = [*stack[start_index:], token]
raise RelationFormulaError(f"关系式存在循环依赖:{' -> '.join(cycle)}")
if state == "visited":
return
visit_state[token] = "visiting"
stack.append(token)
for dependency in graph.get(token, []):
visit(dependency)
stack.pop()
visit_state[token] = "visited"
for target_token in graph:
visit(target_token)
def relation_value_to_text(value: object) -> str: def relation_value_to_text(value: object) -> str:
value = _coerce_formula_value(value) value = _coerce_formula_value(value)
if isinstance(value, Vector3): if isinstance(value, Vector3):
@@ -175,6 +232,18 @@ def _ref_from_match(match: re.Match[str]) -> ObjectParameterRef:
) )
def _replace_unit_literals(expression: str) -> str:
def replace(match: re.Match[str]) -> str:
number_text = str(match.group("number"))
unit = str(match.group("unit"))
multiplier = RELATION_UNIT_MULTIPLIERS.get(unit) or RELATION_UNIT_MULTIPLIERS.get(unit.lower())
if multiplier is None:
raise RelationFormulaError(f"不支持的单位:{unit}")
return f"({number_text}*{multiplier:.12g})"
return RELATION_UNIT_LITERAL_PATTERN.sub(replace, expression)
def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None: def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None:
allowed = ( allowed = (
ast.Expression, ast.Expression,
+623
View File
@@ -0,0 +1,623 @@
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable, Mapping, Sequence
try: # pragma: no cover - exercised only on Windows hosts with registry access.
import winreg
except ImportError: # pragma: no cover
winreg = None # type: ignore[assignment]
SCDM_EXE_NAME = "SpaceClaim.exe"
SCDM_CACHE_RELATIVE_PATH = Path("local") / "scdm_backend.json"
SCDM_PATH_ENV_VARS = (
"STEP_EDITOR_SCDM_EXE",
"STEP_EDITOR_SPACECLAIM_EXE",
"SPACECLAIM_EXE",
)
SCDM_DISABLE_ENV = "STEP_EDITOR_DISABLE_SCDM"
SCDM_TIMEOUT_ENV = "STEP_EDITOR_SCDM_TIMEOUT"
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmBackendInfo:
path: Path
source: str
version: str = ""
verified_at: str = ""
run_script_ok: bool = False
license_ok: bool | None = None
message: str = ""
def to_cache(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_CACHE_SCHEMA_VERSION,
"path": str(self.path),
"source": self.source,
"version": self.version,
"verifiedAt": self.verified_at,
"runScriptOk": bool(self.run_script_ok),
"licenseOk": self.license_ok,
"message": self.message,
}
@classmethod
def from_cache(cls, payload: Mapping[str, object]) -> "ScdmBackendInfo | None":
raw_path = str(payload.get("path") or "").strip()
if not raw_path:
return None
path = Path(os.path.expandvars(raw_path)).expanduser()
if not _is_spaceclaim_exe(path):
return None
return cls(
path=path,
source=str(payload.get("source") or "cache"),
version=str(payload.get("version") or _version_from_path(path)),
verified_at=str(payload.get("verifiedAt") or ""),
run_script_ok=bool(payload.get("runScriptOk")),
license_ok=_optional_bool(payload.get("licenseOk")),
message=str(payload.get("message") or ""),
)
def project_root(project_root_override: str | Path | None = None) -> Path:
return Path(project_root_override).expanduser() if project_root_override else Path(__file__).resolve().parent.parent
def default_scdm_cache_path(project_root_override: str | Path | None = None) -> Path:
return project_root(project_root_override) / SCDM_CACHE_RELATIVE_PATH
def is_scdm_disabled(env: Mapping[str, str] | None = None) -> bool:
value = (env or os.environ).get(SCDM_DISABLE_ENV, "")
return value.strip().lower() in {"1", "true", "yes", "on"}
def load_scdm_backend_cache(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> ScdmBackendInfo | None:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
if not path.is_file():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(payload, dict):
return None
return ScdmBackendInfo.from_cache(payload)
def save_scdm_backend_cache(
backend: ScdmBackendInfo,
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
) -> Path:
path = Path(cache_path).expanduser() if cache_path else default_scdm_cache_path(project_root_override)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(backend.to_cache(), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return path
def discover_scdm_backend_candidates(
*,
manual_path: str | Path | None = None,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> tuple[ScdmBackendInfo, ...]:
env_map = env or os.environ
candidates: list[ScdmBackendInfo] = []
if manual_path:
candidates.extend(_info_for_user_value(manual_path, "manual"))
if include_env:
for env_name in SCDM_PATH_ENV_VARS:
raw_value = env_map.get(env_name, "").strip()
if raw_value:
candidates.extend(_info_for_user_value(raw_value, f"env:{env_name}"))
if include_registry:
candidates.extend(_registry_candidates())
if include_common:
candidates.extend(_common_install_candidates(common_roots=common_roots, env=env_map))
if include_path:
found = shutil.which(SCDM_EXE_NAME)
if found:
candidates.extend(_info_for_user_value(found, "PATH"))
return _dedupe_candidates(candidates)
def resolve_scdm_backend(
*,
project_root_override: str | Path | None = None,
cache_path: str | Path | None = None,
manual_path: str | Path | None = None,
prefer_cache: bool = True,
save_cache: bool = True,
validate: bool = False,
include_env: bool = True,
include_registry: bool = True,
include_common: bool = True,
include_path: bool = True,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
timeout_seconds: float | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
env_map = env or os.environ
if is_scdm_disabled(env_map):
return {"ok": False, "reason": "disabled", "backend": None, "message": "SCDM backend is disabled by environment."}
if prefer_cache:
cached = load_scdm_backend_cache(project_root_override=project_root_override, cache_path=cache_path)
if cached is not None:
if not validate or cached.run_script_ok:
return _resolution_payload(cached, reason="cache", message="Using cached SCDM backend.")
checked = verify_scdm_backend(cached, timeout_seconds=timeout_seconds, runner=runner)
if checked.get("ok"):
verified = _verified_backend_from_result(cached, checked)
if save_cache:
save_scdm_backend_cache(verified, project_root_override=project_root_override, cache_path=cache_path)
return _resolution_payload(verified, reason="cache-verified", message="Cached SCDM backend passed smoke test.")
failures: list[dict[str, object]] = []
candidates = discover_scdm_backend_candidates(
manual_path=manual_path,
include_env=include_env,
include_registry=include_registry,
include_common=include_common,
include_path=include_path,
common_roots=common_roots,
env=env_map,
)
for candidate in candidates:
backend = candidate
if validate:
checked = verify_scdm_backend(candidate, timeout_seconds=timeout_seconds, runner=runner)
if not checked.get("ok"):
failures.append(
{
"path": str(candidate.path),
"source": candidate.source,
"reason": checked.get("reason"),
"message": checked.get("message"),
}
)
continue
backend = _verified_backend_from_result(candidate, checked)
if save_cache:
save_scdm_backend_cache(backend, project_root_override=project_root_override, cache_path=cache_path)
reason = "discovered-verified" if validate else "discovered"
return _resolution_payload(backend, reason=reason, message=f"SCDM backend resolved from {backend.source}.")
return {
"ok": False,
"reason": "missing-spaceclaim",
"backend": None,
"candidates": (),
"failures": tuple(failures),
"message": "SpaceClaim.exe was not found. Ask the user to configure the SCDM path manually.",
}
def verify_scdm_backend(
backend: ScdmBackendInfo | str | Path,
*,
timeout_seconds: float | None = None,
work_dir: str | Path | None = None,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
info = backend
else:
matches = _info_for_user_value(backend, "manual")
if not matches:
return {
"ok": False,
"reason": "missing-exe",
"path": str(Path(str(backend)).expanduser()),
"message": "SpaceClaim.exe does not exist.",
}
info = matches[0]
if not _is_spaceclaim_exe(info.path):
return {"ok": False, "reason": "missing-exe", "path": str(info.path), "message": "SpaceClaim.exe does not exist."}
timeout = timeout_seconds if timeout_seconds is not None else _timeout_seconds()
temp_context = None
if work_dir is None:
temp_context = tempfile.TemporaryDirectory(prefix="step_editor_scdm_")
work_root = Path(temp_context.name)
else:
work_root = Path(work_dir).expanduser()
work_root.mkdir(parents=True, exist_ok=True)
try:
script_path = work_root / "scdm_smoke.py"
report_path = work_root / "scdm_smoke_result.json"
script_path.write_text(_smoke_script(report_path), encoding="utf-8")
command = scdm_run_script_command(info.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(work_root),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout", "path": str(info.path), "message": "SCDM smoke test timed out."}
except OSError as exc:
return {"ok": False, "reason": "launch-failed", "path": str(info.path), "message": str(exc)}
returncode = int(getattr(completed, "returncode", -1))
stdout = str(getattr(completed, "stdout", "") or "")
stderr = str(getattr(completed, "stderr", "") or "")
if returncode != 0:
return {
"ok": False,
"reason": "run-script-failed",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": (stderr or stdout or f"SCDM returned {returncode}.").strip(),
}
if not report_path.is_file():
return {
"ok": False,
"reason": "missing-report",
"path": str(info.path),
"returncode": returncode,
"stdout": stdout,
"stderr": stderr,
"message": "SCDM smoke script finished but did not write a report.",
}
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
return {"ok": False, "reason": "bad-report", "path": str(info.path), "message": str(exc)}
if not isinstance(report, dict) or report.get("ok") is not True:
return {"ok": False, "reason": "negative-report", "path": str(info.path), "message": str(report)}
return {
"ok": True,
"reason": "ok",
"path": str(info.path),
"source": info.source,
"version": str(report.get("version") or info.version or _version_from_path(info.path)),
"verifiedAt": _utc_now(),
"runScriptOk": True,
"licenseOk": True,
"returncode": returncode,
"message": str(report.get("message") or "SCDM /RunScript smoke test passed."),
}
finally:
if temp_context is not None:
temp_context.cleanup()
def scdm_run_script_command(spaceclaim_exe: str | Path, script_path: str | Path) -> list[str]:
exe = Path(spaceclaim_exe).expanduser().resolve(strict=False)
script = Path(script_path).expanduser().resolve(strict=False)
return [
str(exe),
f"/RunScript={script}",
"/Headless=True",
"/ExitAfterScript=True",
]
def _info_for_user_value(value: str | Path, source: str) -> list[ScdmBackendInfo]:
path = _spaceclaim_path_from_value(value)
if path is None:
return []
return [ScdmBackendInfo(path=path, source=source, version=_version_from_path(path))]
def _spaceclaim_path_from_value(value: str | Path) -> Path | None:
text = os.path.expandvars(str(value)).strip().strip('"')
if not text:
return None
path = Path(text).expanduser()
possible = [path]
if path.is_dir():
possible = [
path / SCDM_EXE_NAME,
path / "SCDM" / SCDM_EXE_NAME,
]
for candidate in possible:
if _is_spaceclaim_exe(candidate):
return candidate.resolve(strict=False)
return None
def _is_spaceclaim_exe(path: Path) -> bool:
return path.name.lower() == SCDM_EXE_NAME.lower() and path.is_file()
def _registry_candidates() -> list[ScdmBackendInfo]:
if os.name != "nt" or winreg is None:
return []
candidates: list[ScdmBackendInfo] = []
app_path_keys = (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\SpaceClaim.exe",
r"SOFTWARE\Classes\Applications\SpaceClaim.exe\shell\open\command",
)
roots = ((winreg.HKEY_CURRENT_USER, "HKCU"), (winreg.HKEY_LOCAL_MACHINE, "HKLM"))
views = (0, getattr(winreg, "KEY_WOW64_64KEY", 0), getattr(winreg, "KEY_WOW64_32KEY", 0))
for root, root_label in roots:
for access in views:
for key_path in app_path_keys:
for raw_value in _registry_key_values(root, key_path, access):
for path in _paths_from_registry_value(raw_value):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\{key_path}"))
candidates.extend(_uninstall_registry_candidates(root, root_label, access))
return candidates
def _registry_key_values(root: int, key_path: str, access: int) -> list[str]:
values: list[str] = []
try:
with winreg.OpenKey(root, key_path, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
for name in ("", "Path", "InstallPath", "InstallLocation"):
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
continue
if isinstance(value, str) and value.strip():
values.append(value)
except OSError:
return []
return values
def _uninstall_registry_candidates(root: int, root_label: str, access: int) -> list[ScdmBackendInfo]:
uninstall_key = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
candidates: list[ScdmBackendInfo] = []
try:
with winreg.OpenKey(root, uninstall_key, 0, winreg.KEY_READ | access) as key: # type: ignore[union-attr]
index = 0
while True:
try:
subkey_name = winreg.EnumKey(key, index) # type: ignore[union-attr]
except OSError:
break
index += 1
try:
with winreg.OpenKey(key, subkey_name, 0, winreg.KEY_READ | access) as subkey: # type: ignore[union-attr]
display_name = _registry_string(subkey, "DisplayName")
install_location = _registry_string(subkey, "InstallLocation")
except OSError:
continue
if "spaceclaim" not in display_name.lower() and "ansys" not in display_name.lower():
continue
for path in _paths_from_registry_value(install_location):
candidates.extend(_info_for_user_value(path, f"registry:{root_label}\\Uninstall"))
except OSError:
return []
return candidates
def _registry_string(key: object, name: str) -> str:
try:
value, _value_type = winreg.QueryValueEx(key, name) # type: ignore[union-attr]
except OSError:
return ""
return value if isinstance(value, str) else ""
def _paths_from_registry_value(value: str) -> list[str]:
text = value.strip()
if not text:
return []
exe = _extract_exe_from_command(text)
if exe:
return [exe]
return [
text,
str(Path(text) / SCDM_EXE_NAME),
str(Path(text) / "SCDM" / SCDM_EXE_NAME),
]
def _extract_exe_from_command(command: str) -> str:
text = command.strip()
if not text:
return ""
if text.startswith('"'):
end = text.find('"', 1)
if end > 1:
first = text[1:end]
return first if first.lower().endswith(".exe") else ""
lowered = text.lower()
index = lowered.find(".exe")
if index >= 0:
return text[: index + 4]
return ""
def _common_install_candidates(
*,
common_roots: Iterable[str | Path] | None = None,
env: Mapping[str, str] | None = None,
) -> list[ScdmBackendInfo]:
roots = list(common_roots) if common_roots is not None else _default_common_roots(env or os.environ)
candidates: list[ScdmBackendInfo] = []
for root in roots:
base = Path(os.path.expandvars(str(root))).expanduser()
if not base.is_dir():
continue
direct_paths = (
base / SCDM_EXE_NAME,
base / "SCDM" / SCDM_EXE_NAME,
)
for path in direct_paths:
candidates.extend(_info_for_user_value(path, f"common:{base}"))
version_dirs = sorted((item for item in base.glob("v*") if item.is_dir()), key=_version_sort_key, reverse=True)
for version_dir in version_dirs:
candidates.extend(_info_for_user_value(version_dir / "SCDM" / SCDM_EXE_NAME, f"common:{base}"))
return candidates
def _default_common_roots(env: Mapping[str, str]) -> tuple[Path, ...]:
roots: list[Path] = []
for env_name in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
raw = env.get(env_name, "")
if raw:
roots.append(Path(raw) / "ANSYS Inc")
for drive in ("C", "D", "E"):
roots.append(Path(f"{drive}:/Program Files/ANSYS Inc"))
roots.append(Path(f"{drive}:/softwaresInstallDir/ANSYS Inc"))
return tuple(_dedupe_paths(roots))
def _dedupe_candidates(candidates: Iterable[ScdmBackendInfo]) -> tuple[ScdmBackendInfo, ...]:
result: list[ScdmBackendInfo] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate.path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(candidate)
return tuple(result)
def _dedupe_paths(paths: Iterable[Path]) -> list[Path]:
result: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve(strict=False)).casefold()
if key in seen:
continue
seen.add(key)
result.append(path)
return result
def _version_sort_key(path: Path) -> tuple[int, str]:
match = re.search(r"v(\d+)", path.name, flags=re.IGNORECASE)
return (int(match.group(1)) if match else -1, path.name.lower())
def _version_from_path(path: Path) -> str:
for part in path.parts:
match = re.fullmatch(r"v\d+", part, flags=re.IGNORECASE)
if match:
return part
return ""
def _verified_backend_from_result(candidate: ScdmBackendInfo, result: Mapping[str, object]) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=candidate.path,
source=candidate.source,
version=str(result.get("version") or candidate.version),
verified_at=str(result.get("verifiedAt") or _utc_now()),
run_script_ok=bool(result.get("runScriptOk")),
license_ok=_optional_bool(result.get("licenseOk")),
message=str(result.get("message") or candidate.message),
)
def _resolution_payload(backend: ScdmBackendInfo, *, reason: str, message: str) -> dict[str, object]:
return {
"ok": True,
"reason": reason,
"backend": backend,
"path": str(backend.path),
"source": backend.source,
"version": backend.version,
"verifiedAt": backend.verified_at,
"runScriptOk": backend.run_script_ok,
"licenseOk": backend.license_ok,
"message": message,
}
def _smoke_script(report_path: Path) -> str:
report_literal = repr(str(report_path))
return (
"from __future__ import print_function\n"
f"report_path = {report_literal}\n"
"version = ''\n"
"try:\n"
" version = str(Application.Version)\n"
"except Exception:\n"
" version = ''\n"
"payload = '{\"ok\": true, \"version\": \"' + version.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"') + '\", \"message\": \"RunScript reached\"}'\n"
"handle = open(report_path, 'w')\n"
"handle.write(payload)\n"
"handle.close()\n"
)
def _timeout_seconds() -> float:
try:
return max(float(os.environ.get(SCDM_TIMEOUT_ENV, "") or 25.0), 0.1)
except ValueError:
return 25.0
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _optional_bool(value: object) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
text = value.strip().lower()
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return None
__all__ = [
"SCDM_CACHE_RELATIVE_PATH",
"SCDM_DISABLE_ENV",
"SCDM_EXE_NAME",
"SCDM_PATH_ENV_VARS",
"SCDM_TIMEOUT_ENV",
"ScdmBackendInfo",
"default_scdm_cache_path",
"discover_scdm_backend_candidates",
"is_scdm_disabled",
"load_scdm_backend_cache",
"project_root",
"resolve_scdm_backend",
"save_scdm_backend_cache",
"scdm_run_script_command",
"verify_scdm_backend",
]
+382
View File
@@ -0,0 +1,382 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
@dataclass(frozen=True)
class ScdmCapabilityDefinition:
key: str
display_name: str
object_types: tuple[str, ...]
value_kind: str
current_fields: tuple[str, ...]
default_intent: str
backend_operation: str
post_check: str
required_backend_command_groups: tuple[tuple[str, ...], ...] = ()
productized: bool = True
roadmap_stage: str = "S5"
block_reason: str = ""
def to_payload(self) -> dict[str, object]:
return {
"key": self.key,
"displayName": self.display_name,
"objectTypes": self.object_types,
"valueKind": self.value_kind,
"currentFields": self.current_fields,
"defaultIntent": self.default_intent,
"backendOperation": self.backend_operation,
"postCheck": self.post_check,
"requiredBackendCommandGroups": self.required_backend_command_groups,
"productized": self.productized,
"roadmapStage": self.roadmap_stage,
"blockReason": self.block_reason,
}
# 产品能力字典:SCDM raw 对象只有进入这里,才会被翻译成客户可见的中文参数。
# 新能力要同时补 backend_operation、post_check 和验证脚本,避免只显示不能执行的参数。
CAPABILITY_DEFINITIONS: dict[str, ScdmCapabilityDefinition] = {
"hole.diameter": ScdmCapabilityDefinition(
key="hole.diameter",
display_name="直径",
object_types=("hole", "cylindrical_hole", "cylindrical_face_group"),
value_kind="number",
current_fields=("geometry.diameter", "geometry.radius*2"),
default_intent="修改孔径",
backend_operation="change_hole_diameter",
post_check="target_hole_diameter",
required_backend_command_groups=(("StandardHoles",), ("OffsetFaces",)),
roadmap_stage="S5",
),
"hole.position": ScdmCapabilityDefinition(
key="hole.position",
display_name="位置",
object_types=("hole", "cylindrical_hole", "cylindrical_face_group"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动孔",
backend_operation="move_hole_axis",
post_check="target_hole_axis_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S5",
),
"face.offset": ScdmCapabilityDefinition(
key="face.offset",
display_name="偏移",
object_types=("face", "planar_face"),
value_kind="number",
current_fields=("geometry.offset", "geometry.planeOffset", "0"),
default_intent="推拉平面",
backend_operation="pull_face_offset",
post_check="target_face_offset",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S5",
),
"feature.fill": ScdmCapabilityDefinition(
key="feature.fill",
display_name="填孔/删除小特征",
object_types=("hole", "small_feature"),
value_kind="command",
current_fields=("1",),
default_intent="删除并补面",
backend_operation="fill_feature",
post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)),
roadmap_stage="S5",
),
"slot.width": ScdmCapabilityDefinition(
key="slot.width",
display_name="槽宽",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number",
current_fields=("geometry.width",),
default_intent="修改槽宽",
backend_operation="change_slot_width",
post_check="target_slot_width",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S7.2",
),
"slot.depth": ScdmCapabilityDefinition(
key="slot.depth",
display_name="槽深",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="number",
current_fields=("geometry.slotInfo.depth", "geometry.depth"),
default_intent="修改槽深",
backend_operation="change_slot_depth",
post_check="target_slot_depth",
required_backend_command_groups=(("Move",), ("OffsetFaces",)),
roadmap_stage="S7.2",
),
"slot.position": ScdmCapabilityDefinition(
key="slot.position",
display_name="槽位置",
object_types=("slot", "obround_slot", "rectangular_slot"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动槽",
backend_operation="move_slot",
post_check="target_slot_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.2",
),
"boss.height": ScdmCapabilityDefinition(
key="boss.height",
display_name="凸台高度",
object_types=("boss", "cylindrical_boss", "rectangular_boss"),
value_kind="number",
current_fields=("geometry.height",),
default_intent="修改凸台高度",
backend_operation="change_boss_height",
post_check="target_boss_height",
required_backend_command_groups=(("Move",), ("OffsetFaces",)),
roadmap_stage="S7.3",
),
"boss.diameter": ScdmCapabilityDefinition(
key="boss.diameter",
display_name="凸台直径",
object_types=("boss", "cylindrical_boss"),
value_kind="number",
current_fields=("geometry.diameter", "geometry.radius*2"),
default_intent="修改凸台直径",
backend_operation="change_boss_diameter",
post_check="target_boss_diameter",
required_backend_command_groups=(("OffsetFaces",),),
roadmap_stage="S7.3",
),
"boss.position": ScdmCapabilityDefinition(
key="boss.position",
display_name="凸台位置",
object_types=("boss", "cylindrical_boss", "rectangular_boss"),
value_kind="vector3",
current_fields=("geometry.center", "geometry.axisCenter"),
default_intent="移动凸台",
backend_operation="move_boss",
post_check="target_boss_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.3",
),
"round.radius": ScdmCapabilityDefinition(
key="round.radius",
display_name="圆角半径",
object_types=("round", "fillet"),
value_kind="number",
current_fields=("geometry.roundInfo.radius", "geometry.radius"),
default_intent="修改圆角半径",
backend_operation="change_round_radius",
post_check="target_round_radius",
required_backend_command_groups=(("ConstantRound",),),
roadmap_stage="S7.4",
),
"chamfer.distance": ScdmCapabilityDefinition(
key="chamfer.distance",
display_name="倒角距离",
object_types=("chamfer",),
value_kind="number",
current_fields=("geometry.chamferInfo.distance", "geometry.distance", "geometry.offset"),
default_intent="修改倒角距离",
backend_operation="change_chamfer_distance",
post_check="target_chamfer_distance",
required_backend_command_groups=(("Chamfer",),),
roadmap_stage="S7.4",
),
"feature.delete_round_or_chamfer": ScdmCapabilityDefinition(
key="feature.delete_round_or_chamfer",
display_name="删除圆角/倒角",
object_types=("round", "fillet", "chamfer"),
value_kind="command",
current_fields=("1",),
default_intent="删除圆角/倒角并补面",
backend_operation="delete_round_or_chamfer",
post_check="target_feature_removed",
required_backend_command_groups=(("Fill",), ("Delete",)),
roadmap_stage="S7.4",
),
"pattern.spacing": ScdmCapabilityDefinition(
key="pattern.spacing",
display_name="阵列间距",
object_types=("pattern", "linear_pattern"),
value_kind="number",
current_fields=("geometry.spacing", "geometry.pitch"),
default_intent="修改阵列间距",
backend_operation="change_pattern_spacing",
post_check="target_pattern_spacing",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"pattern.segment_spacing": ScdmCapabilityDefinition(
key="pattern.segment_spacing",
display_name="局部间距",
object_types=("pattern", "linear_pattern"),
value_kind="number",
current_fields=("geometry.spacing", "geometry.pitch"),
default_intent="修改相邻阵列成员间距",
backend_operation="change_pattern_segment_spacing",
post_check="target_pattern_segment_spacing",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"pattern.instance_position": ScdmCapabilityDefinition(
key="pattern.instance_position",
display_name="阵列实例位置",
object_types=("pattern", "linear_pattern"),
value_kind="vector3",
current_fields=("geometry.instanceCenter", "geometry.center"),
default_intent="移动阵列实例",
backend_operation="move_pattern_instance",
post_check="target_pattern_instance_center",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
"shell.thickness": ScdmCapabilityDefinition(
key="shell.thickness",
display_name="壳体厚度",
object_types=("shell", "thin_wall"),
value_kind="number",
current_fields=("geometry.thickness",),
default_intent="固定一侧,移动另一侧",
backend_operation="change_shell_thickness",
post_check="target_shell_thickness",
required_backend_command_groups=(("Move",),),
roadmap_stage="S7.5",
),
}
def capability_definition(key: str) -> ScdmCapabilityDefinition | None:
return CAPABILITY_DEFINITIONS.get(key)
def productized_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
key
for key in capability_keys_for_raw_object(raw_object, include_planned=False)
if (definition := capability_definition(key)) is not None and definition.productized
)
def planned_capability_keys(raw_object: Mapping[str, object]) -> tuple[str, ...]:
return tuple(
key
for key in capability_keys_for_raw_object(raw_object, include_planned=True)
if (definition := capability_definition(key)) is not None and not definition.productized
)
def capability_keys_for_raw_object(raw_object: Mapping[str, object], *, include_planned: bool = False) -> tuple[str, ...]:
object_type = str(raw_object.get("objectType") or "").strip().lower()
geometry = _mapping(raw_object.get("geometry"))
commands = tuple(_command_operations(raw_object.get("backendCommandCandidates")))
keys: list[str] = []
if object_type in {"hole", "cylindrical_hole", "cylindrical_face_group"}:
if _has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius")):
keys.append("hole.diameter")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("hole.position")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.fill")
surface_type = str(geometry.get("surfaceType") or geometry.get("surface") or "").strip().lower()
if object_type in {"face", "planar_face"} and surface_type in {"plane", "planar", ""}:
if _has_command_token(commands, ("pull", "offset", "move_face")) or _has_any(geometry, ("normal", "planeOffset")):
keys.append("face.offset")
if object_type in {"hole", "small_feature"} and _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.fill")
if object_type in {"slot", "obround_slot", "rectangular_slot"}:
if _has_any(geometry, ("width",)) or _has_command_token(commands, ("slot_width", "width")):
keys.append("slot.width")
if _has_any(geometry, ("depth",)) or _has_command_token(commands, ("slot_depth", "depth")):
keys.append("slot.depth")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("slot.position")
if object_type in {"boss", "cylindrical_boss", "rectangular_boss"}:
if _has_any(geometry, ("height",)) or _has_command_token(commands, ("boss_height", "height")):
keys.append("boss.height")
if object_type != "rectangular_boss" and (_has_any(geometry, ("diameter", "radius")) or _has_command_token(commands, ("diameter", "radius"))):
keys.append("boss.diameter")
if _has_any(geometry, ("center", "axisCenter")) or _has_command_token(commands, ("move", "position", "translate")):
keys.append("boss.position")
if object_type in {"round", "fillet"}:
if _has_any(geometry, ("radius",)) or _has_command_token(commands, ("round_radius", "fillet_radius", "radius")):
keys.append("round.radius")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.delete_round_or_chamfer")
if object_type == "chamfer":
if _has_any(geometry, ("distance", "offset")) or _has_command_token(commands, ("chamfer_distance", "distance", "offset")):
keys.append("chamfer.distance")
if _has_command_token(commands, ("fill", "delete", "remove")):
keys.append("feature.delete_round_or_chamfer")
if object_type in {"pattern", "linear_pattern"}:
if _has_any(geometry, ("spacing", "pitch")) or _has_command_token(commands, ("pattern_spacing", "spacing", "pitch")):
keys.append("pattern.spacing")
keys.append("pattern.segment_spacing")
if _has_any(geometry, ("instanceCenter", "center")) or _has_command_token(commands, ("move_instance", "instance_position")):
keys.append("pattern.instance_position")
if object_type in {"shell", "thin_wall"}:
if _has_any(geometry, ("thickness",)) or _has_command_token(commands, ("shell_thickness", "thickness")):
keys.append("shell.thickness")
result = []
for key in keys:
definition = capability_definition(key)
if definition is None:
continue
if definition.productized or include_planned:
result.append(key)
return tuple(dict.fromkeys(result))
def _mapping(value: object) -> Mapping[str, object]:
return value if isinstance(value, Mapping) else {}
def _has_any(mapping: Mapping[str, object], names: tuple[str, ...]) -> bool:
return any(name in mapping and mapping.get(name) is not None for name in names)
def _command_operations(value: object) -> list[str]:
if not isinstance(value, list):
return []
result: list[str] = []
for item in value:
if not isinstance(item, Mapping):
continue
enabled = item.get("enabled")
if enabled is False:
continue
text = " ".join(
str(part or "")
for part in (
item.get("key"),
item.get("operation"),
item.get("command"),
item.get("type"),
)
)
result.append(text.lower())
return result
def _has_command_token(commands: tuple[str, ...], tokens: tuple[str, ...]) -> bool:
return any(token in command for command in commands for token in tokens)
__all__ = [
"CAPABILITY_DEFINITIONS",
"ScdmCapabilityDefinition",
"capability_keys_for_raw_object",
"capability_definition",
"planned_capability_keys",
"productized_capability_keys",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+972
View File
@@ -0,0 +1,972 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Callable
from .scdm_backend import ScdmBackendInfo, resolve_scdm_backend, save_scdm_backend_cache, scdm_run_script_command
from .scdm_schema import ScdmProbeJob, default_scdm_work_dir, file_fingerprint, read_json, utc_now, write_json
def prepare_scdm_probe_job(
step_path: str | Path,
*,
output_dir: str | Path | None = None,
project_root: str | Path | None = None,
backend: ScdmBackendInfo | None = None,
unit: str = "model",
scan_scope: str = "all",
) -> dict[str, object]:
source = Path(step_path).expanduser()
if not source.is_file():
return {"ok": False, "reason": "missing-step", "message": f"STEP file not found: {source}"}
fingerprint = file_fingerprint(source)
work_dir = Path(output_dir).expanduser() if output_dir else default_scdm_work_dir(source, project_root=project_root, fingerprint=fingerprint)
work_dir = work_dir.resolve(strict=False)
work_dir.mkdir(parents=True, exist_ok=True)
job = ScdmProbeJob(
step_path=source.resolve(strict=False),
output_dir=work_dir,
raw_features_path=work_dir / "scdm_raw_features.json",
error_path=work_dir / "error.json",
model_fingerprint=fingerprint,
unit=unit,
scan_scope=scan_scope,
backend_path=str(backend.path) if backend else "",
backend_version=backend.version if backend else "",
)
job_path = work_dir / "scdm_probe_job.json"
script_path = work_dir / "scdm_probe.py"
write_json(job_path, job.to_payload())
script_path.write_text(generate_scdm_probe_script(job_path), encoding="utf-8")
return {
"ok": True,
"reason": "ok",
"work_dir": str(work_dir),
"job_path": str(job_path),
"script_path": str(script_path),
"raw_features_path": str(job.raw_features_path),
"error_path": str(job.error_path),
"model_fingerprint": fingerprint,
}
def run_scdm_probe(
step_path: str | Path,
*,
backend: ScdmBackendInfo | None = None,
output_dir: str | Path | None = None,
project_root: str | Path | None = None,
timeout_seconds: float = 120.0,
runner: Callable[..., subprocess.CompletedProcess[str]] | None = None,
) -> dict[str, object]:
if backend is None:
resolved = resolve_scdm_backend(project_root_override=project_root, validate=False)
if not resolved.get("ok") or not isinstance(resolved.get("backend"), ScdmBackendInfo):
return {
"ok": False,
"reason": str(resolved.get("reason") or "missing-scdm"),
"message": str(resolved.get("message") or "SCDM backend is not available."),
"backend_resolution": {
"ok": bool(resolved.get("ok")),
"reason": str(resolved.get("reason") or ""),
"message": str(resolved.get("message") or ""),
},
}
backend = resolved["backend"] # type: ignore[assignment]
prepared = prepare_scdm_probe_job(step_path, output_dir=output_dir, project_root=project_root, backend=backend)
if not prepared.get("ok"):
return {"backend": backend.to_cache(), **prepared}
script_path = Path(str(prepared["script_path"]))
raw_path = Path(str(prepared["raw_features_path"]))
error_path = Path(str(prepared["error_path"]))
command = scdm_run_script_command(backend.path, script_path)
run = runner or subprocess.run
try:
completed = run(
command,
cwd=str(script_path.parent),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout_seconds), 0.1),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
check=False,
)
except subprocess.TimeoutExpired:
write_json(error_path, {"ok": False, "reason": "timeout", "message": "SCDM probe timed out."})
return {"ok": False, "reason": "timeout", "message": "SCDM probe timed out.", "backend": backend.to_cache(), **prepared}
except OSError as exc:
write_json(error_path, {"ok": False, "reason": "launch-failed", "message": str(exc)})
return {"ok": False, "reason": "launch-failed", "message": str(exc), "backend": backend.to_cache(), **prepared}
returncode = int(getattr(completed, "returncode", -1))
if returncode != 0:
message = (str(getattr(completed, "stderr", "") or "") or str(getattr(completed, "stdout", "") or "")).strip()
write_json(
error_path,
{
"ok": False,
"reason": "probe-failed",
"returncode": returncode,
"message": message,
},
)
return {"ok": False, "reason": "probe-failed", "returncode": returncode, "message": message, "backend": backend.to_cache(), **prepared}
if not raw_path.is_file():
write_json(error_path, {"ok": False, "reason": "missing-raw-output", "message": "SCDM probe did not write raw features."})
return {
"ok": False,
"reason": "missing-raw-output",
"message": "SCDM probe did not write raw features.",
"backend": backend.to_cache(),
**prepared,
}
raw = read_json(raw_path)
verified_backend = _probe_verified_backend(backend)
try:
save_scdm_backend_cache(verified_backend, project_root_override=project_root)
except Exception:
pass
return {"ok": True, "reason": "ok", "raw": raw, "backend": verified_backend.to_cache(), **prepared}
def _probe_verified_backend(backend: ScdmBackendInfo) -> ScdmBackendInfo:
return ScdmBackendInfo(
path=backend.path,
source=backend.source,
version=backend.version,
verified_at=utc_now(),
run_script_ok=True,
license_ok=True,
message="SCDM probe completed.",
)
def generate_scdm_probe_script(job_path: str | Path) -> str:
job_literal = repr(str(Path(job_path).expanduser()))
return (
"from __future__ import print_function\n"
"import json\n"
"import traceback\n"
f"JOB_PATH = {job_literal}\n"
"\n"
"def _write_json(path, payload):\n"
" handle = open(path, 'w')\n"
" try:\n"
" handle.write(json.dumps(payload, indent=2))\n"
" finally:\n"
" handle.close()\n"
"\n"
"def _safe_name(value):\n"
" try:\n"
" return type(value).__name__\n"
" except Exception:\n"
" return ''\n"
"\n"
"def _float_attr(value, names):\n"
" for name in names:\n"
" try:\n"
" result = getattr(value, name)\n"
" return float(result)\n"
" except Exception:\n"
" pass\n"
" return None\n"
"\n"
"def _xyz(value):\n"
" if value is None:\n"
" return []\n"
" result = []\n"
" for name in ('X', 'Y', 'Z'):\n"
" try:\n"
" result.append(float(getattr(value, name)))\n"
" except Exception:\n"
" return []\n"
" return result\n"
"\n"
"def _items(collection):\n"
" if collection is None:\n"
" return []\n"
" try:\n"
" return list(collection)\n"
" except Exception:\n"
" items = []\n"
" try:\n"
" count = int(collection.Count)\n"
" for index in range(count):\n"
" items.append(collection[index])\n"
" except Exception:\n"
" pass\n"
" return items\n"
"\n"
"def _geometry_from_face(face):\n"
" geometry = {}\n"
" surface = None\n"
" for expr in ('Shape.Geometry', 'Geometry', 'Surface'):\n"
" try:\n"
" current = face\n"
" for part in expr.split('.'):\n"
" current = getattr(current, part)\n"
" surface = current\n"
" break\n"
" except Exception:\n"
" pass\n"
" surface_name = _safe_name(surface)\n"
" geometry['surfaceType'] = surface_name\n"
" lowered = surface_name.lower()\n"
" radius = _float_attr(surface, ('Radius', 'radius'))\n"
" if radius is not None:\n"
" geometry['radius'] = radius\n"
" geometry['diameter'] = radius * 2.0\n"
" try:\n"
" geometry['center'] = _xyz(surface.Frame.Origin)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" geometry['axis'] = _xyz(surface.Frame.DirZ)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" center = geometry.get('center') or []\n"
" axis = geometry.get('axis') or []\n"
" if len(center) == 3 and len(axis) == 3:\n"
" geometry['planeOffset'] = center[0] * axis[0] + center[1] * axis[1] + center[2] * axis[2]\n"
" except Exception:\n"
" pass\n"
" if 'plane' in lowered:\n"
" geometry['surfaceType'] = 'plane'\n"
" elif 'cylinder' in lowered:\n"
" geometry['surfaceType'] = 'cylinder'\n"
" slot_info = _slot_info_from_face(face, geometry)\n"
" if slot_info:\n"
" geometry['slotInfo'] = slot_info\n"
" for key in ('width', 'depth', 'center', 'depthAxis'):\n"
" if slot_info.get(key) is not None:\n"
" geometry[key] = slot_info.get(key)\n"
" round_info = _round_info_from_face(face, geometry)\n"
" if round_info:\n"
" geometry['roundInfo'] = round_info\n"
" chamfer_info = _chamfer_info_from_face(face, geometry)\n"
" if chamfer_info:\n"
" geometry['chamferInfo'] = chamfer_info\n"
" return geometry\n"
"\n"
"def _round_info_from_face(face, geometry):\n"
" if str(geometry.get('surfaceType', '')).lower() != 'cylinder':\n"
" return {}\n"
" round_info_type = globals().get('RoundInfo')\n"
" if round_info_type is None:\n"
" return {}\n"
" try:\n"
" info = round_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for attr in ('Radius', 'RoundRadius', 'ConstantRadius'):\n"
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload['radius'] = value\n"
" payload['diameter'] = value * 2.0\n"
" break\n"
" for attr in ('IsConstant', 'IsRound'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _same_object(left, right):\n"
" try:\n"
" if left is right:\n"
" return True\n"
" except Exception:\n"
" pass\n"
" try:\n"
" return left == right\n"
" except Exception:\n"
" return False\n"
"\n"
"def _slot_info_from_face(face, geometry):\n"
" slot_info_type = globals().get('SlotInfo')\n"
" if slot_info_type is None:\n"
" return {}\n"
" try:\n"
" info = slot_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for key, attrs in (\n"
" ('width', ('Width', 'SlotWidth', 'Diameter')),\n"
" ('depth', ('Depth', 'SlotDepth', 'Height')),\n"
" ):\n"
" value = _float_attr(info, attrs)\n"
" if value is not None:\n"
" payload[key] = value\n"
" center = _xyz(_first_path_value(info, ('Center', 'AxisCenter', 'Frame.Origin')))\n"
" if center:\n"
" payload['center'] = center\n"
" depth_axis = _xyz(_first_path_value(info, ('DepthAxis', 'DepthDirection', 'Direction', 'Frame.DirZ')))\n"
" if depth_axis:\n"
" payload['depthAxis'] = depth_axis\n"
" for attr in ('IsBlind', 'IsThrough', 'IsSlot'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" for attr in ('BottomFace', 'DepthFace', 'FloorFace'):\n"
" try:\n"
" if _same_object(getattr(info, attr), face):\n"
" payload['depthFaceIsCurrent'] = True\n"
" except Exception:\n"
" pass\n"
" for attr in ('BottomFaces', 'DepthFaces', 'FloorFaces'):\n"
" try:\n"
" for item in _items(getattr(info, attr)):\n"
" if _same_object(item, face):\n"
" payload['depthFaceIsCurrent'] = True\n"
" except Exception:\n"
" pass\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _chamfer_info_from_face(face, geometry):\n"
" if str(geometry.get('surfaceType', '')).lower() != 'plane':\n"
" return {}\n"
" chamfer_info_type = globals().get('ChamferInfo')\n"
" if chamfer_info_type is None:\n"
" return {}\n"
" try:\n"
" info = chamfer_info_type.Create(face)\n"
" except Exception:\n"
" return {}\n"
" payload = {'available': True, 'type': _safe_name(info)}\n"
" for attr in ('Distance', 'ChamferDistance', 'Offset', 'Width'):\n"
" value = _float_attr(info, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload['distance'] = value\n"
" break\n"
" distance1 = _float_attr(info, ('Distance1', 'distance1', 'FirstDistance'))\n"
" distance2 = _float_attr(info, ('Distance2', 'distance2', 'SecondDistance'))\n"
" if distance1 is not None:\n"
" payload['distance1'] = distance1\n"
" if distance2 is not None:\n"
" payload['distance2'] = distance2\n"
" if distance1 is not None and distance2 is not None and abs(distance1 - distance2) <= max(abs(distance1), abs(distance2), 1.0) * 1e-6:\n"
" payload.setdefault('distance', distance1)\n"
" payload['isEqualDistance'] = True\n"
" for attr in ('IsEqualDistance', 'IsSymmetric', 'IsChamfer'):\n"
" try:\n"
" payload[attr[0].lower() + attr[1:]] = bool(getattr(info, attr))\n"
" except Exception:\n"
" pass\n"
" if payload.get('isSymmetric') is True:\n"
" payload['isEqualDistance'] = True\n"
" try:\n"
" payload['attributes'] = [name for name in dir(info) if not name.startswith('_')][:40]\n"
" except Exception:\n"
" pass\n"
" return payload\n"
"\n"
"def _path_value(value, expr):\n"
" current = value\n"
" for part in expr.split('.'):\n"
" try:\n"
" current = getattr(current, part)\n"
" except Exception:\n"
" return None\n"
" return current\n"
"\n"
"def _first_path_value(value, exprs):\n"
" for expr in exprs:\n"
" result = _path_value(value, expr)\n"
" if result is not None:\n"
" return result\n"
" return None\n"
"\n"
"def _geometry_from_edge(edge):\n"
" geometry = {}\n"
" shape = getattr(edge, 'Shape', edge)\n"
" curve = _first_path_value(edge, ('Shape.Geometry', 'Geometry', 'Shape.Curve', 'Curve', 'Shape')) or shape\n"
" geometry['curveShapeType'] = _safe_name(shape)\n"
" geometry['curveType'] = _safe_name(curve)\n"
" length = _float_attr(edge, ('Length', 'length'))\n"
" if length is None:\n"
" length = _float_attr(shape, ('Length', 'length'))\n"
" if length is not None:\n"
" geometry['length'] = length\n"
" start = _xyz(_first_path_value(edge, ('StartPoint', 'Shape.StartPoint')))\n"
" end = _xyz(_first_path_value(edge, ('EndPoint', 'Shape.EndPoint')))\n"
" if start:\n"
" geometry['startPoint'] = start\n"
" if end:\n"
" geometry['endPoint'] = end\n"
" if len(start) == 3 and len(end) == 3:\n"
" geometry['midPoint'] = [(start[i] + end[i]) * 0.5 for i in range(3)]\n"
" radius = _float_attr(curve, ('Radius', 'radius'))\n"
" if radius is not None:\n"
" geometry['radius'] = radius\n"
" geometry['diameter'] = radius * 2.0\n"
" center = _xyz(_first_path_value(curve, ('Frame.Origin', 'Circle.Frame.Origin')))\n"
" if center:\n"
" geometry['center'] = center\n"
" axis = _xyz(_first_path_value(curve, ('Frame.DirZ', 'Circle.Frame.DirZ')))\n"
" if axis:\n"
" geometry['axis'] = axis\n"
" return geometry\n"
"\n"
"def _edge_adjacent_face_ordinals(edge, face_ordinals_by_marker):\n"
" faces = []\n"
" for expr in ('Faces', 'Shape.Faces', 'GetFaces'):\n"
" value = _path_value(edge, expr)\n"
" if value is None and expr == 'GetFaces':\n"
" value = _maybe_call(edge, 'GetFaces')\n"
" faces = _items(value)\n"
" if faces:\n"
" break\n"
" ordinals = []\n"
" for face in faces:\n"
" marker = str(id(face))\n"
" if marker in face_ordinals_by_marker:\n"
" ordinals.append(face_ordinals_by_marker[marker])\n"
" return {'adjacentFaceCount': len(faces), 'adjacentFaceOrdinals': ordinals}\n"
"\n"
"def _int_or_none(value):\n"
" try:\n"
" return int(value)\n"
" except Exception:\n"
" return None\n"
"\n"
"def _edge_kind(geometry):\n"
" curve_type = str(geometry.get('curveType', '') or geometry.get('curveShapeType', '')).lower()\n"
" if geometry.get('radius') is not None or 'circle' in curve_type or 'arc' in curve_type:\n"
" return 'circular'\n"
" if 'line' in curve_type or 'segment' in curve_type:\n"
" return 'linear'\n"
" return 'other'\n"
"\n"
"def _add_edge_geometry_summary(summary, geometry):\n"
" summary['totalEdgeCount'] = int(summary.get('totalEdgeCount', 0)) + 1\n"
" kind = _edge_kind(geometry)\n"
" kind_counts = summary.setdefault('edgeKindCounts', {})\n"
" kind_counts[kind] = int(kind_counts.get(kind, 0)) + 1\n"
" radius = geometry.get('radius')\n"
" if radius is not None:\n"
" try:\n"
" radius = float(radius)\n"
" summary['circularEdgeCount'] = int(summary.get('circularEdgeCount', 0)) + 1\n"
" values = summary.setdefault('circularRadii', [])\n"
" if len(values) < 80:\n"
" values.append(radius)\n"
" except Exception:\n"
" pass\n"
" length = geometry.get('length')\n"
" if length is not None:\n"
" try:\n"
" length = float(length)\n"
" summary['minEdgeLength'] = min(float(summary.get('minEdgeLength', length)), length)\n"
" summary['maxEdgeLength'] = max(float(summary.get('maxEdgeLength', length)), length)\n"
" except Exception:\n"
" pass\n"
"\n"
"def _final_edge_geometry_summary(summary):\n"
" result = dict(summary)\n"
" radii = result.get('circularRadii')\n"
" if isinstance(radii, list) and radii:\n"
" buckets = {}\n"
" for value in radii:\n"
" try:\n"
" key = '%.6g' % float(value)\n"
" buckets[key] = int(buckets.get(key, 0)) + 1\n"
" except Exception:\n"
" pass\n"
" result['circularRadiusBuckets'] = [\n"
" {'radius': key, 'count': buckets[key]} for key in sorted(buckets.keys())[:40]\n"
" ]\n"
" result.pop('circularRadii', None)\n"
" return result\n"
"\n"
"def _record_face_adjacency(adjacency_map, body_index, edge_topology, geometry):\n"
" ordinals = []\n"
" for value in edge_topology.get('adjacentFaceOrdinals', []) or []:\n"
" number = _int_or_none(value)\n"
" if number is not None and number not in ordinals:\n"
" ordinals.append(number)\n"
" if len(ordinals) < 2:\n"
" return\n"
" ordinals.sort()\n"
" kind = _edge_kind(geometry)\n"
" for left_index in range(len(ordinals)):\n"
" for right_index in range(left_index + 1, len(ordinals)):\n"
" left = ordinals[left_index]\n"
" right = ordinals[right_index]\n"
" key = (body_index, left, right)\n"
" item = adjacency_map.setdefault(\n"
" key,\n"
" {'bodyIndex': body_index, 'faceOrdinals': [left, right], 'edgeCount': 0, 'edgeKinds': {}, 'edges': []},\n"
" )\n"
" item['edgeCount'] = int(item.get('edgeCount', 0)) + 1\n"
" edge_kinds = item.setdefault('edgeKinds', {})\n"
" edge_kinds[kind] = int(edge_kinds.get(kind, 0)) + 1\n"
" edges = item.setdefault('edges', [])\n"
" if len(edges) < 6:\n"
" edges.append({\n"
" 'edgeOrdinal': edge_topology.get('edgeOrdinal'),\n"
" 'globalEdgeOrdinal': edge_topology.get('globalEdgeOrdinal'),\n"
" 'curveType': geometry.get('curveType'),\n"
" 'kind': kind,\n"
" 'length': geometry.get('length'),\n"
" 'radius': geometry.get('radius'),\n"
" })\n"
"\n"
"def _face_adjacency_rows(adjacency_map):\n"
" rows = list(adjacency_map.values())\n"
" rows.sort(key=lambda item: (int(item.get('bodyIndex') or 0), item.get('faceOrdinals') or []))\n"
" return rows\n"
"\n"
"def _count_key(counts, key):\n"
" key = str(key or '').strip() or 'unknown'\n"
" counts[key] = int(counts.get(key, 0)) + 1\n"
"\n"
"def _feature_inventory(objects):\n"
" result = {'objectTypeCounts': {}, 'surfaceTypeCounts': {}, 'curveTypeCounts': {}, 'operationCounts': {}}\n"
" for item in objects:\n"
" if not isinstance(item, dict):\n"
" continue\n"
" _count_key(result['objectTypeCounts'], item.get('objectType'))\n"
" geometry = item.get('geometry')\n"
" if not isinstance(geometry, dict):\n"
" geometry = {}\n"
" if geometry.get('surfaceType') is not None:\n"
" _count_key(result['surfaceTypeCounts'], geometry.get('surfaceType'))\n"
" if geometry.get('curveType') is not None:\n"
" _count_key(result['curveTypeCounts'], geometry.get('curveType'))\n"
" for command in item.get('backendCommandCandidates', []) or []:\n"
" if isinstance(command, dict):\n"
" _count_key(result['operationCounts'], command.get('operation'))\n"
" return result\n"
"\n"
"def _command_candidates(object_type, geometry):\n"
" surface_type = str(geometry.get('surfaceType', '')).lower()\n"
" result = []\n"
" if object_type == 'face' and surface_type == 'plane':\n"
" result.append({'operation': 'pull_face_offset', 'enabled': True, 'parameterFields': {'distance': 0}})\n"
" if object_type in ('face', 'hole') and surface_type == 'cylinder':\n"
" result.append({'operation': 'change_hole_diameter', 'enabled': True, 'parameterFields': {'diameter': geometry.get('diameter')}})\n"
" result.append({'operation': 'move_hole_axis', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" if object_type == 'hole' and surface_type == 'cylinder':\n"
" result.append({'operation': 'fill_feature', 'enabled': True, 'parameterFields': {}})\n"
" if object_type in ('slot', 'obround_slot', 'rectangular_slot'):\n"
" if geometry.get('width') is not None:\n"
" result.append({'operation': 'change_slot_width', 'enabled': True, 'parameterFields': {'width': geometry.get('width')}})\n"
" if geometry.get('depth') is not None:\n"
" result.append({'operation': 'change_slot_depth', 'enabled': True, 'parameterFields': {'depth': geometry.get('depth')}})\n"
" if geometry.get('center') is not None:\n"
" result.append({'operation': 'move_slot', 'enabled': True, 'parameterFields': {'center': geometry.get('center')}})\n"
" round_info = geometry.get('roundInfo')\n"
" if isinstance(round_info, dict) and round_info.get('radius') is not None:\n"
" result.append({'operation': 'change_round_radius', 'enabled': True, 'parameterFields': {'radius': round_info.get('radius')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" chamfer_info = geometry.get('chamferInfo')\n"
" if isinstance(chamfer_info, dict) and chamfer_info.get('distance') is not None:\n"
" result.append({'operation': 'change_chamfer_distance', 'enabled': True, 'parameterFields': {'distance': chamfer_info.get('distance')}})\n"
" result.append({'operation': 'delete_round_or_chamfer', 'enabled': True, 'parameterFields': {}})\n"
" return result\n"
"\n"
"def _open_step(path):\n"
" errors = []\n"
" for opener in ('DocumentOpen.Execute', 'Application.OpenDocument'):\n"
" try:\n"
" current = globals()\n"
" target = None\n"
" for part in opener.split('.'):\n"
" target = current.get(part) if isinstance(current, dict) else getattr(current, part)\n"
" current = target\n"
" target(path)\n"
" return\n"
" except Exception as exc:\n"
" errors.append(str(exc))\n"
" raise Exception('Could not open STEP: ' + '; '.join(errors))\n"
"\n"
"def _root_part():\n"
" try:\n"
" return GetRootPart()\n"
" except Exception:\n"
" pass\n"
" try:\n"
" return Application.ActiveWindow.Document.MainPart\n"
" except Exception:\n"
" return None\n"
"\n"
"def _maybe_call(target, name):\n"
" try:\n"
" value = getattr(target, name)\n"
" except Exception:\n"
" return None\n"
" try:\n"
" return value()\n"
" except Exception:\n"
" return value\n"
"\n"
"def _safe_str(value):\n"
" if value is None:\n"
" return ''\n"
" try:\n"
" return str(value)\n"
" except Exception:\n"
" return _safe_name(value)\n"
"\n"
"def _matrix_payload(matrix):\n"
" if matrix is None:\n"
" return {}\n"
" payload = {'type': _safe_name(matrix), 'text': _safe_str(matrix)}\n"
" translation = _xyz(_path_value(matrix, 'Translation'))\n"
" if translation:\n"
" payload['translation'] = translation\n"
" for attr in ('OffsetX', 'OffsetY', 'OffsetZ'):\n"
" value = _float_attr(matrix, (attr, attr[0].lower() + attr[1:]))\n"
" if value is not None:\n"
" payload[attr] = value\n"
" return payload\n"
"\n"
"def _moniker_text(value):\n"
" try:\n"
" return _safe_str(getattr(value, 'Moniker'))\n"
" except Exception:\n"
" return ''\n"
"\n"
"def _component_name(component):\n"
" for attr in ('Name', 'DisplayName'):\n"
" try:\n"
" text = _safe_str(getattr(component, attr)).strip()\n"
" if text:\n"
" return text\n"
" except Exception:\n"
" pass\n"
" return ''\n"
"\n"
"def _immediate_components(part):\n"
" if part is None:\n"
" return []\n"
" try:\n"
" items = _items(getattr(part, 'Components'))\n"
" if items:\n"
" return items\n"
" except Exception:\n"
" pass\n"
" return []\n"
"\n"
"def _component_content(component):\n"
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
" try:\n"
" value = getattr(component, attr)\n"
" if value is not None:\n"
" return value\n"
" except Exception:\n"
" pass\n"
" return None\n"
"\n"
"def _component_locator(component, component_index, component_path):\n"
" locator = {\n"
" 'backendId': 'component:' + '.'.join(str(item) for item in component_path),\n"
" 'componentIndex': component_index,\n"
" 'componentPath': list(component_path),\n"
" 'componentName': _component_name(component),\n"
" }\n"
" try:\n"
" locator['componentMoniker'] = _moniker_text(component)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" content = getattr(component, 'Content')\n"
" locator['contentMoniker'] = _moniker_text(content)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" template = getattr(component, 'Template')\n"
" locator['templateMoniker'] = _moniker_text(template)\n"
" except Exception:\n"
" pass\n"
" try:\n"
" placement = _matrix_payload(getattr(component, 'Placement'))\n"
" if placement:\n"
" locator['placement'] = placement\n"
" if placement.get('translation'):\n"
" locator['placementTranslation'] = placement.get('translation')\n"
" except Exception:\n"
" pass\n"
" return locator\n"
"\n"
"def _component_entries(root):\n"
" result = []\n"
" queue = [(root, [])]\n"
" while queue:\n"
" part, path = queue.pop(0)\n"
" if part is None or len(path) > 8:\n"
" continue\n"
" for child_index, component in enumerate(_immediate_components(part)):\n"
" component_path = list(path) + [child_index]\n"
" content = _component_content(component)\n"
" entry = {\n"
" 'component': component,\n"
" 'content': content,\n"
" 'locator': _component_locator(component, len(result), component_path),\n"
" }\n"
" result.append(entry)\n"
" if content is not None:\n"
" queue.append((content, component_path))\n"
" return result\n"
"\n"
"def _component_body_locator_map(component_entries):\n"
" result = {}\n"
" for entry in component_entries:\n"
" content = entry.get('content')\n"
" if content is None:\n"
" continue\n"
" for component_body_index, body in enumerate(_items(_maybe_call(content, 'Bodies'))):\n"
" locator = dict(entry.get('locator') or {})\n"
" locator['componentBodyIndex'] = component_body_index\n"
" key = str(id(body))\n"
" result.setdefault(key, []).append(locator)\n"
" try:\n"
" master = getattr(body, 'Master')\n"
" result.setdefault(str(id(master)), []).append(locator)\n"
" except Exception:\n"
" pass\n"
" return result\n"
"\n"
"def _body_locators_for_body(component_body_locators, body, body_index):\n"
" result = [{'bodyIndex': body_index}]\n"
" seen = set(['body:' + str(body_index)])\n"
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
" item = dict(locator)\n"
" item['bodyIndex'] = body_index\n"
" key = str(item.get('componentIndex')) + ':' + '.'.join(str(value) for value in item.get('componentPath', []) or []) + ':' + str(item.get('componentBodyIndex'))\n"
" if key in seen:\n"
" continue\n"
" seen.add(key)\n"
" result.append(item)\n"
" return result\n"
"\n"
"def _component_locators_for_body(component_body_locators, body):\n"
" result = []\n"
" seen = set()\n"
" for locator in component_body_locators.get(str(id(body)), []) or []:\n"
" key = str(locator.get('componentIndex')) + ':' + '.'.join(str(value) for value in locator.get('componentPath', []) or []) + ':' + str(locator.get('componentBodyIndex'))\n"
" if key in seen:\n"
" continue\n"
" seen.add(key)\n"
" result.append(dict(locator))\n"
" return result\n"
"\n"
"def _component_inventory(component_entries):\n"
" result = []\n"
" for entry in component_entries:\n"
" locator = dict(entry.get('locator') or {})\n"
" content = entry.get('content')\n"
" locator['contentBodyCount'] = len(_items(_maybe_call(content, 'Bodies'))) if content is not None else 0\n"
" locator['childComponentCount'] = len(_immediate_components(content)) if content is not None else 0\n"
" result.append(locator)\n"
" return result\n"
"\n"
"def _body_faces(body):\n"
" for name in ('Faces', 'GetFaces'):\n"
" items = _items(_maybe_call(body, name))\n"
" if items:\n"
" return items\n"
" return []\n"
"\n"
"def _body_edges(body):\n"
" for name in ('Edges', 'GetEdges'):\n"
" items = _items(_maybe_call(body, name))\n"
" if items:\n"
" return items\n"
" return []\n"
"\n"
"def _child_parts(part):\n"
" children = []\n"
" for name in ('Components', 'GetAllComponents'):\n"
" for component in _items(_maybe_call(part, name)):\n"
" for attr in ('Content', 'ContentMaster', 'Template', 'Part'):\n"
" try:\n"
" value = getattr(component, attr)\n"
" if value is not None:\n"
" children.append(value)\n"
" break\n"
" except Exception:\n"
" pass\n"
" return children\n"
"\n"
"def _all_bodies(root):\n"
" if root is None:\n"
" return []\n"
" for name in ('GetAllBodies', 'Bodies'):\n"
" items = _items(_maybe_call(root, name))\n"
" if items:\n"
" return items\n"
" bodies = []\n"
" queue = [root]\n"
" seen = set()\n"
" while queue:\n"
" part = queue.pop(0)\n"
" marker = str(id(part))\n"
" if marker in seen:\n"
" continue\n"
" seen.add(marker)\n"
" bodies.extend(_items(_maybe_call(part, 'Bodies')))\n"
" queue.extend(_child_parts(part))\n"
" return bodies\n"
"\n"
"def _hole_face_markers(bodies):\n"
" standard_holes = globals().get('StandardHoles')\n"
" if standard_holes is None:\n"
" return set()\n"
" faces = []\n"
" options = None\n"
" options_cls = globals().get('FindStandardHoleOptions')\n"
" if options_cls is not None:\n"
" try:\n"
" options = options_cls()\n"
" except Exception:\n"
" options = None\n"
" identified = []\n"
" find = getattr(standard_holes, 'Find', None)\n"
" if find is not None:\n"
" for args in ((bodies, options, None), (bodies, options), (options, None), (options,), (None,)):\n"
" try:\n"
" identified = _items(find(*args))\n"
" if identified:\n"
" break\n"
" except Exception:\n"
" pass\n"
" if identified:\n"
" try:\n"
" faces = _items(standard_holes.GetHoleFaces(identified))\n"
" except Exception:\n"
" faces = []\n"
" if not faces:\n"
" for hole in identified:\n"
" try:\n"
" faces.extend(_items(getattr(hole, 'Faces')))\n"
" except Exception:\n"
" pass\n"
" if faces:\n"
" return set(str(id(face)) for face in faces)\n"
" for args in ((bodies,), ()):\n"
" try:\n"
" faces = _items(standard_holes.GetHoleFaces(*args))\n"
" if faces:\n"
" break\n"
" except Exception:\n"
" pass\n"
" return set(str(id(face)) for face in faces)\n"
"\n"
"def _available_commands():\n"
" names = ('StandardHoles', 'OffsetFaces', 'Move', 'Fill', 'Delete', 'Chamfer', 'ConstantRound', 'RoundInfo', 'ChamferInfo', 'SlotInfo')\n"
" result = []\n"
" for name in names:\n"
" result.append({'name': name, 'available': globals().get(name) is not None})\n"
" return result\n"
"\n"
"def main():\n"
" job = json.load(open(JOB_PATH, 'r'))\n"
" model = job.get('model', {})\n"
" outputs = job.get('outputs', {})\n"
" raw_path = outputs.get('rawFeatures')\n"
" error_path = outputs.get('error')\n"
" try:\n"
" _open_step(model.get('path'))\n"
" root = _root_part()\n"
" bodies = _all_bodies(root)\n"
" component_entries = _component_entries(root)\n"
" component_body_locators = _component_body_locator_map(component_entries)\n"
" hole_face_markers = _hole_face_markers(bodies)\n"
" objects = []\n"
" face_adjacency = {}\n"
" edge_geometry_summary = {}\n"
" face_counter = 0\n"
" edge_counter = 0\n"
" for body_index, body in enumerate(bodies):\n"
" body_faces = _body_faces(body)\n"
" body_locators = _body_locators_for_body(component_body_locators, body, body_index)\n"
" component_locators = _component_locators_for_body(component_body_locators, body)\n"
" face_ordinals_by_marker = dict((str(id(face)), index) for index, face in enumerate(body_faces))\n"
" for face_index, face in enumerate(body_faces):\n"
" geometry = _geometry_from_face(face)\n"
" object_type = 'hole' if str(id(face)) in hole_face_markers else 'face'\n"
" if object_type == 'face' and isinstance(geometry.get('slotInfo'), dict) and (geometry.get('depth') is not None or geometry.get('width') is not None):\n"
" object_type = 'slot'\n"
" if object_type == 'face' and isinstance(geometry.get('roundInfo'), dict) and geometry.get('roundInfo', {}).get('radius') is not None:\n"
" object_type = 'round'\n"
" if object_type == 'face' and isinstance(geometry.get('chamferInfo'), dict) and geometry.get('chamferInfo', {}).get('distance') is not None:\n"
" object_type = 'chamfer'\n"
" topology_hint = {'bodyIndex': body_index, 'faceOrdinal': face_index, 'globalFaceOrdinal': face_counter, 'bodyLocators': body_locators}\n"
" if component_locators:\n"
" topology_hint['componentLocators'] = component_locators\n"
" if object_type == 'slot' and isinstance(geometry.get('slotInfo'), dict) and geometry.get('slotInfo', {}).get('depthFaceIsCurrent') is True:\n"
" topology_hint['depthFaceLocators'] = [dict(topology_hint)]\n"
" objects.append({\n"
" 'backendId': 'body:%d/face:%d' % (body_index, face_index),\n"
" 'objectType': object_type,\n"
" 'geometry': geometry,\n"
" 'topologyHint': topology_hint,\n"
" 'backendCommandCandidates': _command_candidates(object_type, geometry),\n"
" 'rawLimitations': [],\n"
" })\n"
" face_counter += 1\n"
" for edge_index, edge in enumerate(_body_edges(body)):\n"
" geometry = _geometry_from_edge(edge)\n"
" edge_topology = {'bodyIndex': body_index, 'edgeOrdinal': edge_index, 'globalEdgeOrdinal': edge_counter, 'bodyLocators': body_locators}\n"
" if component_locators:\n"
" edge_topology['componentLocators'] = component_locators\n"
" edge_topology.update(_edge_adjacent_face_ordinals(edge, face_ordinals_by_marker))\n"
" _add_edge_geometry_summary(edge_geometry_summary, geometry)\n"
" _record_face_adjacency(face_adjacency, body_index, edge_topology, geometry)\n"
" objects.append({\n"
" 'backendId': 'body:%d/edge:%d' % (body_index, edge_index),\n"
" 'objectType': 'edge',\n"
" 'geometry': geometry,\n"
" 'topologyHint': edge_topology,\n"
" 'backendCommandCandidates': [],\n"
" 'rawLimitations': [],\n"
" })\n"
" edge_counter += 1\n"
" payload = {\n"
" 'schemaVersion': 1,\n"
" 'backend': job.get('backend', {}),\n"
" 'model': model,\n"
" 'scan': job.get('scan', {}),\n"
" 'objects': objects,\n"
" 'diagnostics': {\n"
" 'availableCommands': _available_commands(),\n"
" 'faceAdjacency': _face_adjacency_rows(face_adjacency),\n"
" 'edgeGeometrySummary': _final_edge_geometry_summary(edge_geometry_summary),\n"
" 'featureInventory': _feature_inventory(objects),\n"
" 'componentInstances': _component_inventory(component_entries),\n"
" },\n"
" 'summary': {'bodyCount': len(bodies), 'objectCount': len(objects), 'faceCount': face_counter, 'edgeCount': edge_counter, 'holeFaceCount': len(hole_face_markers), 'componentCount': len(component_entries)},\n"
" }\n"
" _write_json(raw_path, payload)\n"
" except Exception as exc:\n"
" _write_json(error_path, {'ok': False, 'reason': 'probe-exception', 'message': str(exc), 'traceback': traceback.format_exc()})\n"
" raise\n"
"\n"
"main()\n"
)
__all__ = [
"generate_scdm_probe_script",
"prepare_scdm_probe_job",
"run_scdm_probe",
]
+819
View File
@@ -0,0 +1,819 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
def property_specs_from_scdm_cache(
cache: Mapping[str, object],
*,
selected_face_ids: Iterable[int] = (),
selected_edge_ids: Iterable[int] = (),
selected_solid_ids: Iterable[int] = (),
execution_ready: bool | Iterable[str] = False,
) -> list[dict[str, object]]:
face_ids = {int(item) for item in selected_face_ids}
edge_ids = {int(item) for item in selected_edge_ids}
solid_ids = {int(item) for item in selected_solid_ids}
if not face_ids and not edge_ids and not solid_ids:
return []
objects = cache.get("objects")
if not isinstance(objects, list):
return []
specs: list[dict[str, object]] = []
for item in objects:
if not isinstance(item, Mapping) or not _object_matches(
item,
face_ids=face_ids,
edge_ids=edge_ids,
solid_ids=solid_ids,
):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if isinstance(capability, Mapping):
if str(capability.get("key") or "") in {"pattern.segment_spacing", "pattern.instance_position"}:
continue
spec = _capability_spec(item, capability, execution_ready=execution_ready)
if spec is not None:
specs.append(spec)
specs.extend(
_pattern_instance_position_specs(
item,
selected_face_ids=face_ids,
selected_solid_ids=solid_ids,
execution_ready=execution_ready,
)
)
specs.extend(_pattern_segment_spacing_specs(item, execution_ready=execution_ready))
return specs
def _object_matches(
raw_object: Mapping[str, object],
*,
face_ids: set[int],
edge_ids: set[int],
solid_ids: set[int],
) -> bool:
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return False
object_faces = set(_int_values(signature.get("faceIds")))
object_faces.update(_int_values(signature.get("supportFaceIds")))
object_edges = set(_int_values(signature.get("edgeIds")))
if (face_ids and object_faces & face_ids) or (edge_ids and object_edges & edge_ids):
return True
if not solid_ids:
return False
object_type = str(raw_object.get("objectType") or "").strip().lower()
if object_type not in {"pattern", "linear_pattern"}:
return False
return bool(_pattern_local_solid_ids(signature) & solid_ids)
def _capability_spec(
raw_object: Mapping[str, object],
capability: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> dict[str, object] | None:
key = str(capability.get("key") or "").strip()
label = str(capability.get("displayName") or key).strip()
if not key or not label:
return None
value_kind = str(capability.get("valueKind") or "number")
current = capability.get("currentValue")
value_type = _value_type(value_kind, key)
signature = raw_object.get("geometrySignature") if isinstance(raw_object.get("geometrySignature"), Mapping) else {}
unit_scale = _unit_scale(signature if isinstance(signature, Mapping) else {})
current_display = _display_value(current, key=key, value_type=value_type, unit_scale=unit_scale)
command_value = value_type == "command"
current_text = "可执行" if command_value else _format_value(current_display, value_type=value_type)
target_text = "执行" if command_value else _format_value(current_display, value_type=value_type)
capability_block = str(capability.get("blockReason") or "").strip()
object_block = str(raw_object.get("blockReason") or "").strip()
block_reason = capability_block or object_block
if not command_value and not _current_value_available(current_display, value_type=value_type):
block_reason = block_reason or f"SCDM 已识别“{label}”,但没有返回可用于编辑的当前值。"
backend_operation = str(capability.get("backendOperation") or "")
post_check = str(capability.get("postCheck") or "")
max_value = _display_max_value(key=key, signature=signature if isinstance(signature, Mapping) else {}, unit_scale=unit_scale)
range_hint = "来源:SCDM 结构化识别结果。执行前仍需生成 edit job,并在结果 STEP 上做 OCCT 校验和目标值回测。"
if key == "pattern.spacing" and max_value is not None:
range_hint = f"该阵列受承载面范围限制,保持阵列中心不变时最大间距约 {max_value:g};超过后会跑出承载面。"
can_execute = bool(_capability_execution_ready(key, execution_ready) and capability.get("editable", True) and not block_reason)
if can_execute:
disabled_tip = ""
enabled_tip = (
f"SCDM 已识别“{label}”可由 {backend_operation or '后端命令'} 修改;"
f"执行后会用 {post_check or '结果回测'} 校验。"
)
elif block_reason:
enabled_tip = ""
disabled_tip = f"SCDM 已识别该对象,但当前能力被阻止:{block_reason}"
else:
enabled_tip = ""
disabled_tip = "SCDM 已识别该参数,但 S5 修改执行器还没有接入;当前只作为后端识别结果缓存,不开放执行。"
return {
"key": f"scdm:{key}",
"label": label,
"current_raw": current_display if current_display is not None else "",
"scdm_current_raw": current if current is not None else "",
"scdm_unit_scale": unit_scale,
"current_text": current_text,
"target_text": target_text,
"editable": True,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": str(capability.get("defaultIntent") or "SCDM"),
"action": "apply_scdm_property_edit",
"value_type": value_type,
"enabled_tip": enabled_tip,
"disabled_tip": disabled_tip,
"range_hint": range_hint,
"min_value": 0.0 if value_type == "positive" else None,
"min_exclusive": True if value_type == "positive" else False,
"max_value": max_value,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": key,
"scdm_backend_operation": backend_operation,
"scdm_post_check": post_check,
"scdm_geometry_signature": signature if isinstance(signature, Mapping) else {},
}
def _unit_scale(signature: Mapping[str, object]) -> float:
try:
value = float(str(signature.get("localUnitScale")).strip())
except (TypeError, ValueError):
return 1.0
return value if value > 0 else 1.0
def _display_value(value: object, *, key: str, value_type: str, unit_scale: float) -> object:
if unit_scale <= 0 or abs(unit_scale - 1.0) <= 1.0e-12 or not _uses_length_units(key, value_type):
return value
if value_type == "vector3":
values = _float_values(value)
if len(values) == 3:
return [item / unit_scale for item in values]
return value
try:
return float(str(value).strip()) / unit_scale
except (TypeError, ValueError):
return value
def _uses_length_units(key: str, value_type: str) -> bool:
if value_type == "vector3":
return True
suffixes = (
".diameter",
".radius",
".offset",
".width",
".depth",
".height",
".distance",
".thickness",
".spacing",
".segment_spacing",
".position",
)
return key.endswith(suffixes)
def _display_max_value(*, key: str, signature: Mapping[str, object], unit_scale: float) -> float | None:
if key != "pattern.spacing":
return None
fit = signature.get("supportPatternFit")
if not isinstance(fit, Mapping):
return None
value = fit.get("maxSpacingLocal")
try:
result = float(str(value).strip())
except (TypeError, ValueError):
backend_value = fit.get("maxSpacing")
try:
return float(str(backend_value).strip()) / unit_scale if unit_scale > 0 else None
except (TypeError, ValueError):
return None
return result if result > 0 else None
def _pattern_segment_spacing_specs(
raw_object: Mapping[str, object],
*,
execution_ready: bool | Iterable[str],
) -> list[dict[str, object]]:
if str(raw_object.get("objectType") or "").strip().lower() != "linear_pattern":
return []
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return []
axis = _unit_vector(_float_values(signature.get("axis")))
if len(axis) != 3:
return []
instances = _sorted_pattern_instances(signature, axis)
if len(instances) < 2:
return []
unit_scale = _unit_scale(signature)
can_execute = bool(_capability_execution_ready("pattern.segment_spacing", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
specs: list[dict[str, object]] = []
for segment_index in range(len(instances) - 1):
# UI 上展示的是相邻实例之间的“段间距”,不是整列统一 spacing。
# 每一段都带自己的移动语义和安全范围,避免“第 1-2 间距”改成整列平移。
left = instances[segment_index]
right = instances[segment_index + 1]
left_label = _segment_instance_label(left, segment_index + 1)
right_label = _segment_instance_label(right, segment_index + 2)
segment_label = f"{left_label}-{right_label}间距"
current = max(0.0, float(right["projection"]) - float(left["projection"]))
if current <= 0:
continue
current_display = current / unit_scale if unit_scale > 0 else current
scope_modes = _segment_scope_modes(
signature,
instances,
segment_index,
current,
current_display,
unit_scale,
left_label=left_label,
right_label=right_label,
segment_label=segment_label,
can_execute=can_execute,
)
default_mode = scope_modes.get("fix_left_move_right", {}) if isinstance(scope_modes, Mapping) else {}
max_display = default_mode.get("max_value")
range_hint = str(default_mode.get("range_hint") or "")
enabled_tip = str(default_mode.get("enabled_tip") or range_hint)
segment_signature = default_mode.get("scdm_geometry_signature")
if not isinstance(segment_signature, Mapping):
segment_signature = _segment_signature(
signature,
segment_index,
current,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side="after",
motion_semantics="fix_left_move_right_group",
)
specs.append(
{
"key": f"scdm:pattern.segment_spacing:{segment_index}",
"label": segment_label,
"current_raw": current_display,
"scdm_current_raw": current,
"scdm_unit_scale": unit_scale,
"current_text": _format_value(current_display, value_type="positive"),
"target_text": _format_value(current_display, value_type="positive"),
"editable": True,
"enabled": can_execute,
"status_text": "可修改" if can_execute else "暂未接入",
"scope_text": "固定前项,移动后侧",
"scope_modes": scope_modes,
"scope_default": "fix_left_move_right",
"action": "apply_scdm_property_edit",
"value_type": "positive",
"enabled_tip": enabled_tip,
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"min_value": 0.0,
"min_exclusive": True,
"max_value": max_display,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": "pattern.segment_spacing",
"scdm_backend_operation": "change_pattern_segment_spacing",
"scdm_post_check": "target_pattern_segment_spacing",
"scdm_geometry_signature": segment_signature,
}
)
return specs
def _pattern_instance_position_specs(
raw_object: Mapping[str, object],
*,
selected_face_ids: set[int],
selected_solid_ids: set[int],
execution_ready: bool | Iterable[str],
) -> list[dict[str, object]]:
if str(raw_object.get("objectType") or "").strip().lower() not in {"pattern", "linear_pattern"}:
return []
signature = raw_object.get("geometrySignature")
if not isinstance(signature, Mapping):
return []
unit_scale = _unit_scale(signature)
can_execute = bool(_capability_execution_ready("pattern.instance_position", execution_ready) and not str(raw_object.get("blockReason") or "").strip())
specs: list[dict[str, object]] = []
instances = _pattern_instances_in_original_order(signature)
for ordinal, instance in enumerate(instances, start=1):
if selected_face_ids and not (set(_int_values(instance.get("faceIds"))) & selected_face_ids):
continue
if selected_solid_ids and not (_instance_local_solid_ids(instance) & selected_solid_ids):
continue
center = _float_values(instance.get("center") or instance.get("instanceCenter"))
if len(center) != 3:
continue
label = _segment_instance_label({"source": instance}, ordinal)
current_display = [value / unit_scale for value in center] if unit_scale > 0 else list(center)
instance_signature = _pattern_instance_signature(signature, instance, unit_scale=unit_scale, label=label)
locatable = _pattern_instance_has_locator(instance_signature)
enabled = bool(can_execute and locatable)
disabled_tip = ""
if not can_execute:
disabled_tip = "SCDM 已识别该阵列实例,但当前修改执行器尚未开放。"
elif not locatable:
disabled_tip = "SCDM 已识别该阵列实例,但缓存里没有可定位的 Face / Body / Component,不能稳定移动。"
range_hint = f"移动阵列实例:只平移 {label},不自动保持整体阵列等距;需要保持间距时请使用“阵列间距”或“局部间距”。"
specs.append(
{
"key": f"scdm:pattern.instance_position:{ordinal - 1}",
"label": f"{label}位置",
"current_raw": current_display,
"scdm_current_raw": center,
"scdm_unit_scale": unit_scale,
"current_text": _format_value(current_display, value_type="vector3"),
"target_text": _format_value(current_display, value_type="vector3"),
"editable": True,
"enabled": enabled,
"status_text": "可修改" if enabled else "暂未接入",
"scope_text": "只移动该实例",
"action": "apply_scdm_property_edit",
"value_type": "vector3",
"enabled_tip": range_hint if enabled else "",
"disabled_tip": disabled_tip,
"range_hint": range_hint,
"min_value": None,
"min_exclusive": False,
"max_value": None,
"scdm_object_id": raw_object.get("objectId"),
"scdm_source_backend_id": raw_object.get("sourceBackendId"),
"scdm_capability_key": "pattern.instance_position",
"scdm_backend_operation": "move_pattern_instance",
"scdm_post_check": "target_pattern_instance_center",
"scdm_geometry_signature": instance_signature,
}
)
return specs
def _pattern_instances_in_original_order(signature: Mapping[str, object]) -> list[Mapping[str, object]]:
value = signature.get("patternInstances")
if not isinstance(value, (list, tuple)):
return []
return [item for item in value if isinstance(item, Mapping)]
def _pattern_instance_signature(
signature: Mapping[str, object],
instance: Mapping[str, object],
*,
unit_scale: float,
label: str,
) -> dict[str, object]:
result = dict(instance)
result["objectType"] = "pattern_instance"
result["displayLabel"] = label
result["patternObjectType"] = signature.get("objectType")
result["patternKind"] = signature.get("patternKind")
result["instanceKind"] = instance.get("instanceKind") or signature.get("instanceKind")
result["axis"] = signature.get("axis")
result["localUnitScale"] = unit_scale
center = _float_values(instance.get("center") or instance.get("instanceCenter"))
if len(center) == 3:
result["center"] = center
result["instanceCenter"] = center
return result
def _pattern_instance_has_locator(signature: Mapping[str, object]) -> bool:
if signature.get("componentLocators") or signature.get("bodyLocators") or signature.get("scdmFaceLocators"):
return True
if _int_values(signature.get("faceOrdinals")) or _int_values(signature.get("globalFaceOrdinals")):
return True
if _int_or_none(signature.get("faceOrdinal")) is not None or _int_or_none(signature.get("globalFaceOrdinal")) is not None:
return True
instance_kind = str(signature.get("instanceKind") or "").strip().lower()
return instance_kind in {"body", "part", "component"} and _int_or_none(signature.get("bodyIndex")) is not None
def _pattern_local_solid_ids(signature: Mapping[str, object]) -> set[int]:
ids = set(_int_values(signature.get("localSolidIds")))
local_solid = _int_or_none(signature.get("localSolidId"))
if local_solid is not None:
ids.add(local_solid)
ids.update(_int_values(signature.get("bodyIndices")))
body_index = _int_or_none(signature.get("bodyIndex"))
if body_index is not None:
ids.add(body_index)
for instance in _pattern_instances_in_original_order(signature):
ids.update(_instance_local_solid_ids(instance))
return ids
def _instance_local_solid_ids(instance: Mapping[str, object]) -> set[int]:
ids = set(_int_values(instance.get("localSolidIds")))
local_solid = _int_or_none(instance.get("localSolidId"))
if local_solid is not None:
ids.add(local_solid)
body_index = _int_or_none(instance.get("bodyIndex"))
if body_index is not None:
ids.add(body_index)
return ids
def _sorted_pattern_instances(signature: Mapping[str, object], axis: list[float]) -> list[dict[str, object]]:
value = signature.get("patternInstances")
if not isinstance(value, (list, tuple)):
return []
result: list[dict[str, object]] = []
for index, item in enumerate(value):
if not isinstance(item, Mapping):
continue
center = _float_values(item.get("center") or item.get("instanceCenter"))
if len(center) != 3:
continue
result.append(
{
"index": index,
"source": item,
"center": center,
"projection": _point_projection(center, axis),
}
)
# 用阵列轴投影排序,比原始 cache 顺序更接近用户看到的左到右/前到后顺序。
result.sort(key=lambda item: float(item["projection"]))
return result
def _segment_max_spacing_display(
signature: Mapping[str, object],
instances: list[dict[str, object]],
segment_index: int,
current_display: float,
unit_scale: float,
*,
moving_side: str = "after",
) -> float | None:
fit = signature.get("supportPatternFit")
if not isinstance(fit, Mapping):
return None
projection_min = _float_or_none(fit.get("supportProjectionMinLocal"))
projection_max = _float_or_none(fit.get("supportProjectionMaxLocal"))
member_span = _float_or_none(fit.get("memberSpanLocal"))
if projection_min is None or projection_max is None or member_span is None or member_span <= 0:
return None
first_projection = float(instances[0]["projection"])
last_projection = float(instances[-1]["projection"])
first_projection_display = first_projection / unit_scale if unit_scale > 0 else first_projection
last_projection_display = last_projection / unit_scale if unit_scale > 0 else last_projection
backward_capacity = first_projection_display - projection_min - (member_span * 0.5)
forward_capacity = projection_max - (member_span * 0.5) - last_projection_display
if moving_side in {"before", "left", "single_left", "only_left"}:
extra = backward_capacity
elif moving_side in {"split", "both", "center"}:
extra = 2.0 * min(backward_capacity, forward_capacity)
else:
extra = forward_capacity
return max(current_display, current_display + max(0.0, extra))
def _segment_scope_modes(
signature: Mapping[str, object],
instances: list[dict[str, object]],
segment_index: int,
current_backend: float,
current_display: float,
unit_scale: float,
*,
left_label: str,
right_label: str,
segment_label: str,
can_execute: bool,
) -> dict[str, dict[str, object]]:
modes: dict[str, dict[str, object]] = {}
# 同一个“间距”参数有多种建模意图:固定哪一侧、是否保持中心。
# 这些模式会直接进入 scdm_edit_job,不能只作为 UI 文案存在。
for key, label, moving_side, semantics, description in (
(
"fix_left_move_right",
"固定前项,移动后侧",
"after",
"fix_left_move_right_group",
f"固定 {left_label},平移 {right_label} 及其右侧所有阵列成员,右侧已有间距保持不变。",
),
(
"fix_right_move_left",
"固定后项,移动前侧",
"before",
"fix_right_move_left_group",
f"固定 {right_label},平移 {left_label} 及其左侧所有阵列成员,左侧已有间距保持不变。",
),
(
"split_keep_center",
"两侧均分,中心不变",
"split",
"split_groups_keep_segment_center",
f"{left_label} 及左侧向前移动一半,{right_label} 及右侧向后移动一半,保持这段间距中心不变。",
),
):
max_display = _segment_max_spacing_display(
signature,
instances,
segment_index,
current_display,
unit_scale,
moving_side=moving_side,
)
mode_signature = _segment_signature(
signature,
segment_index,
current_backend,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side=moving_side,
motion_semantics=semantics,
max_display=max_display,
)
range_hint = f"{label}{description} 对象段:{segment_label},沿阵列方向由 {left_label}{right_label}"
if max_display is not None and max_display > 0:
range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}"
modes[key] = {
"label": label,
"enabled": can_execute,
"enabled_tip": range_hint,
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"max_value": max_display,
"scdm_geometry_signature": mode_signature,
}
for key, label, moving_side, semantics, moved_label, neighbor_warning in (
(
"move_single_left",
"只移动前项",
"single_left",
"move_only_left_instance",
left_label,
"会改变它与左侧相邻成员的距离",
),
(
"move_single_right",
"只移动后项",
"single_right",
"move_only_right_instance",
right_label,
"会改变它与右侧相邻成员的距离",
),
):
max_display = _segment_max_spacing_display(
signature,
instances,
segment_index,
current_display,
unit_scale,
moving_side=moving_side,
)
mode_signature = _segment_signature(
signature,
segment_index,
current_backend,
unit_scale,
left_label=left_label,
right_label=right_label,
moving_side=moving_side,
motion_semantics=semantics,
max_display=max_display,
)
range_hint = (
f"{label}:只平移 {moved_label},把 {left_label}-{right_label} 这段调到目标间距;"
f"{neighbor_warning},不用于保持整列等距。对象段:{segment_label}"
)
if max_display is not None and max_display > 0:
range_hint += f" 当前支撑面约允许该策略最大间距 {max_display:g}"
modes[key] = {
"label": label,
"enabled": can_execute,
"enabled_tip": range_hint if can_execute else "",
"disabled_tip": "" if can_execute else "SCDM 已识别该局部间距,但当前修改执行器尚未开放。",
"range_hint": range_hint,
"max_value": max_display,
"scdm_geometry_signature": mode_signature,
}
return modes
def _segment_signature(
signature: Mapping[str, object],
segment_index: int,
current_backend: float,
unit_scale: float,
*,
left_label: str,
right_label: str,
moving_side: str,
motion_semantics: str,
max_display: object = None,
) -> dict[str, object]:
result = dict(signature)
segment_fit = dict(result.get("supportPatternFit") if isinstance(result.get("supportPatternFit"), Mapping) else {})
max_number = _float_or_none(max_display)
if max_number is not None and max_number > 0:
segment_fit["maxSegmentSpacingLocal"] = max_number
segment_fit["maxSegmentSpacing"] = max_number * unit_scale if unit_scale > 0 else max_number
result["supportPatternFit"] = segment_fit
result["segmentIndex"] = segment_index
result["segmentLabel"] = f"{left_label}-{right_label}"
result["segmentLeftLabel"] = left_label
result["segmentRightLabel"] = right_label
result["segmentSpacing"] = current_backend
result["movingSide"] = moving_side
result["motionSemantics"] = motion_semantics
axis = _unit_vector(_float_values(signature.get("axis")))
instances = _sorted_pattern_instances(signature, axis) if len(axis) == 3 else []
if segment_index < len(instances) - 1:
result["segmentLeft"] = _segment_instance_reference(instances[segment_index], label=left_label)
result["segmentRight"] = _segment_instance_reference(instances[segment_index + 1], label=right_label)
result["localUnitScale"] = unit_scale
return result
def _segment_instance_reference(item: Mapping[str, object], *, label: str = "") -> dict[str, object]:
source = item.get("source")
if not isinstance(source, Mapping):
return {}
return {
"displayLabel": label,
"sourceObjectId": source.get("sourceObjectId"),
"faceIds": _int_values(source.get("faceIds")),
"bodyIndex": _int_or_none(source.get("bodyIndex")),
"componentLocators": source.get("componentLocators") or source.get("bodyLocators") or [],
}
def _segment_instance_label(item: Mapping[str, object], ordinal: int) -> str:
source = item.get("source")
if not isinstance(source, Mapping):
return f"阵列成员{ordinal}"
instance_kind = str(source.get("instanceKind") or "").strip().lower()
if instance_kind in {"body", "part", "component"}:
local_solid_ids = sorted(set(_int_values(source.get("localSolidIds") or [source.get("localSolidId")])))
if local_solid_ids:
return f"Solid{local_solid_ids[0]}{'' if len(local_solid_ids) > 1 else ''}"
local_part_ids = sorted(set(_int_values(source.get("localPartIds") or [source.get("localPartId")])))
if local_part_ids:
return f"Part{local_part_ids[0]}{'' if len(local_part_ids) > 1 else ''}"
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"), include_index=False)
if component_label:
return component_label
body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None:
return f"Solid{body_index}"
face_ids = sorted(set(_int_values(source.get("faceIds"))))
if face_ids:
return f"Face{face_ids[0]}{'' if len(face_ids) > 1 else ''}"
component_label = _component_locator_label(source.get("componentLocators") or source.get("bodyLocators"))
if component_label:
return component_label
body_index = _int_or_none(source.get("bodyIndex"))
if body_index is not None:
return f"Solid{body_index}"
source_id = str(source.get("sourceObjectId") or "").strip()
if source_id:
return source_id
return f"阵列成员{ordinal}"
def _component_locator_label(value: object, *, include_index: bool = True) -> str:
if not isinstance(value, (list, tuple)):
return ""
for locator in value:
if not isinstance(locator, Mapping):
continue
for key in ("componentName", "name", "displayName"):
text = str(locator.get(key) or "").strip()
if text:
return text
if not include_index:
return ""
for locator in value:
if not isinstance(locator, Mapping):
continue
component_index = _int_or_none(locator.get("componentIndex"))
if component_index is not None:
return f"组件{component_index + 1}"
return ""
def _unit_vector(values: list[float]) -> list[float]:
if len(values) != 3:
return []
length = sum(item * item for item in values) ** 0.5
if length <= 1.0e-12:
return []
return [item / length for item in values]
def _point_projection(point: list[float], axis: list[float]) -> float:
return sum(float(point[index]) * float(axis[index]) for index in range(3))
def _float_or_none(value: object) -> float | None:
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _value_type(value_kind: str, key: str) -> str:
if value_kind == "vector3":
return "vector3"
if value_kind == "command":
return "command"
positive_suffixes = (".diameter", ".radius", ".width", ".depth", ".height", ".distance", ".thickness", ".spacing", ".segment_spacing")
if key.endswith(positive_suffixes):
return "positive"
return "number"
def _capability_execution_ready(key: str, execution_ready: bool | Iterable[str]) -> bool:
if isinstance(execution_ready, bool):
return execution_ready
try:
return key in {str(item) for item in execution_ready}
except TypeError:
return False
def _format_value(value: object, *, value_type: str) -> str:
if value is None:
return ""
if value_type == "vector3":
values = _float_values(value)
return f"({values[0]:g}, {values[1]:g}, {values[2]:g})" if len(values) == 3 else ""
if isinstance(value, float):
return f"{value:g}"
return str(value)
def _current_value_available(value: object, *, value_type: str) -> bool:
if value is None or value == "":
return False
if value_type == "vector3":
return len(_float_values(value)) == 3
if value_type in {"number", "positive"}:
try:
return float(str(value).strip()) > 0 if value_type == "positive" else True
except (TypeError, ValueError):
return False
return True
def _float_values(value: object) -> list[float]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[float] = []
for item in values[:3]:
try:
result.append(float(item))
except (TypeError, ValueError):
return []
return result
def _int_values(value: object) -> list[int]:
if isinstance(value, (str, bytes)) or value is None:
return []
try:
values = list(value) # type: ignore[arg-type]
except TypeError:
return []
result: list[int] = []
for item in values:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
__all__ = ["property_specs_from_scdm_cache"]
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
SCDM_RAW_SCHEMA_VERSION = 1
SCDM_CACHE_SCHEMA_VERSION = 1
@dataclass(frozen=True)
class ScdmProbeJob:
step_path: Path
output_dir: Path
raw_features_path: Path
error_path: Path
model_fingerprint: str
unit: str = "model"
scan_scope: str = "all"
adapter: str = "spaceclaim-v1"
backend_path: str = ""
backend_version: str = ""
created_at: str = ""
def to_payload(self) -> dict[str, object]:
return {
"schemaVersion": SCDM_RAW_SCHEMA_VERSION,
"adapter": self.adapter,
"createdAt": self.created_at or utc_now(),
"backend": {
"name": "SCDM",
"path": self.backend_path,
"version": self.backend_version,
},
"model": {
"path": str(self.step_path),
"fingerprint": self.model_fingerprint,
"unit": self.unit,
},
"scan": {
"scope": self.scan_scope,
},
"outputs": {
"rawFeatures": str(self.raw_features_path),
"error": str(self.error_path),
},
}
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def file_fingerprint(path: str | Path) -> str:
source = Path(path)
digest = hashlib.sha256()
stat = source.stat()
digest.update(str(source.resolve(strict=False)).encode("utf-8", errors="replace"))
digest.update(str(stat.st_size).encode("ascii"))
digest.update(str(stat.st_mtime_ns).encode("ascii"))
with source.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def read_json(path: str | Path) -> dict[str, object]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"JSON payload must be an object: {path}")
return payload
def write_json(path: str | Path, payload: Mapping[str, object]) -> Path:
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(dict(payload), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return target
def default_scdm_work_dir(
step_path: str | Path,
*,
project_root: str | Path | None = None,
fingerprint: str | None = None,
) -> Path:
root = Path(project_root).expanduser() if project_root else Path(__file__).resolve().parent.parent
source = Path(step_path)
short = (fingerprint or file_fingerprint(source))[:12]
return root / "local" / "scdm" / f"{source.stem}_{short}"
def payload_model_fingerprint(payload: Mapping[str, object]) -> str:
model = payload.get("model")
if not isinstance(model, Mapping):
return ""
return str(model.get("fingerprint") or "")
def payload_backend_version(payload: Mapping[str, object]) -> str:
backend = payload.get("backend")
if not isinstance(backend, Mapping):
return ""
return str(backend.get("version") or "")
__all__ = [
"SCDM_CACHE_SCHEMA_VERSION",
"SCDM_RAW_SCHEMA_VERSION",
"ScdmProbeJob",
"default_scdm_work_dir",
"file_fingerprint",
"payload_backend_version",
"payload_model_fingerprint",
"read_json",
"utc_now",
"write_json",
]
+570
View File
@@ -0,0 +1,570 @@
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
from .scdm_backend import ScdmBackendInfo, is_scdm_disabled, load_scdm_backend_cache
from .scdm_capabilities import CAPABILITY_DEFINITIONS, ScdmCapabilityDefinition
def cached_scdm_backend_payload(project_root: str | Path | None = None) -> dict[str, object] | None:
if is_scdm_disabled():
return {"disabled": True, "reason": "disabled", "message": "SCDM backend is disabled by environment."}
backend = load_scdm_backend_cache(project_root_override=project_root)
return backend.to_cache() if backend is not None else None
def summarize_scdm_runtime(
*,
backend: ScdmBackendInfo | Mapping[str, object] | None = None,
cache_state: str = "",
cache_message: str = "",
feature_cache: Mapping[str, object] | None = None,
) -> dict[str, object]:
backend_payload = _backend_payload(backend)
disabled = _backend_disabled(backend)
state = str(cache_state or "empty").strip().lower()
message = _compact(str(cache_message or "").strip(), 120)
count = _feature_cache_counts(feature_cache)
if disabled:
headline = "SCDM:已关闭,当前使用 OCCT/Analysis Situs 兜底"
path = ""
elif backend_payload:
version = str(backend_payload.get("version") or "").strip()
source = _source_label(str(backend_payload.get("source") or "").strip())
version_text = f" {version}" if version else ""
headline = f"SCDM:已配置{version_text}{source}"
path = str(backend_payload.get("path") or "").strip()
else:
headline = "SCDM:未配置,当前可用 OCCT/Analysis Situs 兜底"
path = ""
if disabled:
detail = "检测到 SCDM 禁用开关;本次不会启动 SpaceClaim.exe。"
elif state == "running":
detail = "正在后台识别可修改参数;界面可继续旋转查看模型。"
elif state == "ready":
object_text = f"{count['objects']} 个对象" if count["objects"] else "0 个对象"
capability_text = f"{count['capabilities']} 项能力" if count["capabilities"] else "0 项能力"
detail = f"识别缓存已就绪:{object_text}{capability_text}"
elif state == "failed":
reason = message or "未拿到 SCDM 识别结果"
detail = f"识别未启用:{reason};当前使用本软件已有能力。"
elif state == "deferred":
detail = message or "已延后 SCDM 全量识别,优先保证导入显示、旋转和点选流畅。"
elif state == "stale":
detail = message or "缓存已失效,用户选择对象后会按需重新识别。"
else:
detail = "导入 STEP 后先显示模型;用户选择对象后再按需启动 SCDM 识别。"
tooltip_lines = [headline, detail]
if path:
tooltip_lines.append(f"路径:{path}")
if backend_payload:
run_script_ok = backend_payload.get("runScriptOk")
license_ok = backend_payload.get("licenseOk")
tooltip_lines.append(f"/RunScript{_ok_text(run_script_ok)}")
tooltip_lines.append(f"许可证:{_ok_text(license_ok)}")
return {
"headline": headline,
"detail": detail,
"tooltip": "\n".join(line for line in tooltip_lines if line),
"backendReady": bool(backend_payload) and not disabled,
"cacheState": state,
"objectCount": count["objects"],
"capabilityCount": count["capabilities"],
}
def summarize_scdm_capability_progress(
*,
feature_cache: Mapping[str, object] | None = None,
execution_ready: bool | set[str] | list[str] | tuple[str, ...] = False,
) -> dict[str, object]:
ready_keys = _execution_ready_keys(execution_ready)
# 这里把“识别到”“计划中”“几何 hint”和“可执行”分开统计。
# 客户界面只展示能稳定解释的进度,不能把 SCDM/raw hint 直接包装成可改参数。
detection_counts = _cache_capability_counts(feature_cache)
blocked_counts = _cache_blocked_capability_counts(feature_cache)
planned_counts = _planned_capability_counts(feature_cache)
hint_counts = _geometry_candidate_hint_counts(feature_cache)
discovered_summary = _discovered_not_productized_summary(feature_cache)
probe_evidence = _probe_evidence_summary(feature_cache)
rows: list[dict[str, object]] = []
for key, definition in sorted(CAPABILITY_DEFINITIONS.items(), key=lambda item: (_stage_sort_key(item[1].roadmap_stage), item[0])):
detected = int(detection_counts.get(key, 0))
blocked = int(blocked_counts.get(key, 0))
planned_detected = int(planned_counts.get(key, 0))
hint_detected = int(hint_counts.get(key, 0))
runner_ready = _capability_runner_ready(key, execution_ready, ready_keys)
status, reason = _capability_progress_status(
key,
definition,
detected=detected,
blocked=blocked,
planned_detected=planned_detected,
hint_detected=hint_detected,
runner_ready=runner_ready,
)
executable = detected if definition.productized and runner_ready else 0
if blocked:
executable = max(0, executable - blocked)
rows.append(
{
"key": key,
"displayName": definition.display_name,
"roadmapStage": definition.roadmap_stage,
"productized": definition.productized,
"runnerReady": runner_ready,
"detectedCount": detected,
"plannedDetectedCount": planned_detected,
"hintDetectedCount": hint_detected,
"blockedCount": blocked,
"executableCount": executable,
"status": status,
"reason": reason,
}
)
executable_count = sum(int(row["executableCount"]) for row in rows)
productized_count = sum(1 for row in rows if bool(row["productized"]))
runner_ready_count = sum(1 for row in rows if bool(row["productized"]) and bool(row["runnerReady"]))
planned_detected_total = sum(int(row["plannedDetectedCount"]) for row in rows)
hint_detected_total = sum(int(row["hintDetectedCount"]) for row in rows)
blocked_total = sum(int(row["blockedCount"]) for row in rows)
return {
"rows": rows,
"summary": {
"defined": len(rows),
"productized": productized_count,
"runnerReady": runner_ready_count,
"detectedCapabilities": sum(int(row["detectedCount"]) for row in rows),
"executableCapabilities": executable_count,
"plannedDetected": planned_detected_total,
"geometryHints": hint_detected_total,
"backendBlocked": blocked_total,
"discoveredNotProductized": discovered_summary["count"],
"faceAdjacency": probe_evidence["faceAdjacency"],
"circularEdges": probe_evidence["circularEdges"],
"inventoryObjectTypes": probe_evidence["inventoryObjectTypes"],
"inventoryOperationCandidates": probe_evidence["inventoryOperationCandidates"],
"derivedFeatureCandidates": probe_evidence["derivedFeatureCandidates"],
},
"productizedLines": _capability_progress_lines(
row for row in rows if bool(row["productized"])
),
"plannedLines": _capability_progress_lines(
row
for row in rows
if not bool(row["productized"])
and (
int(row["plannedDetectedCount"]) > 0
or int(row["detectedCount"]) > 0
or int(row["hintDetectedCount"]) > 0
)
),
"roadmapLines": _capability_progress_lines(
row
for row in rows
if not bool(row["productized"])
and int(row["plannedDetectedCount"]) <= 0
and int(row["detectedCount"]) <= 0
and int(row["hintDetectedCount"]) <= 0
),
"discoveredNotProductized": discovered_summary,
"probeEvidence": probe_evidence,
}
def _backend_payload(backend: ScdmBackendInfo | Mapping[str, object] | None) -> dict[str, object]:
if isinstance(backend, ScdmBackendInfo):
return backend.to_cache()
if not isinstance(backend, Mapping):
return {}
nested = backend.get("backend")
if isinstance(nested, ScdmBackendInfo):
return nested.to_cache()
if isinstance(nested, Mapping):
return _backend_payload(nested)
path = str(backend.get("path") or "").strip()
if not path:
return {}
return {
"path": path,
"source": str(backend.get("source") or ""),
"version": str(backend.get("version") or ""),
"verifiedAt": str(backend.get("verifiedAt") or ""),
"runScriptOk": backend.get("runScriptOk"),
"licenseOk": backend.get("licenseOk"),
"message": str(backend.get("message") or ""),
}
def _backend_disabled(backend: ScdmBackendInfo | Mapping[str, object] | None) -> bool:
return isinstance(backend, Mapping) and bool(backend.get("disabled"))
def _feature_cache_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
if not isinstance(feature_cache, Mapping):
return {"objects": 0, "capabilities": 0}
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return {"objects": 0, "capabilities": 0}
capability_count = 0
object_count = 0
for item in objects:
if not isinstance(item, Mapping):
continue
object_count += 1
capabilities = item.get("capabilities")
if isinstance(capabilities, list):
capability_count += sum(1 for capability in capabilities if isinstance(capability, Mapping))
return {"objects": object_count, "capabilities": capability_count}
def _cache_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
result: dict[str, int] = {}
if not isinstance(feature_cache, Mapping):
return result
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return result
for item in objects:
if not isinstance(item, Mapping):
continue
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if not isinstance(capability, Mapping):
continue
key = str(capability.get("key") or "").strip()
if key:
result[key] = result.get(key, 0) + 1
return result
def _cache_blocked_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
result: dict[str, int] = {}
if not isinstance(feature_cache, Mapping):
return result
objects = feature_cache.get("objects")
if not isinstance(objects, list):
return result
for item in objects:
if not isinstance(item, Mapping):
continue
object_block = str(item.get("blockReason") or "").strip()
capabilities = item.get("capabilities")
if not isinstance(capabilities, list):
continue
for capability in capabilities:
if not isinstance(capability, Mapping):
continue
key = str(capability.get("key") or "").strip()
if not key:
continue
capability_block = str(capability.get("blockReason") or "").strip()
if object_block or capability_block or capability.get("editable") is False:
result[key] = result.get(key, 0) + 1
return result
def _planned_capability_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
diagnostics = _cache_diagnostics(feature_cache)
planned = diagnostics.get("planned_not_productized")
result: dict[str, int] = {}
if not isinstance(planned, list):
return result
for item in planned:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if key:
result[key] = result.get(key, 0) + 1
return result
def _geometry_candidate_hint_counts(feature_cache: Mapping[str, object] | None) -> dict[str, int]:
diagnostics = _cache_diagnostics(feature_cache)
hints = diagnostics.get("geometry_candidate_hints")
result: dict[str, int] = {}
if not isinstance(hints, list):
return result
for item in hints:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if not key:
continue
count = _int_value(item.get("evidenceCount"))
result[key] = result.get(key, 0) + max(count, 1)
return result
def _discovered_not_productized_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]:
diagnostics = _cache_diagnostics(feature_cache)
discovered = diagnostics.get("discovered_not_productized")
by_type: dict[str, int] = {}
if not isinstance(discovered, list):
return {"count": 0, "byObjectType": {}, "lines": []}
for item in discovered:
if not isinstance(item, Mapping):
continue
object_type = str(item.get("objectType") or "object").strip() or "object"
by_type[object_type] = by_type.get(object_type, 0) + 1
lines = [f"{name}{count}" for name, count in sorted(by_type.items(), key=lambda item: (-item[1], item[0]))[:6]]
return {"count": sum(by_type.values()), "byObjectType": by_type, "lines": lines}
def _probe_evidence_summary(feature_cache: Mapping[str, object] | None) -> dict[str, object]:
diagnostics = _cache_diagnostics(feature_cache)
face_adjacency = diagnostics.get("face_adjacency")
edge_summary = diagnostics.get("edge_geometry_summary")
feature_inventory = diagnostics.get("feature_inventory")
adjacency_count = len(face_adjacency) if isinstance(face_adjacency, list) else 0
edge_summary = edge_summary if isinstance(edge_summary, Mapping) else {}
feature_inventory = feature_inventory if isinstance(feature_inventory, Mapping) else {}
edge_kind_counts = edge_summary.get("edgeKindCounts")
edge_kind_counts = edge_kind_counts if isinstance(edge_kind_counts, Mapping) else {}
object_type_counts = _mapping_count_dict(feature_inventory.get("objectTypeCounts"))
surface_type_counts = _mapping_count_dict(feature_inventory.get("surfaceTypeCounts"))
operation_counts = _mapping_count_dict(feature_inventory.get("operationCounts"))
geometry_hints = diagnostics.get("geometry_candidate_hints")
geometry_hint_lines = _geometry_candidate_hint_lines(geometry_hints)
derived_candidates = diagnostics.get("derived_feature_candidates")
derived_candidate_lines = _derived_feature_candidate_lines(derived_candidates)
derived_candidate_count = len(derived_candidates) if isinstance(derived_candidates, list) else 0
circular_edges = _int_value(edge_summary.get("circularEdgeCount"))
if circular_edges <= 0:
circular_edges = _int_value(edge_kind_counts.get("circular"))
linear_edges = _int_value(edge_kind_counts.get("linear"))
total_edges = _int_value(edge_summary.get("totalEdgeCount"))
radius_buckets = edge_summary.get("circularRadiusBuckets")
radius_bucket_count = len(radius_buckets) if isinstance(radius_buckets, list) else 0
lines = []
if adjacency_count:
lines.append(f"Face 邻接 {adjacency_count}")
if total_edges:
lines.append(f"Edge {total_edges}")
if circular_edges:
lines.append(f"圆边 {circular_edges}")
if linear_edges:
lines.append(f"直边 {linear_edges}")
if radius_bucket_count:
lines.append(f"圆边半径分组 {radius_bucket_count}")
object_lines = _count_summary_lines(object_type_counts, label="对象")
surface_lines = _count_summary_lines(surface_type_counts, label="曲面")
operation_lines = _count_summary_lines(operation_counts, label="命令候选")
lines.extend(object_lines[:2])
lines.extend(surface_lines[:2])
lines.extend(operation_lines[:2])
lines.extend(derived_candidate_lines[:3])
lines.extend(geometry_hint_lines[:4])
return {
"faceAdjacency": adjacency_count,
"totalEdges": total_edges,
"circularEdges": circular_edges,
"linearEdges": linear_edges,
"radiusBucketCount": radius_bucket_count,
"inventoryObjectTypes": sum(object_type_counts.values()),
"inventorySurfaceTypes": sum(surface_type_counts.values()),
"inventoryOperationCandidates": sum(operation_counts.values()),
"derivedFeatureCandidates": derived_candidate_count,
"derivedFeatureCandidateLines": derived_candidate_lines,
"objectTypeCounts": object_type_counts,
"surfaceTypeCounts": surface_type_counts,
"operationCounts": operation_counts,
"geometryHintLines": geometry_hint_lines,
"lines": lines,
}
def _derived_feature_candidate_lines(value: object, *, limit: int = 4) -> list[str]:
if not isinstance(value, list):
return []
counts: dict[str, int] = {}
for item in value:
if not isinstance(item, Mapping):
continue
object_type = str(item.get("objectType") or "object").strip() or "object"
counts[object_type] = counts.get(object_type, 0) + 1
rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))]
return [f"派生候选 {name}:{count}" for name, count in rows]
def _geometry_candidate_hint_lines(value: object, *, limit: int = 4) -> list[str]:
if not isinstance(value, list):
return []
best: dict[str, dict[str, object]] = {}
for item in value:
if not isinstance(item, Mapping):
continue
key = str(item.get("capabilityKey") or "").strip()
if not key:
continue
count = max(_int_value(item.get("evidenceCount")), 1)
existing = best.get(key)
if existing is None or count > int(existing.get("evidenceCount") or 0):
best[key] = {
"displayName": str(item.get("displayName") or key),
"evidenceCount": count,
"confidence": str(item.get("confidence") or ""),
}
rows = sorted(best.items(), key=lambda item: (-int(item[1].get("evidenceCount") or 0), item[0]))[: max(1, int(limit))]
return [
f"几何候选 {payload['displayName']}:{payload['evidenceCount']}{payload['confidence'] or 'unknown'}"
for _key, payload in rows
]
def _mapping_count_dict(value: object) -> dict[str, int]:
if not isinstance(value, Mapping):
return {}
result: dict[str, int] = {}
for key, count in value.items():
text = str(key or "").strip() or "unknown"
number = _int_value(count)
if number > 0:
result[text] = number
return result
def _count_summary_lines(counts: Mapping[str, int], *, label: str, limit: int = 4) -> list[str]:
if not counts:
return []
rows = sorted(counts.items(), key=lambda item: (-int(item[1]), item[0]))[: max(1, int(limit))]
summary = "".join(f"{name}:{count}" for name, count in rows)
return [f"{label}分布 {summary}"]
def _int_value(value: object) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _cache_diagnostics(feature_cache: Mapping[str, object] | None) -> Mapping[str, object]:
if not isinstance(feature_cache, Mapping):
return {}
diagnostics = feature_cache.get("diagnostics")
return diagnostics if isinstance(diagnostics, Mapping) else {}
def _execution_ready_keys(execution_ready: bool | set[str] | list[str] | tuple[str, ...]) -> set[str]:
if isinstance(execution_ready, bool):
return set()
try:
return {str(item) for item in execution_ready}
except TypeError:
return set()
def _capability_runner_ready(
key: str,
execution_ready: bool | set[str] | list[str] | tuple[str, ...],
ready_keys: set[str],
) -> bool:
return bool(execution_ready) if isinstance(execution_ready, bool) else key in ready_keys
def _capability_progress_status(
key: str,
definition: ScdmCapabilityDefinition,
*,
detected: int,
blocked: int,
planned_detected: int,
hint_detected: int,
runner_ready: bool,
) -> tuple[str, str]:
if definition.productized and runner_ready and detected > blocked:
return "已开放", "已识别到对象时会显示在特征参数表。"
if definition.productized and runner_ready and blocked:
return "已开放但被后端阻止", "当前模型里识别到该能力,但 SCDM 命令、对象状态或安全守门暂时阻止执行。"
if definition.productized and runner_ready:
return "已开放待识别", "执行链路已接入,当前 cache 还没有识别到可执行对象。"
if definition.productized and detected:
return "已识别待执行器", "能力已进入产品字典,但当前 UI 执行器还未开放。"
if definition.productized:
return "已产品化待对象", "能力已定义,等待 SCDM 在当前模型中识别到对象。"
if planned_detected or detected:
return "已识别待验证", definition.block_reason or "已识别到候选,但还没有完成真实 STEP 回测。"
if hint_detected:
return "几何证据待分类", "SCDM probe 已看到相关曲面/边/命令线索,但还没有确认成可执行特征对象。"
return "路线中待接入", definition.block_reason or f"{key} 还没有接入可执行闭环。"
def _capability_progress_lines(rows: object) -> list[str]:
result: list[str] = []
for row in rows: # type: ignore[assignment]
if not isinstance(row, Mapping):
continue
display = str(row.get("displayName") or row.get("key") or "").strip()
status = str(row.get("status") or "").strip()
detected = int(row.get("detectedCount") or 0)
planned = int(row.get("plannedDetectedCount") or 0)
hinted = int(row.get("hintDetectedCount") or 0)
blocked = int(row.get("blockedCount") or 0)
suffix_parts = []
if detected:
suffix_parts.append(f"cache {detected}")
if planned:
suffix_parts.append(f"候选 {planned}")
if hinted:
suffix_parts.append(f"证据 {hinted}")
if blocked:
suffix_parts.append(f"阻止 {blocked}")
suffix = f"{''.join(suffix_parts)}" if suffix_parts else ""
result.append(f"- {display}{status}{suffix}")
return result
def _stage_sort_key(stage: str) -> tuple[int, int, str]:
text = str(stage or "")
numbers: list[int] = []
for part in text.replace("S", "").split("."):
try:
numbers.append(int(part))
except ValueError:
pass
while len(numbers) < 2:
numbers.append(0)
return numbers[0], numbers[1], text
def _source_label(source: str) -> str:
if source.startswith("registry:"):
return "注册表"
if source.startswith("env:"):
return "环境变量"
if source.startswith("common:"):
return "常见安装目录"
if source.lower() == "path":
return "PATH"
if source == "manual":
return "手动配置"
if source == "cache":
return "缓存"
return source or "未知来源"
def _ok_text(value: object) -> str:
if value is True:
return "可用"
if value is False:
return "不可用"
return "未验证"
def _compact(text: str, limit: int) -> str:
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[: max(limit - 1, 0)].rstrip() + ""
__all__ = ["cached_scdm_backend_payload", "summarize_scdm_capability_progress", "summarize_scdm_runtime"]
+68 -2
View File
@@ -33,6 +33,18 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"feature_source_face_id", "feature_source_face_id",
], ],
), ),
(
"SCDM",
[
"scdm_backend_status",
"scdm_runtime_status",
"scdm_selection_status",
"scdm_selection_enabled_capabilities",
"scdm_selection_blocked_capabilities",
"scdm_selection_capability_count",
"scdm_selection_blocked_count",
],
),
( (
"拓扑", "拓扑",
[ [
@@ -529,6 +541,13 @@ INFO_LABELS = {
"associated_feature_count": "关联特征数", "associated_feature_count": "关联特征数",
"associated_feature_face_ids": "关联特征 Face", "associated_feature_face_ids": "关联特征 Face",
"feature_context_note": "关联探测", "feature_context_note": "关联探测",
"scdm_backend_status": "SCDM 后端",
"scdm_runtime_status": "SCDM 运行状态",
"scdm_selection_status": "SCDM 当前选择",
"scdm_selection_enabled_capabilities": "SCDM 可执行能力",
"scdm_selection_blocked_capabilities": "SCDM 未开放能力",
"scdm_selection_capability_count": "SCDM 能力数量",
"scdm_selection_blocked_count": "SCDM 未开放数量",
"recognition_summary": "识别摘要", "recognition_summary": "识别摘要",
"recognition_candidate": "识别候选", "recognition_candidate": "识别候选",
"recognition_confidence": "识别置信度", "recognition_confidence": "识别置信度",
@@ -1086,13 +1105,27 @@ EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
SELECTION_MODE_LABELS = { SELECTION_MODE_LABELS = {
"Part": "零件", "Part": "Part",
"Solid": "Solid", "Solid": "Solid",
"Face": "Face", "Face": "Face",
"Edge": "Edge", "Edge": "Edge",
"Feature": "特征", "Feature": "Feature",
} }
SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()} SELECTION_MODE_VALUES = {label: mode for mode, label in SELECTION_MODE_LABELS.items()}
SELECTION_MODE_VALUES.update(
{
"装配零件": "Part",
"零件": "Part",
"实体": "Solid",
"": "Face",
"": "Edge",
"智能特征": "Feature",
"Solid": "Solid",
"Face": "Face",
"Edge": "Edge",
"特征": "Feature",
}
)
SURFACE_VALUE_LABELS = { SURFACE_VALUE_LABELS = {
@@ -1178,6 +1211,39 @@ def _smooth_surface_polydata(polydata):
return smoothed return smoothed
def _large_model_display_deflection(
requested: float,
*,
face_count: int = 0,
edge_count: int = 0,
) -> float:
"""Use a coarser display mesh for large STEP interaction only."""
value = max(float(requested), 1e-9)
if int(face_count or 0) > 1000 or int(edge_count or 0) > 2500:
return max(value, 1.2)
if int(face_count or 0) > 600 or int(edge_count or 0) > 1600:
return max(value, 0.6)
return value
def _large_model_display_deflection_for_model(model: object, requested: float) -> float:
faces = getattr(model, "faces", ()) or ()
edges = getattr(model, "edges", ()) or ()
return _large_model_display_deflection(
requested,
face_count=len(faces),
edge_count=len(edges),
)
def _large_model_display_deflection_for_stats(stats: object, requested: float) -> float:
return _large_model_display_deflection(
requested,
face_count=int(getattr(stats, "faces", 0) or 0),
edge_count=int(getattr(stats, "edges", 0) or 0),
)
def _format_percent(value: object) -> str: def _format_percent(value: object) -> str:
if value is None or value == "": if value is None or value == "":
return "" return ""
+259 -18
View File
@@ -106,6 +106,7 @@ def _edit_timing_summary(timings: object, *, limit: int = 5) -> str:
"validate": "结果校验", "validate": "结果校验",
"display_faces": "面显示", "display_faces": "面显示",
"display_edges": "边线", "display_edges": "边线",
"result_face_mapping": "结果Face定位",
"finish_ui": "界面刷新", "finish_ui": "界面刷新",
"total": "总计", "total": "总计",
} }
@@ -507,7 +508,14 @@ class WindowActionMixin:
if plan["status"] == "blocked": if plan["status"] == "blocked":
self._show_blocked_plan_message(operation_name, plan, "拉伸/切除平面已阻止") self._show_blocked_plan_message(operation_name, plan, "拉伸/切除平面已阻止")
return return
if plan["risk"] != "low": if keep_relations:
operation_key = "push_pull_face_keep_relations"
isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance])
else:
operation_key = "push_pull_face"
isolation = self._isolation_for_plan(plan, operation_key, [face_id, distance])
if plan["risk"] != "low" and not self._can_skip_edit_confirmation(plan, isolation):
warnings = str(plan.get("warnings", "")) warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else "" warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = ( isolation_line = (
@@ -538,10 +546,6 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes: if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消拉伸/切除平面") self.statusBar().showMessage("已取消拉伸/切除平面")
return return
if keep_relations:
isolation = self._isolation_for_plan(plan, "push_pull_face_keep_relations", [face_id, distance])
else:
isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance])
if isolation is None: if isolation is None:
self._show_push_pull_preview(face_id, distance, plan=plan) self._show_push_pull_preview(face_id, distance, plan=plan)
else: else:
@@ -629,6 +633,7 @@ class WindowActionMixin:
target_kind="face", target_kind="face",
target_id=face_id, target_id=face_id,
isolation=isolation, isolation=isolation,
operation_key=operation_key,
) )
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]: def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -840,13 +845,31 @@ class WindowActionMixin:
if model_face_count < 600: if model_face_count < 600:
return False return False
inner_wires = int(quick_plan.get("inner_boundary_wires") or 0) inner_wires = int(
boundary_wires = int(quick_plan.get("boundary_wires") or 0) quick_plan.get("inner_boundary_wires")
or quick_plan.get("selected_inner_boundary_wires")
or 0
)
boundary_wires = int(
quick_plan.get("boundary_wires")
or quick_plan.get("selected_boundary_wires")
or 0
)
if inner_wires <= 0 and boundary_wires <= 1 and not bool(quick_plan.get("has_inner_boundaries")): if inner_wires <= 0 and boundary_wires <= 1 and not bool(quick_plan.get("has_inner_boundaries")):
return False return False
# Large STEP + holed planar caps are exactly where a full plan can spend boundary_edges = int(
# seconds scanning topology before the actual isolated edit even starts. quick_plan.get("first_level_boundary_edge_count")
or quick_plan.get("selected_boundary_edge_count")
or 0
)
if boundary_wires and boundary_wires <= 16 and inner_wires <= 12:
return False
if boundary_edges and boundary_edges <= 120 and inner_wires <= 12:
return False
# Very large STEP + extremely fragmented holed caps can still spend
# noticeable time scanning topology before the actual edit starts.
return abs(float(distance)) > 1e-9 return abs(float(distance)) > 1e-9
def _deferred_push_pull_model_plan( def _deferred_push_pull_model_plan(
@@ -1727,13 +1750,66 @@ class WindowActionMixin:
return None return None
if risk not in {"low", "medium", "high"}: if risk not in {"low", "medium", "high"}:
return None return None
prefer_smooth_process = self._prefer_isolated_process_for_large_interactive_edit(plan, operation)
if not prefer_smooth_process and self._can_run_inprocess_background_edit(plan, operation):
return None
return { return {
"operation": operation, "operation": operation,
"args": args, "args": args,
"timeout_seconds": timeout_seconds, "timeout_seconds": timeout_seconds,
"reason": f"{risk}-risk-isolated-occ-edit", "reason": "large-model-smooth-ui-isolated-occ-edit" if prefer_smooth_process else f"{risk}-risk-isolated-occ-edit",
} }
def _can_run_inprocess_background_edit(self, plan: dict[str, object], operation: str) -> bool:
if operation != "push_pull_face":
return False
if str(plan.get("planar_cap_extension_method") or "") == "boundary-shell-rebuild":
return True
if str(plan.get("cylindrical_cap_extension_method") or "") == "local-shell-rebuild":
return True
if bool(plan.get("ui_deferred_model_plan")) and int(plan.get("selected_inner_boundary_wires", 0) or 0) > 0:
return True
return False
def _prefer_isolated_process_for_large_interactive_edit(self, plan: dict[str, object], operation: str) -> bool:
if operation not in {"push_pull_face", "push_pull_face_keep_relations"}:
return False
method = str(plan.get("planar_cap_extension_method") or plan.get("cylindrical_cap_extension_method") or "")
if method not in {"boundary-shell-rebuild", "local-shell-rebuild"}:
return False
if method == "local-shell-rebuild":
return True
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return True
boundary_edges = _int_or_none(plan.get("first_level_boundary_edge_count")) or _int_or_none(
plan.get("planar_cap_boundary_edge_count")
) or 0
adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count")) or _int_or_none(
plan.get("planar_cap_adjacent_face_count")
) or 0
inner_wires = _int_or_none(plan.get("selected_inner_boundary_wires")) or _int_or_none(
plan.get("planar_cap_inner_boundary_wires")
) or 0
return boundary_edges >= 32 or adjacent_faces >= 32 or inner_wires >= 2
def _skip_before_quality_check_for_large_edit(
self,
operation_name: str,
operation_key: str | None = None,
) -> bool:
if operation_key not in {"push_pull_face", "push_pull_face_keep_relations"} and "拉伸/切除" not in str(
operation_name or ""
):
return False
return bool(getattr(self, "_large_model_interaction_mode", lambda: False)())
def _can_skip_edit_confirmation(self, plan: dict[str, object], isolation: dict[str, object] | None) -> bool:
if str(plan.get("status") or "") == "blocked":
return False
if not isinstance(isolation, dict):
return False
return isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit"
def _edit_failure_diagnostics(self, context: dict[str, object]) -> str: def _edit_failure_diagnostics(self, context: dict[str, object]) -> str:
parameters = context.get("parameters") parameters = context.get("parameters")
if not isinstance(parameters, dict): if not isinstance(parameters, dict):
@@ -7683,6 +7759,8 @@ class WindowActionMixin:
@Slot(object) @Slot(object)
def _finish_scan_task_result(self, result: object) -> None: def _finish_scan_task_result(self, result: object) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scan_task_result(result)):
return
scan_kind = self.pending_scan_kind scan_kind = self.pending_scan_kind
context = dict(self.pending_scan_context or {}) context = dict(self.pending_scan_context or {})
if scan_kind == "editable": if scan_kind == "editable":
@@ -7701,6 +7779,8 @@ class WindowActionMixin:
@Slot(str) @Slot(str)
def _fail_scan_task_result(self, message: str) -> None: def _fail_scan_task_result(self, message: str) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_scan_task_result(message)):
return
scan_kind = self.pending_scan_kind scan_kind = self.pending_scan_kind
if scan_kind == "editable": if scan_kind == "editable":
self._fail_editable_scan(message) self._fail_editable_scan(message)
@@ -7978,6 +8058,7 @@ class WindowActionMixin:
target_kind: str | None = None, target_kind: str | None = None,
target_id: int | None = None, target_id: int | None = None,
isolation: dict[str, object] | None = None, isolation: dict[str, object] | None = None,
operation_key: str | None = None,
) -> None: ) -> None:
if self.model is None: if self.model is None:
return return
@@ -7985,11 +8066,15 @@ class WindowActionMixin:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。") self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。")
return return
target_logical_id = self._edit_target_logical_id(target_kind, target_id) target_logical_id = self._edit_target_logical_id(target_kind, target_id)
result_deflection = float(getattr(self, "edit_result_deflection", 1.6)) result_deflection = _large_model_display_deflection_for_model(
self.model,
float(getattr(self, "edit_result_deflection", 1.6)),
)
if operation_name == "拉伸/切除平面": if operation_name == "拉伸/切除平面":
result_deflection = max(result_deflection, 0.35) result_deflection = max(result_deflection, 0.35)
context = { context = {
"operation_name": operation_name, "operation_name": operation_name,
"operation_key": operation_key or "",
"target": target, "target": target,
"parameters": parameters, "parameters": parameters,
"target_kind": target_kind, "target_kind": target_kind,
@@ -8000,6 +8085,10 @@ class WindowActionMixin:
"edit_result_deflection": result_deflection, "edit_result_deflection": result_deflection,
"defer_edge_polydata": True, "defer_edge_polydata": True,
"isolation": dict(isolation or {}), "isolation": dict(isolation or {}),
"skip_before_quality_check": self._skip_before_quality_check_for_large_edit(
operation_name,
operation_key,
),
} }
blocker = self._edit_preflight_blocker(context) blocker = self._edit_preflight_blocker(context)
if blocker is not None: if blocker is not None:
@@ -8046,7 +8135,11 @@ class WindowActionMixin:
target_part_id = self._edit_context_part_id(context) target_part_id = self._edit_context_part_id(context)
before_stats = self.model.stats() before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id) before_part_stats = self._part_stats_or_none(target_part_id)
before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id) before_quality = (
None
if bool(context.get("skip_before_quality_check"))
else self._edit_quality_info_or_none(self.model, context, target_part_id)
)
before_geometry = {} before_geometry = {}
timings["snapshot"] = time.perf_counter() - started timings["snapshot"] = time.perf_counter() - started
isolation = context.get("isolation") isolation = context.get("isolation")
@@ -8257,8 +8350,14 @@ class WindowActionMixin:
new_model.filename = self.step_path new_model.filename = self.step_path
except Exception: except Exception:
pass pass
try:
new_model.mark_external_recognition_stale("isolated-edit-result")
except Exception:
pass
child_message = str(response.get("message") or "隔离子进程编辑完成。") child_message = str(response.get("message") or "隔离子进程编辑完成。")
started = time.perf_counter()
self._preserve_isolated_face_logical_id(new_model, context, child_message) self._preserve_isolated_face_logical_id(new_model, context, child_message)
timings["result_face_mapping"] = time.perf_counter() - started
started = time.perf_counter() started = time.perf_counter()
after_snapshot = new_model.snapshot() after_snapshot = new_model.snapshot()
after_stats = new_model.stats() after_stats = new_model.stats()
@@ -8301,7 +8400,7 @@ class WindowActionMixin:
timings["total"] = time.perf_counter() - total_started timings["total"] = time.perf_counter() - total_started
return { return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩", "message": f"{child_message} 已通过独立后台几何进程完成,主界面会保持可响应",
"snapshot": snapshot, "snapshot": snapshot,
"before_stats": before_stats, "before_stats": before_stats,
"before_part_stats": before_part_stats, "before_part_stats": before_part_stats,
@@ -8371,9 +8470,6 @@ class WindowActionMixin:
logical_id = int(target_logical_id) logical_id = int(target_logical_id)
except (TypeError, ValueError): except (TypeError, ValueError):
return return
candidate_ids: list[int] = []
if 0 <= face_id < len(model.faces):
candidate_ids.append(face_id)
parameters = context.get("parameters") parameters = context.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {} parameters = parameters if isinstance(parameters, dict) else {}
target_position = _float_or_none(parameters.get("target_plane_position")) target_position = _float_or_none(parameters.get("target_plane_position"))
@@ -8381,6 +8477,29 @@ class WindowActionMixin:
_unit_triple_or_none(parameters.get("plane_direction")) _unit_triple_or_none(parameters.get("plane_direction"))
or _unit_triple_or_none(parameters.get("outward_direction")) or _unit_triple_or_none(parameters.get("outward_direction"))
) )
if 0 <= face_id < len(model.faces):
if target_position is not None and plane_direction is not None:
if self._face_target_plane_position_matches(
model,
[face_id],
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
):
try:
model.assign_logical_face_region_exclusive(logical_id, [face_id])
return
except Exception:
pass
elif self._assign_isolated_logical_face_candidate(model, logical_id, face_id, context):
return
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation.get("reason") == "large-model-smooth-ui-isolated-occ-edit":
return
candidate_ids: list[int] = []
if 0 <= face_id < len(model.faces):
candidate_ids.append(face_id)
if target_position is not None and plane_direction is not None: if target_position is not None and plane_direction is not None:
part_id = self._edit_integrity_int_or_none(parameters.get("part_id")) part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id")) solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
@@ -8418,6 +8537,26 @@ class WindowActionMixin:
except Exception: except Exception:
continue continue
def _assign_isolated_logical_face_candidate(
self,
model: StepModel,
logical_id: int,
face_id: int,
context: dict[str, object],
) -> bool:
try:
if bool(getattr(self, "_large_model_interaction_mode", lambda: False)()) or (
isinstance(context.get("isolation"), dict)
and context["isolation"].get("reason") == "large-model-smooth-ui-isolated-occ-edit"
):
face_ids = [int(face_id)]
else:
face_ids = model.face_region_ids(int(face_id)) or [int(face_id)]
model.assign_logical_face_region_exclusive(int(logical_id), face_ids)
return True
except Exception:
return False
def _edit_context_part_id(self, context: dict[str, object]) -> int | None: def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None: if self.model is None:
return None return None
@@ -9165,10 +9304,16 @@ class WindowActionMixin:
pick_position=context["pick_position"], pick_position=context["pick_position"],
before_snapshot=result["snapshot"], before_snapshot=result["snapshot"],
after_snapshot=result["after_snapshot"], after_snapshot=result["after_snapshot"],
isolation=dict(context.get("isolation") or {}),
) )
model_polydata = result.get("model_polydata") model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata") edge_polydata = result.get("edge_polydata")
edge_deferred = bool(result.get("edge_polydata_deferred")) edge_deferred = bool(result.get("edge_polydata_deferred"))
large_model = len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
if edge_deferred and large_model:
self.large_model_edge_overlay_skipped = True
elif not edge_deferred:
self.large_model_edge_overlay_skipped = False
if model_polydata is None or (edge_polydata is None and not edge_deferred): if model_polydata is None or (edge_polydata is None and not edge_deferred):
deflection = float(context.get("edit_result_deflection", 1.6)) deflection = float(context.get("edit_result_deflection", 1.6))
model_polydata = self.model.build_face_polydata(deflection=deflection) model_polydata = self.model.build_face_polydata(deflection=deflection)
@@ -9186,8 +9331,11 @@ class WindowActionMixin:
timings["finish_ui"] = time.perf_counter() - finish_started timings["finish_ui"] = time.perf_counter() - finish_started
locator_note = self._locate_operation_record(record) locator_note = self._locate_operation_record(record)
relation_note = "" relation_note = ""
relation_dependency_ids: list[int] = []
relation_replay_active = bool(getattr(self, "relation_formula_replay_active", False))
if hasattr(self, "_refresh_relation_formulas_after_model_edit"): if hasattr(self, "_refresh_relation_formulas_after_model_edit"):
relation_note = self._refresh_relation_formulas_after_model_edit() relation_note = self._refresh_relation_formulas_after_model_edit(context=context)
relation_dependency_ids = list(getattr(self, "_last_relation_formula_refresh_affected_ids", []) or [])
if relation_note: if relation_note:
locator_note = f"{locator_note}\n{relation_note}" if locator_note else relation_note locator_note = f"{locator_note}\n{relation_note}" if locator_note else relation_note
except Exception as exc: except Exception as exc:
@@ -9206,7 +9354,8 @@ class WindowActionMixin:
self._refresh_history_list() self._refresh_history_list()
self._end_edit_task(clear_preview=False) self._end_edit_task(clear_preview=False)
timing_text = _edit_timing_summary(result.get("timings")) timing_text = _edit_timing_summary(result.get("timings"))
if bool(result.get("edge_polydata_deferred")): edge_deferred = bool(result.get("edge_polydata_deferred"))
if edge_deferred and not bool(getattr(self, "large_model_edge_overlay_skipped", False)):
QTimer.singleShot(80, self._rebuild_deferred_edge_display) QTimer.singleShot(80, self._rebuild_deferred_edge_display)
if result.get("quality_warnings"): if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情") self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
@@ -9214,12 +9363,23 @@ class WindowActionMixin:
selection_note = ";已保持当前选择" if self.selected_kind is not None else "" selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
timing_note = f";耗时 {timing_text}" if timing_text else "" timing_note = f";耗时 {timing_text}" if timing_text else ""
edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else "" edge_note = ";边线稍后补充" if bool(result.get("edge_polydata_deferred")) else ""
if edge_deferred and bool(getattr(self, "large_model_edge_overlay_skipped", False)):
edge_note = ";边线按需生成"
self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}") self.statusBar().showMessage(f"{message}{selection_note}{timing_note}{edge_note}")
if self.selected_kind is None: if self.selected_kind is None:
timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else "" timing_detail = f"\n\n性能耗时:{timing_text}" if timing_text else ""
self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}") self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}")
if hasattr(self, "_after_property_edit_finished"): if hasattr(self, "_after_property_edit_finished"):
self._after_property_edit_finished(success=True) self._after_property_edit_finished(success=True)
if (
relation_dependency_ids
and not relation_replay_active
and hasattr(self, "_queue_relation_formula_dependency_reapply")
and hasattr(self, "_run_pending_relation_formula_dependency_reapply")
):
self._queue_relation_formula_dependency_reapply(relation_dependency_ids)
if not bool(getattr(self, "property_batch_active", False)):
self._run_pending_relation_formula_dependency_reapply()
@Slot(str) @Slot(str)
def _fail_edit_action(self, message: str) -> None: def _fail_edit_action(self, message: str) -> None:
@@ -9277,6 +9437,84 @@ class WindowActionMixin:
self.edit_thread = None self.edit_thread = None
self.edit_worker = None self.edit_worker = None
def _operation_parameters_with_recognition_sources(
self,
parameters: dict[str, object],
target_kind: str | None,
target_id: int | None,
) -> dict[str, object]:
result = dict(parameters or {})
if result.get("recognition_source") not in {None, ""}:
return result
evidence = [result, getattr(self, "current_info_values", {})]
if (
target_kind in {"face", "feature"}
and target_id is not None
and getattr(self, "model", None) is not None
):
try:
evidence.append(self.model.quick_face_info(int(target_id)))
except Exception:
pass
result["recognition_source"] = (
"Analysis Situs + internal StepModel"
if any(self._operation_has_analysis_situs_evidence(item) for item in evidence)
else "internal StepModel"
)
return result
def _operation_backend_log_lines(
self,
parameters: dict[str, object],
result_message: str,
*,
isolation: dict[str, object] | None = None,
) -> list[str]:
execution = "isolated OCCT subprocess" if isinstance(isolation, dict) and isolation else "Qt background worker"
recognition = str(parameters.get("recognition_source") or "").strip()
if not recognition:
recognition = (
"Analysis Situs + internal StepModel"
if self._operation_has_analysis_situs_evidence(parameters)
or self._operation_has_analysis_situs_evidence(result_message)
else "internal StepModel"
)
return [
"backend: OCCT",
f"execution: {execution}",
f"recognition: {recognition}",
]
def _operation_has_analysis_situs_evidence(self, value: object) -> bool:
if self._operation_value_is_empty(value):
return False
if isinstance(value, str):
lowered = value.lower()
return "analysis situs" in lowered or "analysis-situs" in lowered
if isinstance(value, dict):
for key, item in value.items():
key_text = str(key).lower()
if (
key_text.startswith("asitus_")
or key_text.startswith("analysis_situs_")
or key_text.startswith("external_recognition_")
) and not self._operation_value_is_empty(item):
return True
if self._operation_has_analysis_situs_evidence(item):
return True
return False
if isinstance(value, (tuple, list, set)):
return any(self._operation_has_analysis_situs_evidence(item) for item in value)
return False
@staticmethod
def _operation_value_is_empty(value: object) -> bool:
if value is None or value == "":
return True
if isinstance(value, (tuple, list, set, dict)) and not value:
return True
return False
def _make_operation_record( def _make_operation_record(
self, self,
operation_name: str, operation_name: str,
@@ -9296,7 +9534,9 @@ class WindowActionMixin:
pick_position: tuple[float, float, float] | None = None, pick_position: tuple[float, float, float] | None = None,
before_snapshot: dict[int, object] | None = None, before_snapshot: dict[int, object] | None = None,
after_snapshot: dict[int, object] | None = None, after_snapshot: dict[int, object] | None = None,
isolation: dict[str, object] | None = None,
) -> OperationRecord: ) -> OperationRecord:
parameters = self._operation_parameters_with_recognition_sources(parameters, target_kind, target_id)
target_summary = target target_summary = target
if target_kind in {"face", "feature"} and target_logical_id is not None: if target_kind in {"face", "feature"} and target_logical_id is not None:
target_summary = f"{target_kind} logical {target_logical_id}" target_summary = f"{target_kind} logical {target_logical_id}"
@@ -9361,6 +9601,7 @@ class WindowActionMixin:
f"target: {target}", f"target: {target}",
f"target_kind: {target_kind or ''}", f"target_kind: {target_kind or ''}",
f"target_id: {target_id if target_id is not None else ''}", f"target_id: {target_id if target_id is not None else ''}",
*self._operation_backend_log_lines(parameters, result_message, isolation=isolation),
"parameters:", "parameters:",
] ]
if target_logical_id is not None: if target_logical_id is not None:
File diff suppressed because it is too large Load Diff
+2456 -132
View File
File diff suppressed because it is too large Load Diff