Compare commits

..

16 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
nikelaluo a633b5a338 feat: 完善 STEP 一级参数化编辑识别与关系式建模 2026-08-14 18:42:39 +08:00
nikelaluo 70b59c1de6 chore: 提交ICEPAK测试模型并忽略本地第三方目录 2026-08-14 09:31:30 +08:00
nikelaluo 066d28660b chore: 忽略Analysis Situs第三方源码 2026-08-13 17:57:00 +08:00
nikelaluo 7d814d2939 feat: 推进一级关系参数化编辑与参数导出 2026-08-13 17:50:20 +08:00
nikelaluo 19364d81b5 feat: 完善一级关系参数化建模与参数导出 2026-08-11 18:28:16 +08:00
nikelaluo 6cb99a1273 docs: 收口R2R3孔槽阶段验收口径 2026-08-11 14:09:31 +08:00
nikelaluo 9b44918648 docs: 收口R1 Face阶段验收口径 2026-08-11 09:50:40 +08:00
nikelaluo 3aadd6a34e feat: 增强一级共面同轴事实识别 2026-08-10 18:05:18 +08:00
nikelaluo 3a851d0967 feat: 扩展Face保持关系编辑语义 2026-08-10 17:50:50 +08:00
nikelaluo 61f0b76e59 feat: 完善Face保持关系守门与大模型预判 2026-08-10 16:51:52 +08:00
nikelaluo 415e8a0f27 feat: 增加Face偏移保持一级关系策略 2026-08-10 15:26:35 +08:00
nikelaluo 83c44265ba feat: 增强一级关系识别与保持关系策略 2026-08-10 14:56:50 +08:00
70 changed files with 35285 additions and 2209 deletions
+14
View File
@@ -22,10 +22,24 @@ dist/
*.zip
assets/screenshots/
/screenshot.png
local/
tmp.md
tmp_scdm*
data.json
nodes/
Analysis-Component/
occt_feature_editor/
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.
概念.md
Face一级关系专项测试说明.md
Creo软件具备哪些建模形式.md
中文版Creo+4.0从入门到精通.pdf
+415 -1416
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
param(
[string]$Configuration = "Release"
)
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$sourceDir = Join-Path $repoRoot "tools\asitus_probe"
$buildDir = Join-Path $repoRoot "third_party\asitus_probe_tools_build"
cmake -S $sourceDir -B $buildDir -A x64
cmake --build $buildDir --config $Configuration
$exePath = Join-Path $buildDir "$Configuration\recognize_holes.exe"
if (-not (Test-Path $exePath)) {
throw "Analysis Situs probe build finished but recognize_holes.exe was not found: $exePath"
}
Write-Host "Analysis Situs probe built: $exePath"
+207
View File
@@ -0,0 +1,207 @@
from __future__ import annotations
from pathlib import Path
import sys
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.asitus_bridge import (
default_asitus_recognize_holes_path,
parse_asitus_hole_groups,
parse_asitus_probe_payload,
run_asitus_hole_recognition,
)
from step_editor.model import StepModel
from step_editor.recognition_graph import recognition_summary
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _install_external_regions(model: StepModel, groups: list[tuple[int, ...]]) -> None:
model._asitus_hole_regions_attempted = True # noqa: SLF001
model._asitus_hole_region_cache.clear() # noqa: SLF001
for group in groups:
for face_id in group:
model._asitus_hole_region_cache[int(face_id)] = list(group) # noqa: SLF001
model._same_domain_face_ids_cache.clear() # noqa: SLF001
def main() -> int:
payload = {
"validBreP": True,
"faceCount": 158,
"aagNodeCount": 158,
"holeFaceIds": [85, 86, 87, 88, 95, 96, 97, 98],
"holeCount": 4,
"holes": [
{"index": 1, "faceIds": [85, 98]},
{"index": 2, "faceIds": [86, 97]},
{"index": 3, "faceIds": [87, 96]},
{"index": 4, "faceIds": [88, 95]},
],
"surfaceSummary": {"plane": 106, "cylinder": 52},
"angleSummary": {"smooth": 4, "convex": 8},
"geometricRelationMode": "all-pairs",
"geometricRelationSummary": {
"coaxial": 1,
"coplanar": 1,
"parallel": 1,
"parallel_axis": 1,
"perpendicular": 1,
"tangent": 1,
},
"faces": [
{"id": 88, "surface": "cylinder", "neighbors": [12, 95]},
{"id": 95, "surface": "cylinder", "neighbors": [88, 42]},
],
"adjacency": [
{"faceIds": [88, 95], "angleType": "smooth", "angleRad": 0.0, "edgeIds": [501, 502]},
],
"geometricRelations": [
{"faceIds": [88, 95], "type": "coaxial", "residual": 0.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "tangent", "residual": 0.0, "source": "analysis-situs-aag-angle"},
{"faceIds": [88, 95], "type": "coplanar", "residual": 0.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "parallel", "residual": 2.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "perpendicular", "residual": 0.0, "source": "analysis-situs-probe"},
{"faceIds": [88, 95], "type": "parallel_axis", "residual": 0.0, "source": "analysis-situs-probe"},
],
}
raw_groups = parse_asitus_hole_groups(payload)
_assert(raw_groups == [(85, 98), (86, 97), (87, 96), (88, 95)], f"unexpected parsed groups: {raw_groups}")
parsed = parse_asitus_probe_payload(payload)
_assert(parsed.get("surface_summary") == {"plane": 106, "cylinder": 52}, f"bad surface summary: {parsed}")
_assert(parsed.get("geometric_relation_mode") == "all-pairs", f"bad geometric relation mode: {parsed}")
expected_geometric_summary = {
"coaxial": 1,
"coplanar": 1,
"parallel": 1,
"parallel_axis": 1,
"perpendicular": 1,
"tangent": 1,
}
_assert(
parsed.get("geometric_relation_summary") == expected_geometric_summary,
f"bad geometric summary: {parsed}",
)
_assert(len(tuple(parsed.get("faces", ()))) == 2, f"bad face summary parse: {parsed}")
_assert(len(tuple(parsed.get("adjacency", ()))) == 1, f"bad adjacency parse: {parsed}")
_assert(len(tuple(parsed.get("geometric_relations", ()))) == 6, f"bad geometric relation parse: {parsed}")
if not MODEL_PATH.exists():
print("asitus hole bridge mapping skipped: local ICEPAK-NATURAL.stp is not present")
return 0
model = StepModel.load(MODEL_PATH)
mapped = model._map_asitus_hole_groups(raw_groups) # noqa: SLF001
expected = [(84, 97), (85, 96), (86, 95), (87, 94)]
_assert(mapped == expected, f"Analysis Situs 1-based AAG face ids should map to Python face ids: {mapped}")
async_model = StepModel.load(MODEL_PATH)
_assert(async_model.begin_asitus_hole_region_load() is True, "background preload should be accepted once")
_assert(
async_model._asitus_hole_region_ids(87) == [], # noqa: SLF001
"selection should not synchronously run Analysis Situs while background preload is pending",
)
async_mapped = async_model.install_asitus_hole_recognition_result({"ok": True, "reason": "ok", "groups": raw_groups})
_assert(async_mapped == expected, f"background result should install mapped groups: {async_mapped}")
_assert(async_model.face_region_ids(87) == [87, 94], "installed background result should drive whole-hole selection")
relation_model = StepModel.load(MODEL_PATH)
relation_result = {
"ok": True,
"reason": "ok",
"groups": raw_groups,
"faces": parsed.get("faces", ()),
"adjacency": parsed.get("adjacency", ()),
"geometric_relations": parsed.get("geometric_relations", ()),
"surface_summary": parsed.get("surface_summary", {}),
"angle_summary": parsed.get("angle_summary", {}),
"geometric_relation_summary": parsed.get("geometric_relation_summary", {}),
}
relation_mapped = relation_model.install_asitus_hole_recognition_result(relation_result)
_assert(relation_mapped == expected, f"relation install should preserve hole mapping: {relation_mapped}")
relation_info = relation_model.quick_face_info(87)
_assert(relation_info.get("asitus_relation_status") == "ready", f"missing AAG relation fields: {relation_info}")
_assert(
relation_info.get("asitus_adjacent_face_ids") == (11, 94),
f"Analysis Situs 1-based neighbors should map to Python face ids: {relation_info}",
)
_assert(
"smooth:1" in str(relation_info.get("asitus_adjacent_relation_summary") or ""),
f"missing AAG angle relation summary: {relation_info}",
)
_assert(
"coaxial:1" in str(relation_info.get("asitus_geometric_relation_summary") or ""),
f"missing AAG geometric relation summary: {relation_info}",
)
_assert(
"tangent:1" in str(relation_info.get("asitus_geometric_relation_summary") or ""),
f"missing AAG tangent relation summary: {relation_info}",
)
graph_summary = recognition_summary(relation_model)
relation_counts = dict(graph_summary.get("relation_counts", {}) or {})
_assert(
relation_counts.get("external_coaxial", 0) >= 1,
f"recognition graph should include external coaxial evidence: {graph_summary}",
)
for relation_type in (
"external_coplanar",
"external_parallel",
"external_parallel_axis",
"external_perpendicular",
"external_tangent",
):
_assert(
relation_counts.get(relation_type, 0) >= 1,
f"recognition graph should include {relation_type} evidence: {graph_summary}",
)
_assert(
int(relation_info.get("recognition_external_relation_score_bonus") or 0) > 0,
f"feature summary should expose an external relation confidence bonus: {relation_info}",
)
candidates = relation_model.editable_feature_candidates(limit=160, detailed=False, max_scan_faces=120)
related_candidates = [
item
for item in candidates
if int(item.get("face_id", -1) or -1) == 87
or int(item.get("target_id", -1) or -1) == 87
]
_assert(related_candidates, f"editable scan should include the Analysis Situs-backed Face 87 candidate: {candidates}")
_assert(
any(int(item.get("recognition_external_relation_score_bonus") or 0) > 0 for item in related_candidates),
f"editable candidates should carry external relation sorting support: {related_candidates}",
)
_assert(
any("coaxial" in str(item.get("external_recognition_relation_summary") or "") for item in related_candidates),
f"editable candidates should expose external relation summary: {related_candidates}",
)
_install_external_regions(model, mapped)
_assert(model.face_region_ids(87) == [87, 94], "Face 87 should select the complete external hole group")
_assert(model.face_region_ids(94) == [87, 94], "Face 94 should select the complete external hole group")
info = model.quick_face_info(87)
_assert(list(info.get("feature_highlight_face_ids") or []) == [87, 94], "quick UI highlight should use external hole group")
cli = default_asitus_recognize_holes_path(PROJECT_ROOT)
if cli is not None:
result = run_asitus_hole_recognition(MODEL_PATH, project_root=PROJECT_ROOT, timeout_seconds=6.0)
_assert(result.get("ok") is True, f"local Analysis Situs CLI should recognize ICEPAK holes: {result}")
external_mapped = model._map_asitus_hole_groups(result.get("groups", ())) # noqa: SLF001
for group in expected:
_assert(group in external_mapped, f"local Analysis Situs CLI mapping missed {group}: {external_mapped}")
print("asitus hole bridge ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -42,6 +42,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"Edge length move end plane, fixed start point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-start"),
),
(
"Edge length keep first-level planar relations, fixed start point",
("verify_edge_length_resize.py", "--strategy", "keep-first-level-planar-relations", "--anchor", "keep-start"),
),
(
"Edge length move end plane, fixed end point",
("verify_edge_length_resize.py", "--strategy", "move-edge-end-plane-by-push-pull", "--anchor", "keep-end"),
@@ -72,6 +72,18 @@ def _assert_edge_topology(model: StepModel, edge_id: int) -> dict[str, object]:
int(topology.get("first_level_edge_count", 0) or 0) == 5,
f"cube Edge first-level Edge set should contain selected Edge + 4 neighbors: {topology}",
)
_assert(
topology.get("first_level_planar_relation_status") == "ready",
f"cube Edge should expose first-level planar relation facts: {topology}",
)
_assert(
int(topology.get("first_level_planar_relation_parallel_count", 0) or 0) >= 2,
f"cube Edge should have two incident Faces parallel to the Edge direction: {topology}",
)
_assert(
int(topology.get("first_level_planar_relation_perpendicular_count", 0) or 0) >= 1,
f"cube Edge incident Faces should be perpendicular to each other: {topology}",
)
ignored = tuple(topology.get("topology_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"missing ignored depths: {topology}")
return topology
@@ -93,6 +105,16 @@ def _assert_edge_facts(facts: dict[str, object], label: str) -> None:
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 2, f"{label}: bad adjacent Faces")
_assert(int(facts.get("first_level_fact_included_edge_count", 0) or 0) == 5, f"{label}: bad included Edges")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: missing summary")
_assert(facts.get("first_level_planar_relation_status") == "ready", f"{label}: missing planar relation facts")
_assert(
int(facts.get("first_level_planar_relation_parallel_count", 0) or 0) >= 2,
f"{label}: expected incident Faces parallel to selected Edge direction",
)
_assert(
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) >= 1,
f"{label}: expected perpendicular incident Face relation",
)
_assert("一级平面关系" in str(facts.get("first_level_fact_summary") or ""), f"{label}: missing relation summary")
def _assert_plan_facts(plan: dict[str, object], label: str) -> None:
@@ -111,6 +133,9 @@ def main() -> int:
"edge_first_level_topology",
"first_level_vertex_count",
"first_level_adjacent_edge_count",
"first_level_planar_relation_summary",
"first_level_planar_relation_parallel_count",
"first_level_planar_relation_perpendicular_count",
):
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
+90
View File
@@ -58,6 +58,7 @@ def _strategy_label(strategy: str) -> str:
"auto": "自动选择",
"local-edge-only-deform": "只改当前Edge",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直",
"keep-first-level-planar-relations": "保持一级平面关系",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径",
"scale-owning-shape-from-edge": "缩放所属",
}.get(strategy, strategy)
@@ -133,6 +134,7 @@ def _verify_line_edge_intent_specs() -> None:
_assert_edge_length_scope(length_spec, "auto", "自动", enabled=True)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=True)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=True)
_assert_edge_length_scope(length_spec, "keep-first-level-planar-relations", "保持关系", enabled=True)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
@@ -140,6 +142,12 @@ def _verify_line_edge_intent_specs() -> None:
enabled=False,
)
_assert_edge_length_scope(length_spec, "scale-owning-shape-from-edge", "缩放所属", enabled=True)
constrained_specs = _specs_for_edge(info, strategy="keep-first-level-planar-relations", anchor="keep-start")
constrained_length_spec = _spec(constrained_specs, "length")
_assert(
str(constrained_length_spec.get("scope_default")) == "keep-first-level-planar-relations",
"line Edge should honor the explicit keep-planar-relations modeling intent",
)
anchor_spec = _spec(specs, "edge_length_anchor_mode")
_assert(str(anchor_spec.get("label")) == "长度基准", "line Edge should expose a length anchor row")
print("line Edge modeling-intent specs ok")
@@ -171,6 +179,7 @@ def _verify_circle_edge_intent_specs() -> None:
)
_assert_edge_length_scope(length_spec, "local-edge-only-deform", "只改当前Edge", enabled=False)
_assert_edge_length_scope(length_spec, "move-edge-end-plane-by-push-pull", "移动端面", enabled=False)
_assert_edge_length_scope(length_spec, "keep-first-level-planar-relations", "保持关系", enabled=False)
_assert_edge_length_scope(
length_spec,
"resize-adjacent-cylinder-from-circular-edge-length",
@@ -195,6 +204,7 @@ def _verify_edge_length_plan_intents() -> None:
expectations = (
("local-edge-only-deform", "keep-start", "local-edge-only-deform", "相邻面会自然变斜"),
("move-edge-end-plane-by-push-pull", "keep-start", "move-edge-end-plane-by-push-pull", "正方体这类模型会更像变成长方体"),
("keep-first-level-planar-relations", "keep-start", "move-edge-end-plane-by-push-pull", "保持一级平面关系"),
("scale-owning-shape-from-edge", "center", "scale-owning-shape-from-edge", "其它尺寸会跟随变化"),
)
for strategy, anchor, expected_strategy, impact_fragment in expectations:
@@ -206,6 +216,28 @@ def _verify_edge_length_plan_intents() -> None:
)
impact = str(plan.get("edge_length_impact_summary") or "")
_assert(impact_fragment in impact, f"{strategy} impact summary should explain result: {impact}")
if expected_strategy == "move-edge-end-plane-by-push-pull":
_assert(
plan.get("edge_length_planar_constraint_status") == "ready",
f"end-face push/pull should expose ready planar constraints: {plan}",
)
_assert(
"端面推拉平面约束" in str(plan.get("edge_length_planar_constraint_summary") or ""),
f"end-face push/pull should explain its planar constraint guard: {plan}",
)
_assert(
int(plan.get("edge_length_planar_constraint_perpendicular_count", 0) or 0) >= 1,
f"end-face push/pull should detect perpendicular side-face relations: {plan}",
)
if strategy == "keep-first-level-planar-relations":
_assert(
plan.get("edge_length_planar_relation_constraint_requested") is True,
f"explicit keep-planar-relations strategy should be preserved in the plan: {plan}",
)
_assert(
str(plan.get("edge_length_strategy_label")) == "保持一级平面关系",
f"explicit keep-planar-relations strategy should keep a user-facing label: {plan}",
)
blocked = model.general_edge_length_plan(
edge_id,
15.0,
@@ -214,13 +246,71 @@ def _verify_edge_length_plan_intents() -> None:
)
_assert(str(blocked.get("status")) == "blocked", "moving an end plane while fixing center should be blocked")
_assert("不能使用固定中心" in str(blocked.get("message") or ""), f"blocked message should explain center conflict: {blocked}")
blocked_keep_relations = model.general_edge_length_plan(
edge_id,
15.0,
anchor_mode="center",
strategy_mode="keep-first-level-planar-relations",
)
_assert(
str(blocked_keep_relations.get("status")) == "blocked",
"keeping first-level planar relations while fixing center should be blocked",
)
_assert(
"保持一级平面关系" in str(blocked_keep_relations.get("message") or ""),
f"blocked keep-planar-relations plan should name the requested constraint: {blocked_keep_relations}",
)
print("Edge modeling-intent plans ok")
def _verify_edge_coordinate_move_plan_semantics() -> None:
model = StepModel.load(DEFAULT_CUBE)
edge_id = _line_edge_ids_near_length(model, 10.0, 1e-5)[0]
info = model.edge_info(edge_id)
start = info.get("start_point")
center = info.get("length_center")
if not isinstance(start, (tuple, list)) or len(start) != 3:
raise AssertionError(f"line Edge lacks a stable start point: {info}")
if not isinstance(center, (tuple, list)) or len(center) != 3:
raise AssertionError(f"line Edge lacks a stable center: {info}")
endpoint_plan = model.edge_endpoint_move_plan(
edge_id,
"start",
(float(start[0]) - 1.0, float(start[1]), float(start[2])),
)
_assert(str(endpoint_plan.get("status")) != "blocked", f"endpoint move should be available: {endpoint_plan}")
_assert(str(endpoint_plan.get("edge_endpoint_label")) == "起点", f"endpoint label should be Chinese: {endpoint_plan}")
_assert(
str(endpoint_plan.get("edit_strategy_label")) == "Edge端点局部移动",
f"endpoint move should expose a user-facing strategy label: {endpoint_plan}",
)
_assert(
"不是端面整体推拉" in str(endpoint_plan.get("edit_semantics") or ""),
f"endpoint move should explain that it is local deformation: {endpoint_plan}",
)
center_plan = model.edge_center_move_plan(
edge_id,
(float(center[0]), float(center[1]), float(center[2]) + 1.0),
)
_assert(str(center_plan.get("status")) != "blocked", f"center move should be available: {center_plan}")
_assert(
str(center_plan.get("edit_strategy_label")) == "Edge整体局部移动",
f"center move should expose a user-facing strategy label: {center_plan}",
)
_assert(
"不是移动整个端面" in str(center_plan.get("edit_semantics") or ""),
f"center move should explain that it is local deformation: {center_plan}",
)
print("Edge coordinate move plan semantics ok")
def main() -> int:
_verify_line_edge_intent_specs()
_verify_circle_edge_intent_specs()
_verify_edge_length_plan_intents()
_verify_edge_coordinate_move_plan_semantics()
print("Edge modeling-intent spec suite passed.")
return 0
+57
View File
@@ -25,10 +25,14 @@ from verify_edge_round_chamfer import (
_cylindrical_faces_near_radius,
_first_adjacent_reference_face,
_first_editable_line_edge,
_first_existing_chamfer_face,
_first_existing_fillet_face,
_verify_chamfer_topology,
_write_box_model,
_write_chamfered_box_model,
_write_chained_filleted_box_model,
_write_filleted_box_model,
_existing_chamfer_faces_near_distance,
)
from verify_ellipse_edge_resize import _ellipse_edge_ids, _write_ellipse_face_model
from verify_hole_resize import (
@@ -267,6 +271,57 @@ def _verify_existing_fillet_isolated(temp_dir: Path) -> None:
print(f"isolated existing fillet ok: face={face_id}, matches={matches[:3]}")
def _verify_existing_fillet_chain_isolated(temp_dir: Path) -> None:
input_path = temp_dir / "existing_fillet_chain.step"
output_path = temp_dir / "existing_fillet_chain_out.step"
request_path = temp_dir / "existing_fillet_chain_request.json"
source_radius = 1.0
target_radius = 1.5
_write_chained_filleted_box_model(input_path, source_radius)
model = StepModel.load(input_path)
face_id = _first_existing_fillet_face(model, source_radius, 2e-4)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if len(chain_face_ids) < 2:
raise SystemExit(f"isolated existing fillet chain source did not expose a chain: {feature}")
_write_request(request_path, input_path, output_path, "resize_existing_fillet", [face_id, target_radius])
response = _run_worker(request_path)
result_model = _load_worker_output(output_path, response)
matches = _cylindrical_faces_near_radius(result_model, target_radius, 2e-4)
old_matches = _cylindrical_faces_near_radius(result_model, source_radius, 2e-4)
if len(matches) < len(chain_face_ids) or old_matches:
raise SystemExit(
"isolated existing fillet chain failed: "
f"matches={matches}, old_matches={old_matches}, chain={chain_face_ids}, response={response}"
)
_assert_one_solid(result_model, "isolated existing fillet chain")
print(f"isolated existing fillet chain ok: face={face_id}, chain={chain_face_ids}, matches={matches[:4]}")
def _verify_existing_chamfer_isolated(temp_dir: Path) -> None:
input_path = temp_dir / "existing_chamfer.step"
output_path = temp_dir / "existing_chamfer_out.step"
request_path = temp_dir / "existing_chamfer_request.json"
source_distance = 1.5
target_distance = 2.0
_write_chamfered_box_model(input_path, source_distance)
model = StepModel.load(input_path)
face_id = _first_existing_chamfer_face(model, source_distance, 2e-4)
_write_request(request_path, input_path, output_path, "resize_existing_chamfer", [face_id, target_distance])
response = _run_worker(request_path)
result_model = _load_worker_output(output_path, response)
matches = _existing_chamfer_faces_near_distance(result_model, target_distance, target_distance * 0.03)
old_matches = _existing_chamfer_faces_near_distance(result_model, source_distance, source_distance * 0.02)
if not matches or old_matches:
raise SystemExit(
f"isolated existing chamfer failed: matches={matches}, old_matches={old_matches}, response={response}"
)
_assert_one_solid(result_model, "isolated existing chamfer")
print(f"isolated existing chamfer ok: face={face_id}, matches={matches[:3]}")
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_edge_isolated_") as temp:
temp_dir = Path(temp)
@@ -278,6 +333,8 @@ def main() -> int:
_verify_edge_fillet_isolated(temp_dir)
_verify_edge_chamfers_isolated(temp_dir)
_verify_existing_fillet_isolated(temp_dir)
_verify_existing_fillet_chain_isolated(temp_dir)
_verify_existing_chamfer_isolated(temp_dir)
print("Edge isolated edit suite passed.")
return 0
+13 -1
View File
@@ -168,7 +168,11 @@ def _assert_cube_edge_intent_geometry(
f"source_count={source_count}, slanted_count={slanted_count}, "
f"expected_slanted={expected_slanted:g}, lengths={_length_distribution_from_values(lengths)}"
)
elif strategy in {"move-edge-end-plane-by-push-pull", "scale-owning-shape-from-edge"}:
elif strategy in {
"move-edge-end-plane-by-push-pull",
"keep-first-level-planar-relations",
"scale-owning-shape-from-edge",
}:
if target_count != 4 or source_count != 8:
raise SystemExit(
f"{strategy} should resize the whole cube span in the selected Edge direction; "
@@ -195,6 +199,7 @@ def main() -> int:
"auto",
"local-edge-only-deform",
"move-edge-end-plane-by-push-pull",
"keep-first-level-planar-relations",
"scale-owning-shape-from-edge",
],
help="Requested Edge length edit semantics.",
@@ -220,6 +225,8 @@ def main() -> int:
expected_strategy = args.expect_strategy or args.strategy
if expected_strategy == "auto":
expected_strategy = strategy
if expected_strategy == "keep-first-level-planar-relations":
expected_strategy = "move-edge-end-plane-by-push-pull"
if strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {strategy or '<none>'}")
if strategy == "move-edge-end-plane-by-push-pull" and plan.get("edge_length_planar_constraint_status") != "ready":
@@ -228,6 +235,11 @@ def main() -> int:
f"status={plan.get('edge_length_planar_constraint_status')}, "
f"blockers={plan.get('edge_length_planar_constraint_blockers')}"
)
if args.strategy == "keep-first-level-planar-relations":
if plan.get("edge_length_planar_relation_constraint_requested") is not True:
raise SystemExit(f"keep-planar-relations plan should preserve explicit constraint intent: {plan}")
if "保持一级平面关系" not in str(plan.get("edge_length_strategy_label") or ""):
raise SystemExit(f"keep-planar-relations plan should use a clear user-facing label: {plan}")
result = model.resize_general_edge_length(
edge_id,
+486 -24
View File
@@ -7,7 +7,7 @@ import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeFillet
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.GeomAbs import GeomAbs_Line
@@ -71,6 +71,14 @@ def _write_filleted_box_model(path: Path, radius: float) -> None:
_write_step(result, path)
def _write_chamfered_box_model(path: Path, distance: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
maker = BRepFilletAPI_MakeChamfer(shape)
maker.Add(float(distance), _first_line_edge(shape))
result = _finalize_builder_result(maker, "verify source box chamfer")
_write_step(result, path)
def _write_chained_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
@@ -92,6 +100,52 @@ def _write_chained_filleted_box_model(path: Path, radius: float) -> None:
_write_step(result, path)
def _write_transitive_chained_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
if len(records) < 6:
raise SystemExit("not enough line edges found in generated box for transitive fillet chain")
maker = BRepFilletAPI_MakeFillet(shape)
for record in records[:6]:
maker.Add(float(radius), record["edge"])
result = _finalize_builder_result(maker, "verify source box transitive fillet chain")
_write_step(result, path)
def _write_complex_same_radius_filleted_box_model(path: Path, radius: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
complex_chain_edge_indices = (0, 1, 2, 4, 9)
if len(records) <= max(complex_chain_edge_indices):
raise SystemExit("not enough line edges found in generated box for complex fillet chain")
maker = BRepFilletAPI_MakeFillet(shape)
for index in complex_chain_edge_indices:
maker.Add(float(radius), records[index]["edge"])
result = _finalize_builder_result(maker, "verify source box complex same-radius fillet chain")
_write_step(result, path)
def _write_mixed_radius_filleted_box_model(path: Path, radius1: float, radius2: float) -> None:
shape = BRepPrimAPI_MakeBox(20.0, 14.0, 10.0).Shape()
records = _line_edge_endpoint_records(shape)
if not records:
raise SystemExit("no line edges found in generated box")
first = records[0]
first_endpoints = {first["start"], first["end"]}
second = None
for candidate in records[1:]:
if candidate["start"] in first_endpoints or candidate["end"] in first_endpoints:
second = candidate
break
if second is None:
raise SystemExit("no adjacent line Edge pair found for mixed-radius fillet source")
maker = BRepFilletAPI_MakeFillet(shape)
maker.Add(float(radius1), first["edge"])
maker.Add(float(radius2), second["edge"])
result = _finalize_builder_result(maker, "verify source box mixed-radius fillet chain")
_write_step(result, path)
def _first_editable_line_edge(model: StepModel) -> int:
candidates: list[tuple[float, int]] = []
for edge_id in range(len(model.edges)):
@@ -151,6 +205,33 @@ def _first_existing_fillet_face(model: StepModel, radius: float, tolerance: floa
raise SystemExit(f"no existing fillet candidate near radius {radius:g}; loose matches: {detail or '<none>'}")
def _existing_chamfer_faces_near_distance(
model: StepModel,
distance: float,
tolerance: float,
) -> list[tuple[int, float, str]]:
matches: list[tuple[int, float, str]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("existing_chamfer_status") != "candidate":
continue
value = float(info.get("existing_chamfer_distance_estimate") or 0.0)
if abs(value - distance) <= tolerance:
matches.append((face_id, value, str(info.get("feature_type") or "")))
return matches
def _first_existing_chamfer_face(model: StepModel, distance: float, tolerance: float) -> int:
matches = _existing_chamfer_faces_near_distance(model, distance, tolerance)
if matches:
return matches[0][0]
detail = ", ".join(
f"Face {face_id}: distance={value:g}, type={feature_type}"
for face_id, value, feature_type in _existing_chamfer_faces_near_distance(model, distance, max(tolerance, distance))
)
raise SystemExit(f"no existing chamfer candidate near distance {distance:g}; loose matches: {detail or '<none>'}")
def _assert_existing_fillet_plan_topology(plan: dict[str, object]) -> None:
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"existing fillet plan should expose first-level depth: {plan}")
@@ -394,17 +475,69 @@ def _run_existing_fillet_arc_length_case(
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing fillet arc-length result did not verify first-level topology: {result}")
print("mode=existing_fillet_arc_length")
def _run_existing_chamfer_case(source_distance: float, target_distance: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_chamfer_") as temp_dir:
model_path = Path(temp_dir) / "chamfered_box.step"
_write_chamfered_box_model(model_path, source_distance)
model = StepModel.load(model_path)
face_id = _first_existing_chamfer_face(model, source_distance, tolerance)
source_info = model.feature_info(face_id)
if source_info.get("feature_type") != "已有倒角平面候选":
raise SystemExit(f"existing chamfer should use a clear feature type: {source_info}")
ready_actions = str(source_info.get("recognition_ready_actions") or "")
if "已有倒角距离" not in ready_actions:
raise SystemExit(f"existing chamfer should expose distance as ready action: {source_info}")
if "当前面面内长度" in ready_actions or "平面拉伸" in ready_actions:
raise SystemExit(f"existing chamfer should not leak generic Face edits: {source_info}")
editable_candidates = model.editable_feature_candidates(limit=20, detailed=False)
editable_chamfers = [
item
for item in editable_candidates
if item.get("operation_key") == "inspect_existing_chamfer" and item.get("target_id") == face_id
]
if not editable_chamfers:
raise SystemExit(f"editable scan did not expose existing chamfer distance: {editable_candidates}")
plane_leaks = [
item
for item in editable_candidates
if item.get("operation_key") == "push_pull_plane" and item.get("target_id") == face_id
]
if plane_leaks:
raise SystemExit(f"editable scan leaked existing chamfer as generic plane edit: {plane_leaks}")
before = model.stats()
plan = model.existing_chamfer_resize_plan(face_id, target_distance)
if plan.get("status") == "blocked":
raise SystemExit(f"existing chamfer plan was blocked: {plan['message']}")
if plan.get("topology_relation_depth") != 1:
raise SystemExit(f"existing chamfer plan should expose first-level depth: {plan}")
result = model.resize_existing_chamfer(face_id, target_distance)
after = model.stats()
matches = _existing_chamfer_faces_near_distance(model, target_distance, max(tolerance, target_distance * 0.03))
old_matches = _existing_chamfer_faces_near_distance(model, source_distance, max(tolerance, source_distance * 0.02))
if after.solids != before.solids:
raise SystemExit(f"existing chamfer resize changed solid count: before={before.solids}, after={after.solids}")
if not matches:
raise SystemExit(f"existing chamfer verification failed: no planar chamfer near distance {target_distance:g}")
if old_matches:
raise SystemExit(f"existing chamfer still has old distance matches: {old_matches}")
if "Existing chamfer result check" not in result:
raise SystemExit(f"existing chamfer result did not report result check: {result}")
if "first_level_topology_matched=True" not in result:
raise SystemExit(f"existing chamfer result did not verify first-level topology: {result}")
print("mode=existing_chamfer")
print(f"face_id={face_id}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"source_radius={source_radius:.6f}")
print(f"target_arc_length={target_arc_length:.6f} target_radius={target_radius:.6f}")
print(f"verified_face={verified_face_id} verified_arc_length={verified_arc:.6f} error={arc_error:.6g}")
print(f"target_distance={target_distance:.6f}")
print(f"matched_faces={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: float, tolerance: float) -> None:
def _run_existing_fillet_chain_resize_case(source_radius: float, target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_") as temp_dir:
model_path = Path(temp_dir) / "fillet_chain_box.step"
_write_chained_filleted_box_model(model_path, source_radius)
@@ -414,17 +547,19 @@ def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: f
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ())
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ())
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"fillet chain should be blocked at recognition level: {feature}")
if feature.get("existing_fillet_status") != "candidate" or feature.get("existing_fillet_risk") != "high":
raise SystemExit(f"same-radius fillet chain should be editable but high risk at recognition level: {feature}")
if feature.get("existing_fillet_chain_status") != "same-radius-chain-candidate":
raise SystemExit(f"same-radius fillet chain status is unclear: {feature}")
recognition_blockers = str(feature.get("recognition_blockers") or "")
recognition_ready_actions = str(feature.get("recognition_ready_actions") or "")
recognition_limited_actions = str(feature.get("recognition_limited_actions") or "")
if feature.get("recognition_decision") != "已阻止" or "圆角链" not in recognition_blockers:
raise SystemExit(f"fillet chain recognition summary should explain the blocker: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"fillet chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"fillet chain should list existing fillet radius as a limited action: {feature}")
if feature.get("recognition_decision") == "已阻止" or recognition_blockers:
raise SystemExit(f"same-radius fillet chain should not be globally blocked: {feature}")
if "已有圆角半径" not in recognition_ready_actions:
raise SystemExit(f"same-radius fillet chain should expose existing fillet radius as ready: {feature}")
if "已有圆角半径" in recognition_limited_actions:
raise SystemExit(f"same-radius fillet chain should not list existing fillet radius as limited: {feature}")
if len(chain_face_ids) < 2 or not chain_adjacent_face_ids:
raise SystemExit(f"fillet chain should expose connected fillet faces: {feature}")
if set(chain_adjacent_face_ids) & set(support_face_ids):
@@ -435,30 +570,312 @@ def _run_existing_fillet_chain_guard_case(source_radius: float, target_radius: f
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
blockers = str(plan.get("blockers") or "")
if plan.get("status") != "blocked":
raise SystemExit(f"existing fillet chain resize should be blocked before geometry execution: {plan}")
if "圆角链" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}":
raise SystemExit(f"fillet chain blocker should explain the unsupported capability: {plan}")
if plan.get("status") == "blocked":
raise SystemExit(f"same-radius existing fillet chain resize should not be blocked: {plan}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"same-radius fillet chain should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"fillet chain plan should retain chain face ids: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"fillet chain plan should resize every chain face: {plan}")
scan_candidates = model.editable_feature_candidates(limit=20, detailed=False)
chain_candidates = [
item
for item in scan_candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
if not chain_candidates:
raise SystemExit(f"same-radius fillet chain should be listed as an editable fillet operation: {scan_candidates}")
before = model.stats()
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(f"same-radius fillet chain result should contain target fillet faces: matches={matches}, result={result}")
if "required_matches" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"same-radius fillet chain result should report chain result checks: {result}")
print("mode=existing_fillet_chain")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"chain_adjacent_face_ids={chain_adjacent_face_ids}")
print(f"support_face_ids={support_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"matches={matches}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_chain_arc_length_case(
source_radius: float,
target_arc_length: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_chain_arc_") as temp_dir:
model_path = Path(temp_dir) / "fillet_chain_box.step"
_write_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
source_info = model.feature_info(face_id)
chain_face_ids = tuple(source_info.get("feature_existing_fillet_chain_face_ids") or ())
if len(chain_face_ids) < 2:
raise SystemExit(f"same-radius fillet chain source should expose connected faces: {source_info}")
angular_span = float(
source_info.get("existing_fillet_angular_span")
or source_info.get("angular_span")
or 0.0
)
if angular_span <= 1e-6:
raise SystemExit(f"same-radius fillet chain has no stable angular span: {source_info}")
target_radius = float(target_arc_length) / angular_span
before = model.stats()
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
raise SystemExit(f"same-radius fillet chain arc-length plan was blocked: {plan['message']}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"same-radius fillet chain arc-length should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"same-radius fillet chain arc-length should resize every chain face: {plan}")
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(
f"same-radius fillet chain arc-length result should contain target fillet faces: "
f"matches={matches}, result={result}"
)
arc_errors: list[tuple[int, float, float]] = []
for verified_face_id, _radius, _span, _guess in matches[: len(chain_face_ids)]:
verified_info = model.feature_info(verified_face_id)
verified_arc = float(
verified_info.get("existing_fillet_arc_length_estimate")
or target_radius * angular_span
)
arc_error = abs(verified_arc - target_arc_length)
arc_errors.append((verified_face_id, verified_arc, arc_error))
if arc_error > max(tolerance * 4.0, target_arc_length * 5e-4):
raise SystemExit(
f"same-radius fillet chain arc-length verification failed: "
f"target={target_arc_length:g}, face={verified_face_id}, value={verified_arc:g}, error={arc_error:g}"
)
if after.solids != before.solids:
raise SystemExit(
f"same-radius fillet chain arc-length resize changed solid count: "
f"before={before.solids}, after={after.solids}"
)
if "required_matches" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"same-radius fillet chain arc-length result should report chain checks: {result}")
print("mode=existing_fillet_chain_arc_length")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"target_arc_length={target_arc_length:.6f}")
print(f"target_radius={target_radius:.6f}")
print(f"matches={matches}")
print(f"arc_errors={arc_errors}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_transitive_chain_case(source_radius: float, target_radius: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_transitive_chain_") as temp_dir:
model_path = Path(temp_dir) / "transitive_fillet_chain_box.step"
_write_transitive_chained_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
chain_rows: list[tuple[int, tuple[int, ...], dict[str, object]]] = []
for candidate_face_id in range(len(model.faces)):
info = model.face_info(candidate_face_id)
if info.get("feature_guess") != "round/fillet candidate":
continue
feature = model.feature_info(candidate_face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if feature.get("existing_fillet_chain_status") == "same-radius-chain-candidate":
chain_rows.append((candidate_face_id, chain_face_ids, feature))
chain_rows.sort(key=lambda item: (-len(item[1]), item[0]))
if not chain_rows:
raise SystemExit("transitive same-radius fillet chain source did not expose an editable chain")
face_id, chain_face_ids, feature = chain_rows[0]
if len(chain_face_ids) != 4:
raise SystemExit(
"transitive same-radius fillet chain should include every connected chain Face, "
f"expected 4, got {chain_face_ids}: {feature}"
)
if feature.get("existing_fillet_status") != "candidate" or feature.get("existing_fillet_risk") != "high":
raise SystemExit(f"transitive same-radius fillet chain should be a high-risk editable candidate: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
if plan.get("status") == "blocked":
raise SystemExit(f"transitive same-radius fillet chain resize should not be blocked: {plan}")
if plan.get("resize_strategy") != "defeature-existing-fillet-chain-then-refillet-axis-edges":
raise SystemExit(f"transitive same-radius fillet chain should use chain refillet strategy: {plan}")
if tuple(plan.get("feature_existing_fillet_resize_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"transitive same-radius fillet chain should resize the full connected chain: {plan}")
before = model.stats()
result = model.resize_existing_fillet(face_id, target_radius)
after = model.stats()
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance, require_fillet_guess=True)
if len(matches) < len(chain_face_ids):
raise SystemExit(f"transitive same-radius fillet chain result should contain target fillet faces: {matches}")
if "required_matches=4" not in result or "first_level_topology_matched=True" not in result:
raise SystemExit(f"transitive same-radius fillet chain result should report four target checks: {result}")
print("mode=existing_fillet_transitive_chain")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"strategy={plan.get('resize_strategy')}")
print(f"before={before}")
print(f"after={after}")
print(f"matches={matches}")
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_mixed_radius_chain_guard_case(
source_radius: float,
adjacent_radius: float,
target_radius: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_mixed_chain_") as temp_dir:
model_path = Path(temp_dir) / "mixed_radius_fillet_chain_box.step"
_write_mixed_radius_filleted_box_model(model_path, source_radius, adjacent_radius)
model = StepModel.load(model_path)
face_id = _first_existing_fillet_face(model, source_radius, tolerance)
feature = model.feature_info(face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
chain_adjacent_face_ids = tuple(feature.get("feature_existing_fillet_chain_adjacent_face_ids") or ())
mixed_radius_face_ids = tuple(feature.get("feature_existing_fillet_mixed_radius_chain_face_ids") or ())
radius_rows = tuple(feature.get("feature_existing_fillet_adjacent_radius_rows") or ())
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids") or ())
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"mixed-radius fillet chain should be blocked at recognition level: {feature}")
if feature.get("existing_fillet_chain_status") != "variable-radius-chain-candidate":
raise SystemExit(f"mixed-radius fillet chain should expose variable-radius status: {feature}")
recognition_blockers = str(feature.get("recognition_blockers") or "")
recognition_ready_actions = str(feature.get("recognition_ready_actions") or "")
recognition_limited_actions = str(feature.get("recognition_limited_actions") or "")
if feature.get("recognition_decision") != "已阻止" or "变半径" not in recognition_blockers:
raise SystemExit(f"mixed-radius fillet recognition summary should explain the blocker: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"mixed-radius chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"mixed-radius chain should list existing fillet radius as a limited action: {feature}")
if len(chain_face_ids) < 2 or not chain_adjacent_face_ids or not mixed_radius_face_ids:
raise SystemExit(f"mixed-radius fillet chain should expose connected fillet faces: {feature}")
if set(chain_adjacent_face_ids) & set(support_face_ids):
raise SystemExit(
"connected mixed-radius fillet faces should not be counted as support faces: "
f"chain_adjacent={chain_adjacent_face_ids}, support={support_face_ids}"
)
if not any(abs(float(row[1]) - adjacent_radius) <= tolerance for row in radius_rows if len(row) >= 2):
raise SystemExit(f"mixed-radius fillet should record adjacent radii: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
blockers = str(plan.get("blockers") or "")
if plan.get("status") != "blocked":
raise SystemExit(f"mixed-radius existing fillet resize should be blocked before geometry execution: {plan}")
if "变半径" not in f"{message} {blockers}" or "暂未实现" not in f"{message} {blockers}":
raise SystemExit(f"mixed-radius blocker should explain the unsupported capability: {plan}")
if tuple(plan.get("feature_existing_fillet_mixed_radius_chain_face_ids") or ()) != mixed_radius_face_ids:
raise SystemExit(f"mixed-radius fillet plan should retain mixed-radius face ids: {plan}")
scan_candidates = model.editable_feature_candidates(limit=30, detailed=False)
leaked_chain_candidates = [
item
for item in scan_candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
if leaked_chain_candidates:
raise SystemExit(f"fillet chain should not be listed as an editable fillet operation: {leaked_chain_candidates}")
raise SystemExit(
f"mixed-radius fillet chain should not be listed as an editable fillet operation: {leaked_chain_candidates}"
)
print("mode=existing_fillet_chain_guard")
print("mode=existing_fillet_mixed_radius_chain_guard")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(f"chain_adjacent_face_ids={chain_adjacent_face_ids}")
print(f"mixed_radius_face_ids={mixed_radius_face_ids}")
print(f"adjacent_radius_rows={radius_rows}")
print(f"support_face_ids={support_face_ids}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
def _run_existing_fillet_complex_same_radius_chain_guard_case(
source_radius: float,
target_radius: float,
tolerance: float,
) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_existing_fillet_complex_same_chain_") as temp_dir:
model_path = Path(temp_dir) / "complex_same_radius_fillet_chain_box.step"
_write_complex_same_radius_filleted_box_model(model_path, source_radius)
model = StepModel.load(model_path)
blocked_rows: list[tuple[int, tuple[int, ...], dict[str, object]]] = []
for candidate_face_id in range(len(model.faces)):
info = model.face_info(candidate_face_id)
if info.get("feature_guess") != "round/fillet candidate":
continue
feature = model.feature_info(candidate_face_id)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
if feature.get("existing_fillet_chain_status") == "complex-same-radius-chain-candidate":
blocked_rows.append((candidate_face_id, chain_face_ids, feature))
blocked_rows.sort(key=lambda item: (-len(item[1]), item[0]))
if not blocked_rows:
raise SystemExit("complex same-radius fillet chain was not recognized as blocked")
face_id, chain_face_ids, feature = blocked_rows[0]
blockers = str(feature.get("existing_fillet_blockers") or "")
recognition_blockers = str(feature.get("recognition_blockers") or "")
recognition_ready_actions = str(feature.get("recognition_ready_actions") or "")
recognition_limited_actions = str(feature.get("recognition_limited_actions") or "")
if len(chain_face_ids) <= 4:
raise SystemExit(f"complex same-radius fillet chain should contain more than 4 faces: {feature}")
if feature.get("existing_fillet_status") != "blocked" or feature.get("existing_fillet_risk") != "blocked":
raise SystemExit(f"complex same-radius fillet chain should be blocked at recognition level: {feature}")
if "复杂长链" not in blockers or "2 到 4" not in blockers:
raise SystemExit(f"complex same-radius blocker should explain the supported chain size: {feature}")
if feature.get("recognition_decision") != "已阻止" or "复杂长链" not in recognition_blockers:
raise SystemExit(f"complex same-radius chain should be globally blocked: {feature}")
if "已有圆角半径" in recognition_ready_actions:
raise SystemExit(f"complex same-radius chain should not expose existing fillet radius as ready: {feature}")
if "已有圆角半径" not in recognition_limited_actions:
raise SystemExit(f"complex same-radius chain should list existing fillet radius as limited: {feature}")
plan = model.existing_fillet_resize_plan(face_id, target_radius)
message = str(plan.get("message") or "")
plan_blockers = str(plan.get("blockers") or "")
if plan.get("status") != "blocked" or plan.get("risk") != "blocked":
raise SystemExit(f"complex same-radius fillet chain resize should be blocked: {plan}")
if "复杂长链" not in f"{message} {plan_blockers}" or "2 到 4" not in f"{message} {plan_blockers}":
raise SystemExit(f"complex same-radius fillet chain plan should explain the limit: {plan}")
if tuple(plan.get("feature_existing_fillet_chain_face_ids") or ()) != chain_face_ids:
raise SystemExit(f"complex same-radius fillet chain plan should retain full chain ids: {plan}")
scan_candidates = model.editable_feature_candidates(limit=30, detailed=False)
leaked_candidates = [
item
for item in scan_candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
if leaked_candidates:
raise SystemExit(
f"complex same-radius fillet chain should not be listed as editable: {leaked_candidates}"
)
print("mode=existing_fillet_complex_same_radius_chain_guard")
print(f"face_id={face_id}")
print(f"chain_face_ids={chain_face_ids}")
print(message.encode("ascii", "backslashreplace").decode("ascii"))
def main() -> int:
parser = argparse.ArgumentParser(description="Verify Edge fillet/chamfer and existing fillet resize operations.")
parser.add_argument(
@@ -472,7 +889,13 @@ def main() -> int:
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_chamfer",
"existing_fillet_chain",
"existing_fillet_chain_arc_length",
"existing_fillet_transitive_chain",
"existing_fillet_chain_guard",
"existing_fillet_complex_same_radius_chain_guard",
"existing_fillet_mixed_radius_chain_guard",
],
help="Edge rounding/chamfering edit mode to verify.",
)
@@ -483,8 +906,11 @@ def main() -> int:
parser.add_argument("--distance-angle-distance", type=float, default=1.0)
parser.add_argument("--distance-angle-degrees", type=float, default=45.0)
parser.add_argument("--source-fillet-radius", type=float, default=1.0)
parser.add_argument("--adjacent-fillet-radius", type=float, default=1.8)
parser.add_argument("--target-fillet-radius", type=float, default=1.5)
parser.add_argument("--target-fillet-arc-length", type=float, default=2.356194490192345)
parser.add_argument("--source-chamfer-distance", type=float, default=1.5)
parser.add_argument("--target-chamfer-distance", type=float, default=2.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
@@ -496,7 +922,12 @@ def main() -> int:
"distance_angle_chamfer",
"existing_fillet",
"existing_fillet_arc_length",
"existing_fillet_chain_guard",
"existing_chamfer",
"existing_fillet_chain",
"existing_fillet_chain_arc_length",
"existing_fillet_transitive_chain",
"existing_fillet_complex_same_radius_chain_guard",
"existing_fillet_mixed_radius_chain_guard",
]
if args.mode == "all"
else [args.mode]
@@ -518,8 +949,39 @@ def main() -> int:
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_fillet_chain_guard":
_run_existing_fillet_chain_guard_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
elif mode == "existing_chamfer":
_run_existing_chamfer_case(
args.source_chamfer_distance,
args.target_chamfer_distance,
args.tolerance,
)
elif mode in {"existing_fillet_chain", "existing_fillet_chain_guard"}:
_run_existing_fillet_chain_resize_case(args.source_fillet_radius, args.target_fillet_radius, args.tolerance)
elif mode == "existing_fillet_chain_arc_length":
_run_existing_fillet_chain_arc_length_case(
args.source_fillet_radius,
args.target_fillet_arc_length,
args.tolerance,
)
elif mode == "existing_fillet_transitive_chain":
_run_existing_fillet_transitive_chain_case(
args.source_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
elif mode == "existing_fillet_complex_same_radius_chain_guard":
_run_existing_fillet_complex_same_radius_chain_guard_case(
args.source_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
elif mode == "existing_fillet_mixed_radius_chain_guard":
_run_existing_fillet_mixed_radius_chain_guard_case(
args.source_fillet_radius,
args.adjacent_fillet_radius,
args.target_fillet_radius,
args.tolerance,
)
else:
raise SystemExit(f"unsupported mode: {mode}")
return 0
+16
View File
@@ -14,6 +14,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face width, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "width", "--target-size", "15"),
),
(
"face width, keep first-level planar relations",
("verify_face_resize_semantics.py", "--strategy", "keep_relations", "--axis", "width", "--target-size", "15"),
),
(
"Face first-level shared-edge topology",
("verify_face_first_level_topology.py",),
@@ -30,6 +34,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face height, current face only",
("verify_face_resize_semantics.py", "--strategy", "local", "--axis", "height", "--target-size", "15"),
),
(
"face height, keep first-level planar relations",
("verify_face_resize_semantics.py", "--strategy", "keep_relations", "--axis", "height", "--target-size", "15"),
),
(
"face height, owning feature",
("verify_face_resize_semantics.py", "--strategy", "owning", "--axis", "height", "--target-size", "15"),
@@ -50,6 +58,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face center, current face only",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "local", "--center-offset", "2,0,3"),
),
(
"face center, keep first-level planar relations",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "keep_relations", "--center-offset", "2,0,3"),
),
(
"face center, owning feature",
("verify_face_resize_semantics.py", "--property", "center", "--strategy", "owning", "--center-offset", "2,0,3"),
@@ -58,6 +70,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
"face offset, push pull",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "1"),
),
(
"face offset, keep first-level planar relations",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "keep_relations", "--offset-distance", "1"),
),
(
"face offset, push pull inward cut",
("verify_face_resize_semantics.py", "--property", "offset", "--strategy", "push_pull", "--offset-distance", "-1"),
@@ -15,12 +15,12 @@ from scripts.verify_large_stepped_cap_push_pull import _large_multi_boundary_cap
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
FACE_ID = 594
BASE_FACE_KEYS = (
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
)
RESULT_ONLY_KEYS = ("area",)
TEMPORARILY_HIDDEN_KEYS = ("face_center_position",)
class _Probe(WindowStateMixin):
@@ -71,6 +71,12 @@ def _assert_contains(keys: tuple[str, ...], expected: tuple[str, ...], label: st
raise AssertionError(f"{label} missing {missing}, got {keys}")
def _assert_absent(keys: tuple[str, ...], forbidden: tuple[str, ...], label: str) -> None:
leaked = [key for key in forbidden if key in keys]
if leaked:
raise AssertionError(f"{label} should keep result-only values out of feature parameters: {leaked}")
def _assert_face_594_stays_stable_after_remote_cap_edit() -> None:
model = StepModel.load(MODEL_PATH)
source_face_id = _large_stepped_cap_face(model)
@@ -123,16 +129,21 @@ def main() -> int:
before_cached_rows = _feature_rows(model, FACE_ID)
_assert_contains(before_cached_rows, BASE_FACE_KEYS, "Face 594 before full feature cache")
_assert_absent(before_cached_rows, RESULT_ONLY_KEYS, "Face 594 before full feature cache")
_assert_absent(before_cached_rows, TEMPORARILY_HIDDEN_KEYS, "Face 594 before full feature cache")
full_info = model.feature_info(FACE_ID)
if full_info.get("shell_region_status") == "candidate":
dimension_keys = _feature_dimension_keys(full_info)
_assert_contains(dimension_keys, BASE_FACE_KEYS, "shell candidate feature dimensions")
_assert_absent(dimension_keys, RESULT_ONLY_KEYS, "shell candidate feature dimensions")
if "shell_thickness_estimate" not in dimension_keys:
raise AssertionError(f"shell candidate should keep shell thickness as an extra dimension: {dimension_keys}")
after_cached_rows = _feature_rows(model, FACE_ID)
_assert_contains(after_cached_rows, BASE_FACE_KEYS, "Face 594 after full feature cache")
_assert_absent(after_cached_rows, RESULT_ONLY_KEYS, "Face 594 after full feature cache")
_assert_absent(after_cached_rows, TEMPORARILY_HIDDEN_KEYS, "Face 594 after full feature cache")
if before_cached_rows != after_cached_rows:
raise AssertionError(
"Face 594 current-only feature rows changed after full recognition cache: "
+8 -2
View File
@@ -144,12 +144,18 @@ def _assert_selection_exposes_first_level(model: StepModel, face_id: int) -> Non
raise SystemExit(f"Selected Face should expose 4 first-level adjacent Faces, got {feature_info}")
editable_specs, _used = probe._editable_property_specs(feature_info)
feature_rows = probe._feature_property_specs(editable_specs, feature_info)
topology_row = _spec(feature_rows, "face_first_level_topology")
topology_row = _spec(editable_specs, "face_first_level_topology")
topology_text = str(topology_row.get("current_text") or "")
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
if fragment not in topology_text:
raise SystemExit(f"Selected Face topology row is not clear enough: {topology_row}")
feature_rows = probe._feature_property_specs(editable_specs, feature_info)
feature_row_keys = {str(spec.get("key", "")) for spec in feature_rows}
if "face_first_level_topology" in feature_row_keys:
raise SystemExit(
"Feature parameter table should keep first-level topology in diagnostics, "
f"not in editable feature rows: {feature_rows}"
)
def main() -> int:
+5 -2
View File
@@ -120,8 +120,11 @@ def main() -> int:
feature_keys = {str(spec.get("key")) for spec in feature_rows}
if "no_editable_feature_dimensions" not in feature_keys:
raise SystemExit(f"feature mode should say there are no reliable dimensions: {feature_rows}")
if "freeform_surface_edit_semantics" not in feature_keys:
raise SystemExit(f"feature mode should keep the freeform limitation explanation: {feature_rows}")
if "freeform_surface_edit_semantics" in feature_keys:
raise SystemExit(
"feature parameter table should keep freeform limitations in diagnostics, "
f"not mixed into editable parameters: {feature_rows}"
)
_assert_no_editable_actions(feature_rows, "feature mode rows")
print(
+29 -1
View File
@@ -166,6 +166,19 @@ def _assert_local_scope_explains_curved_owner(specs: list[dict[str, object]], ke
raise SystemExit(f"{key}/local disabled tip should mention curved owner: {disabled_tip}")
def _assert_keep_relations_blocked(plan: dict[str, object], label: str, *expected_fragments: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked before execution: {plan}")
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"{label} should record the requested keep-relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "blocked":
raise SystemExit(f"{label} should expose blocked keep-relation constraint status: {plan}")
message = str(plan.get("message") or "") + " " + str(plan.get("blockers") or "")
for fragment in expected_fragments:
if fragment not in message:
raise SystemExit(f"{label} blocker should mention {fragment!r}: {plan}")
def _bbox_height(model: StepModel) -> float:
info = model.part_info(1)
bbox_size = info.get("bbox_size")
@@ -199,6 +212,12 @@ def main() -> int:
_assert_blocked(model.face_center_local_move_plan(face_id, target_center), "Face center local move")
_assert_blocked(model.face_area_local_resize_plan(face_id, area * 1.1), "Face area local resize")
_assert_blocked(model.face_plane_offset_local_plan(face_id, 1.0), "Face plane offset local move")
_assert_keep_relations_blocked(
model.push_pull_keep_relations_plan(face_id, 1.0),
"planar cylinder cap keep-relations push/pull",
"非平面",
"平面邻域",
)
specs = _specs(face_id, info)
semantics = _spec(specs, "face_edit_semantics")
@@ -206,11 +225,20 @@ def main() -> int:
raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}")
if "曲面" not in str(semantics.get("disabled_tip") or ""):
raise SystemExit(f"Face edit semantics tip should include the curved-owner blocker: {semantics}")
for key in ("area", "face_center_position", "face_target_normal_position"):
for key in ("face_center_position", "face_target_normal_position"):
_assert_local_scope_explains_curved_owner(specs, key)
push_pull_mode = _scope_mode(specs, "face_target_normal_position", "push_pull")
if not bool(push_pull_mode.get("enabled", False)):
raise SystemExit("planar cylinder cap push/pull scope should remain available")
keep_relations_mode = _scope_mode(specs, "face_target_normal_position", "keep_relations")
if bool(keep_relations_mode.get("enabled", True)):
raise SystemExit(f"planar cylinder cap keep-relations scope should be disabled: {keep_relations_mode}")
keep_relations_tip = str(keep_relations_mode.get("disabled_tip") or "")
if "非平面" not in keep_relations_tip and "曲面" not in keep_relations_tip:
raise SystemExit(
"planar cylinder cap keep-relations disabled tip should mention non-planar adjacency: "
f"{keep_relations_tip}"
)
huge_push_plan = model.push_pull_plan(face_id, 50.0)
if huge_push_plan.get("status") == "blocked":
@@ -32,6 +32,22 @@ def _write_triangular_prism(path: Path) -> None:
_write_step(prism, path)
def _write_trapezoid_prism(path: Path) -> None:
polygon = BRepBuilderAPI_MakePolygon()
polygon.Add(gp_Pnt(0.0, 0.0, 0.0))
polygon.Add(gp_Pnt(12.0, 0.0, 0.0))
polygon.Add(gp_Pnt(10.0, 0.0, 6.0))
polygon.Add(gp_Pnt(0.0, 0.0, 8.0))
polygon.Close()
if hasattr(polygon, "IsDone") and not polygon.IsDone():
raise RuntimeError("Could not create trapezoid prism profile.")
face_maker = BRepBuilderAPI_MakeFace(polygon.Wire())
if hasattr(face_maker, "IsDone") and not face_maker.IsDone():
raise RuntimeError("Could not create trapezoid prism face.")
prism = BRepPrimAPI_MakePrism(face_maker.Face(), gp_Vec(0.0, 6.0, 0.0)).Shape()
_write_step(prism, path)
def _triangle_face_id(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
@@ -42,6 +58,19 @@ def _triangle_face_id(model: StepModel) -> int:
raise SystemExit("no triangular planar Face was found")
def _sloped_quad_face_id(model: StepModel) -> int:
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
normal = info.get("normal") or info.get("push_pull_outward_direction")
if not isinstance(normal, tuple) or len(normal) != 3:
continue
if abs(float(normal[0])) > 0.1 and abs(float(normal[2])) > 0.1:
return face_id
raise SystemExit("no sloped quadrilateral planar Face was found")
def _triangle_infos(model: StepModel) -> list[dict[str, object]]:
infos: list[dict[str, object]] = []
for face_id in range(len(model.faces)):
@@ -89,6 +118,20 @@ def _assert_not_blocked(plan: dict[str, object], label: str) -> None:
raise SystemExit(f"{label} should be available for a simple triangular Face: {plan}")
def _assert_keep_relations_blocked_for_angled(plan: dict[str, object], label: str) -> None:
if plan.get("status") != "blocked":
raise SystemExit(f"{label} should be blocked before execution: {plan}")
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"{label} should record the requested keep-relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "blocked":
raise SystemExit(f"{label} should expose blocked keep-relation constraint status: {plan}")
if int(plan.get("face_push_pull_planar_constraint_angled_count", 0) or 0) <= 0:
raise SystemExit(f"{label} should count at least one angled relation: {plan}")
message = str(plan.get("message") or "") + " " + str(plan.get("blockers") or "")
if "斜交" not in message:
raise SystemExit(f"{label} blocker should explain angled first-level planar relations: {plan}")
def _assert_single_solid(model: StepModel, label: str) -> None:
stats = model.stats()
if stats.solids != 1:
@@ -162,6 +205,15 @@ def main() -> int:
model.push_pull_face(face_id, 1.0)
_assert_single_solid(model, "triangular Face push/pull")
wedge_path = Path(temp_dir) / "trapezoid_prism.step"
_write_trapezoid_prism(wedge_path)
wedge = StepModel.load(wedge_path)
wedge_face_id = _sloped_quad_face_id(wedge)
plan = wedge.push_pull_plan(wedge_face_id, 0.5)
_assert_not_blocked(plan, "sloped planar Face push/pull")
keep_plan = wedge.push_pull_keep_relations_plan(wedge_face_id, 0.5)
_assert_keep_relations_blocked_for_angled(keep_plan, "sloped planar Face keep-relations push/pull")
model, face_id, info = _fresh_model(path)
current_area = float(info.get("area") or 0.0)
target_area = current_area * 1.44
+41 -3
View File
@@ -15,16 +15,20 @@ DEFAULT_MODEL = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
FACE_CASES = (
("size", "local", "width"),
("size", "keep_relations", "width"),
("size", "owning", "width"),
("size", "local", "height"),
("size", "keep_relations", "height"),
("size", "owning", "height"),
("area", "local", "width"),
("area", "owning", "width"),
("center", "local", "width"),
("center", "keep_relations", "width"),
("center", "owning", "width"),
("offset", "local", "width"),
("offset", "owning", "width"),
("offset", "push_pull", "width"),
("offset", "keep_relations", "width"),
)
@@ -170,7 +174,7 @@ def main() -> int:
parser.add_argument("--target-area", type=float, default=144.0)
parser.add_argument("--center-offset", default="2,0,3")
parser.add_argument("--offset-distance", type=float, default=1.0)
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull"])
parser.add_argument("--strategy", default="local", choices=["local", "owning", "push_pull", "keep_relations"])
parser.add_argument("--tolerance", type=float, default=1e-5)
args = parser.parse_args()
@@ -184,11 +188,17 @@ def main() -> int:
if args.strategy == "push_pull" and args.property != "offset":
raise SystemExit("--strategy push_pull is only valid with --property offset")
if args.strategy == "keep_relations" and args.property not in {"size", "center", "offset"}:
raise SystemExit("--strategy keep_relations is only valid with --property size/center/offset")
if args.property == "size" and args.strategy == "local":
plan = model.face_size_local_resize_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local(face_id, args.target_size, args.axis)
expected_strategy = f"local-face-{args.axis}-only-deform"
elif args.property == "size" and args.strategy == "keep_relations":
plan = model.face_size_local_resize_keep_relations_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_local_keep_relations(face_id, args.target_size, args.axis)
expected_strategy = f"axis-scale-owning-shape-from-face-{args.axis}"
elif args.property == "size":
plan = model.face_size_owning_scale_plan(face_id, args.target_size, args.axis)
result = model.resize_face_size_owning_scale(face_id, args.target_size, args.axis)
@@ -201,7 +211,7 @@ def main() -> int:
plan = model.face_area_scale_plan(face_id, args.target_area)
result = model.resize_face_area(face_id, args.target_area)
expected_strategy = "uniform-scale-face-area-fallback"
elif args.property == "center" and args.strategy == "local":
elif args.property == "center" and args.strategy in {"local", "keep_relations"}:
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
@@ -211,6 +221,11 @@ def main() -> int:
float(current_center[1]) + offset[1],
float(current_center[2]) + offset[2],
)
if args.strategy == "keep_relations":
plan = model.face_center_local_move_keep_relations_plan(face_id, target_center)
result = model.move_face_center_local_keep_relations(face_id, target_center)
expected_strategy = "translate-owning-shape-from-face-center"
else:
plan = model.face_center_local_move_plan(face_id, target_center)
result = model.move_face_center_local(face_id, target_center)
expected_strategy = "local-face-only-deform"
@@ -259,7 +274,7 @@ def main() -> int:
plan = model.face_plane_offset_owning_translation_plan(face_id, args.offset_distance)
result = model.translate_face_plane_offset_owning(face_id, args.offset_distance)
expected_strategy = "translate-owning-shape-from-plane-offset"
else:
elif args.property == "offset" and args.strategy == "push_pull":
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
@@ -275,12 +290,35 @@ def main() -> int:
plan = model.push_pull_plan(face_id, args.offset_distance)
result = model.push_pull_face(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face"
else:
frame = model.face_plane_offset_frame(face_id)
if frame is None:
raise SystemExit("selected Face does not have a stable plane offset frame")
current_center = current_info.get("area_center") or current_info.get("bbox_center")
if not isinstance(current_center, tuple) or len(current_center) != 3:
raise SystemExit("selected Face does not have a stable center")
_origin, direction, _position = frame
target_center = (
float(current_center[0]) + direction[0] * args.offset_distance,
float(current_center[1]) + direction[1] * args.offset_distance,
float(current_center[2]) + direction[2] * args.offset_distance,
)
plan = model.push_pull_keep_relations_plan(face_id, args.offset_distance)
result = model.push_pull_face_keep_relations(face_id, args.offset_distance)
expected_strategy = "push-pull-planar-face-keep-relations"
resolved_strategy = str(plan.get("resize_strategy", ""))
if args.property == "offset" and args.strategy == "push_pull":
resolved_strategy = "push-pull-planar-face"
if resolved_strategy != expected_strategy:
raise SystemExit(f"expected {expected_strategy}, got {resolved_strategy or '<none>'}")
if args.strategy == "keep_relations":
if plan.get("face_push_pull_planar_relation_constraint_requested") is not True:
raise SystemExit(f"keep-relations plan should record requested relation constraint: {plan}")
if plan.get("face_push_pull_planar_constraint_status") != "ready":
raise SystemExit(f"keep-relations plan should be ready on cube Face: {plan}")
if "Planar relation check: ok" not in result:
raise SystemExit(f"keep-relations result should include planar relation check, got: {result}")
if "Face result check:" not in result:
raise SystemExit(f"Face edit result message should include a result check, got: {result}")
if "First-level check:" not in result:
+36 -4
View File
@@ -14,16 +14,22 @@ if str(PROJECT_ROOT) not in sys.path:
EXPECTED_FACE_ISOLATED_OPERATIONS = {
"push_pull_face",
"push_pull_face_keep_relations",
"translate_face_plane_offset_owning",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
"resize_face_size_local",
"resize_face_size_local_keep_relations",
"resize_face_size_owning_scale",
"move_face_center_local",
"move_face_center_local_keep_relations",
"resize_shell_thickness",
"resize_shell_thickness_owning_scale",
"resize_cylindrical_height",
"resize_cylindrical_boss_height",
"resize_cylindrical_boss",
"move_cylindrical_boss_axis",
"resize_cylindrical_height_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
@@ -33,6 +39,10 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = {
EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS = {
"resize_cylindrical_hole",
"edit_cylindrical_holes_by_refs",
"resize_cylindrical_holes_by_refs",
"move_cylindrical_holes_by_offset",
"suppress_cylindrical_holes_by_refs",
"resize_cylindrical_owning_scale",
"move_cylindrical_hole_axis",
"suppress_cylindrical_hole",
@@ -54,6 +64,7 @@ EXPECTED_EDGE_ISOLATED_OPERATIONS = {
"move_circular_edge_axis_center",
"resize_ellipse_edge_axis_radius",
"resize_existing_fillet",
"resize_existing_chamfer",
"fillet_edge",
"chamfer_edge",
"chamfer_edge_asymmetric",
@@ -121,14 +132,14 @@ def _assert_same(label: str, actual: set[str], expected: set[str]) -> None:
PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"push_pull_face": "push_pull_face",
"push_pull_face_keep_relations": "push_pull_face_keep_relations",
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"resize_face_area_local": "resize_face_area_local",
"resize_face_area": "resize_face_area",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_height_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
"resize_face_height_owning_scale": "resize_face_size_owning_scale",
"move_selected_face_center_local": "move_face_center_local",
"resize_shell_thickness": "resize_shell_thickness",
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
"resize_cylinder_height": "resize_cylindrical_height",
@@ -139,6 +150,7 @@ PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
"resize_sphere_radius": "resize_sphere_radius",
"resize_torus_major_radius": "resize_torus_radius",
"resize_torus_minor_radius": "resize_torus_radius",
"resize_existing_chamfer": "resize_existing_chamfer",
}
@@ -151,10 +163,16 @@ PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD = {
ACTION_IMPLEMENTATION_METHOD = {
"push_pull_face": "_push_pull_face_action",
"push_pull_face_keep_relations": "_push_pull_face_action",
"resize_face_width_local": "_resize_face_size_local",
"resize_face_height_local": "_resize_face_size_local",
"resize_face_width_keep_relations": "_resize_face_size_local",
"resize_face_height_keep_relations": "_resize_face_size_local",
"resize_face_width_owning_scale": "_resize_face_size_owning_scale",
"resize_face_height_owning_scale": "_resize_face_size_owning_scale",
"move_selected_face_center_local": "_move_selected_face_center_local",
"move_selected_face_center_keep_relations": "_move_selected_face_center_local",
"resize_torus_major_radius": "_resize_torus_radius",
"resize_torus_minor_radius": "_resize_torus_radius",
}
@@ -221,6 +239,8 @@ def _face_property_actions_from_specs() -> set[str]:
"bbox_center": (5.0, 5.0, 0.0),
"local_face_width": 10.0,
"local_face_height": 10.0,
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
"local_face_size_center": (5.0, 5.0, 0.0),
"plane_origin": (0.0, 0.0, 0.0),
"push_pull_outward_direction": (0.0, 0.0, 1.0),
@@ -270,13 +290,25 @@ def _face_property_actions_from_specs() -> set[str]:
"area",
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
},
),
(shell_info, {"shell_thickness_estimate"}),
(generic_cylinder_info, {"cylinder_height"}),
(boss_info, {"boss_height"}),
(
{
**plane_info,
"feature_guess": "chamfer candidate",
"feature_type": "已有倒角平面候选",
"existing_chamfer_status": "candidate",
"existing_chamfer_distance_estimate": 1.5,
"existing_chamfer_cross_edge_length_estimate": 2.12132,
"feature_existing_chamfer_support_face_ids": (0, 5),
"feature_existing_chamfer_long_edge_ids": (8,),
},
{"existing_chamfer_distance_estimate"},
),
(
{
"surface": "cone",
+27 -5
View File
@@ -51,12 +51,18 @@ def main() -> int:
"feature_guess": "round/fillet candidate",
"angular_span": math.pi / 2.0,
},
("existing_fillet_radius_estimate",),
("existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"),
)
assert_keys(
{"surface": "plane"},
(
"area",
"face_center_position",
"face_target_normal_position",
),
)
assert_keys(
{"surface": "plane", "local_face_size_edit_ready": True},
(
"local_face_width",
"local_face_height",
"face_center_position",
@@ -66,7 +72,14 @@ def main() -> int:
assert_keys(
{"surface": "plane", "shell_region_status": "candidate"},
(
"area",
"face_center_position",
"face_target_normal_position",
"shell_thickness_estimate",
),
)
assert_keys(
{"surface": "plane", "shell_region_status": "candidate", "local_face_size_edit_ready": True},
(
"local_face_width",
"local_face_height",
"face_center_position",
@@ -80,7 +93,16 @@ def main() -> int:
"prismatic_profile_status": "candidate",
"prismatic_extrusion_status": "candidate",
},
("local_face_width", "local_face_height", "shell_thickness_estimate"),
("face_target_normal_position", "shell_thickness_estimate"),
)
assert_keys(
{
"surface": "plane",
"prismatic_profile_status": "candidate",
"prismatic_extrusion_status": "candidate",
"local_face_size_edit_ready": True,
},
("local_face_width", "local_face_height", "face_target_normal_position", "shell_thickness_estimate"),
)
assert_keys(
{"surface": "torus"},
@@ -123,7 +145,7 @@ def main() -> int:
},
)
filtered_keys = tuple(str(spec.get("key")) for spec in filtered)
if filtered_keys != ("diameter", "hole_edit_semantics"):
if filtered_keys != ("diameter",):
raise AssertionError(f"unexpected filtered feature parameters: {filtered_keys}")
print("feature parameter policy ok")
+314 -1
View File
@@ -17,9 +17,15 @@ from step_editor.step_io import _write_step
from step_editor.ui_helpers import INFO_LABELS
from verify_hole_resize import _first_hole_face, _write_blind_hole_model, _write_through_hole_model # noqa: E402
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
from verify_slot_resize import _first_slot_face, _write_cross_obround_slot_model, _write_half_round_slot_model # noqa: E402
from verify_boss_resize import _first_boss_face, _write_boss_model # noqa: E402
from verify_ellipse_edge_resize import _write_ellipse_face_model # noqa: E402
from verify_edge_round_chamfer import ( # noqa: E402
_first_existing_fillet_face,
_write_filleted_box_model,
_write_mixed_radius_filleted_box_model,
)
from verify_shell_thickness_resize import _first_open_shell_wall_face, _write_open_thin_wall_box_model # noqa: E402
def _assert(condition: bool, message: str) -> None:
@@ -59,6 +65,66 @@ def _first_surface_face(model: StepModel, surface: str) -> int:
raise AssertionError(f"no {surface} Face was found")
def _first_adjacent_face(model: StepModel, face_id: int) -> int:
edge_ids = model._face_boundary_edge_ids(face_id) # noqa: SLF001
adjacent_ids = sorted(model._adjacent_face_ids_for_edges(edge_ids, face_id)) # noqa: SLF001
for adjacent_id in adjacent_ids:
if 0 <= int(adjacent_id) < len(model.faces):
return int(adjacent_id)
raise AssertionError(f"Face {face_id} has no adjacent Face")
def _install_synthetic_asitus_support(
model: StepModel,
face_id: int,
*,
relation_type: str,
angle_type: str,
) -> int:
adjacent_id = _first_adjacent_face(model, face_id)
face_info = model.quick_face_info(face_id)
adjacent_info = model.quick_face_info(adjacent_id)
result = {
"ok": True,
"reason": "ok",
"groups": (),
"faces": (
{
"id": face_id + 1,
"surface": str(face_info.get("surface") or ""),
"neighbor_ids": (adjacent_id + 1,),
},
{
"id": adjacent_id + 1,
"surface": str(adjacent_info.get("surface") or ""),
"neighbor_ids": (face_id + 1,),
},
),
"adjacency": (
{
"face_ids": (face_id + 1, adjacent_id + 1),
"angle_type": angle_type,
"angle_rad": 0.0,
"edge_ids": (),
},
),
"geometric_relations": (
{
"face_ids": (face_id + 1, adjacent_id + 1),
"relation_type": relation_type,
"residual": 0.0,
"source": "synthetic-analysis-situs-test",
},
),
"surface_summary": {},
"angle_summary": {angle_type: 1},
"geometric_relation_summary": {relation_type: 1},
"geometric_relation_mode": "synthetic-test",
}
model.install_asitus_hole_recognition_result(result)
return adjacent_id
def _first_quick_candidate(
model: StepModel,
*,
@@ -229,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}")
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:
path = root / "boss.step"
_write_boss_model(path)
@@ -240,6 +353,79 @@ def _verify_boss_summary(root: Path) -> None:
_assert(int(info.get("recognition_user_priority", 99)) == 40, f"boss should use boss priority: {info}")
def _assert_asitus_hint(
info: dict[str, object],
*,
preferred: str,
label: str,
) -> None:
_assert(
info.get("analysis_situs_feature_hint_preferred") == preferred,
f"{label}: Analysis Situs hint should prefer {preferred}: {info}",
)
_assert(
int(info.get("analysis_situs_feature_hint_score") or 0) > 0,
f"{label}: Analysis Situs hint score is missing: {info}",
)
_assert(
"analysis_situs_feature_hint" in set(info.get("recognition_evidence_keys") or ()),
f"{label}: recognition evidence should mention Analysis Situs feature hint: {info}",
)
def _verify_asitus_slot_boss_fillet_hints(root: Path) -> None:
slot_path = root / "asitus_slot_hint.step"
_write_half_round_slot_model(slot_path)
slot_model = StepModel.load(slot_path)
slot_face_id = _first_slot_face(slot_model)
_install_synthetic_asitus_support(slot_model, slot_face_id, relation_type="tangent", angle_type="smooth")
slot_info = slot_model.feature_info(slot_face_id)
_assert_asitus_hint(slot_info, preferred="slot", label="slot hint")
slot_candidates = [
item
for item in slot_model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("face_id", -1) or -1) == slot_face_id
]
_assert(
any(int(item.get("analysis_situs_slot_hint_score") or 0) > 0 for item in slot_candidates),
f"slot candidates should carry Analysis Situs slot support: {slot_candidates}",
)
boss_path = root / "asitus_boss_hint.step"
_write_boss_model(boss_path)
boss_model = StepModel.load(boss_path)
boss_face_id = _first_boss_face(boss_model)
_install_synthetic_asitus_support(boss_model, boss_face_id, relation_type="parallel", angle_type="convex")
boss_info = boss_model.feature_info(boss_face_id)
_assert_asitus_hint(boss_info, preferred="boss", label="boss hint")
boss_candidates = [
item
for item in boss_model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("face_id", -1) or -1) == boss_face_id
]
_assert(
any(int(item.get("analysis_situs_boss_hint_score") or 0) > 0 for item in boss_candidates),
f"boss candidates should carry Analysis Situs boss support: {boss_candidates}",
)
fillet_path = root / "asitus_fillet_hint.step"
_write_filleted_box_model(fillet_path, 1.0)
fillet_model = StepModel.load(fillet_path)
fillet_face_id = _first_existing_fillet_face(fillet_model, 1.0, 2e-4)
_install_synthetic_asitus_support(fillet_model, fillet_face_id, relation_type="tangent", angle_type="smooth")
fillet_info = fillet_model.feature_info(fillet_face_id)
_assert_asitus_hint(fillet_info, preferred="fillet", label="fillet hint")
fillet_candidates = [
item
for item in fillet_model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("face_id", -1) or -1) == fillet_face_id
]
_assert(
any(int(item.get("analysis_situs_fillet_hint_score") or 0) > 0 for item in fillet_candidates),
f"fillet candidates should carry Analysis Situs fillet support: {fillet_candidates}",
)
def _verify_torus_summary(root: Path) -> None:
path = root / "torus.step"
_write_step(BRepPrimAPI_MakeTorus(12.0, 2.0).Shape(), path)
@@ -267,6 +453,34 @@ def _verify_user_priority_scan_order(root: Path) -> None:
)
def _verify_candidate_scan_cache(root: Path) -> None:
path = root / "candidate_cache.step"
_write_through_hole_model(path)
model = StepModel.load(path)
first = model.editable_feature_candidates(limit=16, detailed=False, max_scan_faces=120, max_scan_edges=120)
_assert(first, "editable candidate cache probe returned no candidates")
cache = getattr(model, "_editable_feature_candidates_cache", {})
_assert(cache, "editable candidate scan should populate the model-level cache")
first[0]["operation_key"] = "mutated-by-caller"
second = model.editable_feature_candidates(limit=16, detailed=False, max_scan_faces=120, max_scan_edges=120)
_assert(
second[0].get("operation_key") != "mutated-by-caller",
"editable candidate cache should return defensive copies",
)
cylinders = model.cylindrical_feature_candidates(limit=12, include_end_info=True, max_scan_faces=120)
_assert(cylinders, "cylindrical candidate cache probe returned no candidates")
cylinder_cache = getattr(model, "_cylindrical_feature_candidates_cache", {})
_assert(cylinder_cache, "cylindrical candidate scan should populate the model-level cache")
cylinders[0]["feature_guess"] = "mutated-by-caller"
second_cylinders = model.cylindrical_feature_candidates(limit=12, include_end_info=True, max_scan_faces=120)
_assert(
second_cylinders[0].get("feature_guess") != "mutated-by-caller",
"cylindrical candidate cache should return defensive copies",
)
def _verify_ellipse_edge_scan_entries(root: Path) -> None:
path = root / "ellipse_edge_scan.step"
_write_ellipse_face_model(path)
@@ -288,6 +502,89 @@ def _verify_ellipse_edge_scan_entries(root: Path) -> None:
raise AssertionError(f"ellipse Edge scan should not expose generic length editing: {item}")
def _verify_complex_slot_guard(root: Path) -> None:
path = root / "cross_obround_slot.step"
_write_cross_obround_slot_model(path)
model = StepModel.load(path)
blocked_slot_faces: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
feature = model.feature_info(face_id)
if feature.get("slot_kind") == "partial-cylindrical-groove" and feature.get("slot_status") == "blocked":
blocked_slot_faces.append(face_id)
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
_assert("交叉槽" in blockers or "多槽组" in blockers, f"complex slot blocker is unclear: {feature}")
_assert("复杂槽" in str(feature.get("feature_type") or ""), f"complex slot label is unclear: {feature}")
_assert(blocked_slot_faces, "cross obround slot should be recognized as blocked complex slots")
candidates = model.editable_feature_candidates(limit=80, detailed=False)
forbidden_actions = {
"resize_cylinder",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_angular_span",
}
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if int(item.get("target_id", -1)) in blocked_slot_faces and item.get("operation_key") in forbidden_actions
]
_assert(not leaked, f"complex slot should not leak editable scan entries: {leaked}")
def _verify_mixed_radius_fillet_chain_guard(root: Path) -> None:
path = root / "mixed_radius_fillet_chain.step"
_write_mixed_radius_filleted_box_model(path, 1.0, 1.8)
model = StepModel.load(path)
face_id = _first_existing_fillet_face(model, 1.0, 2e-4)
feature = model.feature_info(face_id)
_assert(feature.get("existing_fillet_status") == "blocked", f"mixed-radius fillet chain should be blocked: {feature}")
_assert(
feature.get("existing_fillet_chain_status") == "variable-radius-chain-candidate",
f"mixed-radius fillet chain should expose variable-radius status: {feature}",
)
blockers = str(feature.get("recognition_blockers") or feature.get("existing_fillet_blockers") or "")
_assert("变半径" in blockers, f"mixed-radius fillet blocker should mention variable radius: {feature}")
_assert(feature.get("recognition_decision") == "已阻止", f"mixed-radius fillet should be globally blocked: {feature}")
_assert(
"已有圆角半径" not in str(feature.get("recognition_ready_actions") or ""),
f"mixed-radius fillet chain should not be ready-editable: {feature}",
)
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids") or ())
candidates = model.editable_feature_candidates(limit=40, detailed=False)
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if item.get("operation_key") == "inspect_existing_fillet" and int(item.get("target_id", -1)) in chain_face_ids
]
_assert(not leaked, f"mixed-radius fillet chain should not leak editable scan entries: {leaked}")
def _verify_open_shell_context_summary(root: Path) -> None:
path = root / "open_thin_wall_box.step"
_write_open_thin_wall_box_model(path)
model = StepModel.load(path)
face_id = _first_open_shell_wall_face(model, 2.0, 2e-4)
info = model.feature_info(face_id)
_assert(info.get("open_shell_context_status") == "limited", f"open shell context should be limited: {info}")
_assert_summary(info, "open shell context", {"surface", "first_level_topology", "open_shell_context"})
_assert(
"完整抽壳/开口面编辑" in str(info.get("recognition_limited_actions") or ""),
f"open shell should list full shell/opening edit as limited: {info}",
)
_assert(
"完整抽壳/开口面" in str(info.get("recognition_limitations") or ""),
f"open shell limitation should explain unsupported complete shell edit: {info}",
)
_assert(
"壳体厚度" in str(info.get("recognition_ready_actions") or ""),
f"open shell wall should keep local shell thickness ready: {info}",
)
def main() -> int:
for key in (
"recognition_summary",
@@ -300,6 +597,16 @@ def main() -> int:
"recognition_user_priority_label",
"recognition_user_priority_reason",
"recognition_evidence",
"recognition_external_relation_score_bonus",
"analysis_situs_feature_hint_status",
"analysis_situs_feature_hint_preferred",
"analysis_situs_feature_hint_label",
"analysis_situs_feature_hint_score",
"analysis_situs_feature_hint_summary",
"analysis_situs_feature_hint_related_face_ids",
"analysis_situs_slot_hint_score",
"analysis_situs_boss_hint_score",
"analysis_situs_fillet_hint_score",
"recognition_ready_actions",
"recognition_limited_actions",
"recognition_blockers",
@@ -314,10 +621,16 @@ def main() -> int:
_verify_quick_cylinder_recognition(root)
_verify_hole_summary(root)
_verify_slot_summary(root)
_verify_split_cylinder_slot_and_hole_guard()
_verify_boss_summary(root)
_verify_asitus_slot_boss_fillet_hints(root)
_verify_torus_summary(root)
_verify_user_priority_scan_order(root)
_verify_candidate_scan_cache(root)
_verify_ellipse_edge_scan_entries(root)
_verify_complex_slot_guard(root)
_verify_mixed_radius_fillet_chain_guard(root)
_verify_open_shell_context_summary(root)
print("feature recognition summary ok")
return 0
+66 -28
View File
@@ -47,14 +47,33 @@ def _verify_readme_mentions(readme: str) -> None:
"当前整体验证基线",
"不等于 CAD 级完成",
"Face 阶段的当前验收口径",
"参数化编辑路线",
"用户最常用优先 > B-Rep 上稳定可实现 > 参数语义清楚",
"`[x]` 已实现",
"`[~]` 部分实现/进行中",
"`[ ]` 未实现",
"STEP/B-Rep 参数化编辑主线",
"[不能修改 -> 立即说明原因]",
"[一级影响范围 -> 明确显示]",
"SCDM-first 主路线",
"核心路线只保留下面这一棵树",
"SCDM 内部如何处理相邻面、圆角链、二级/三级拓扑传播,交给 SCDM",
"本软件不再把手写一级、二级、三级传播当成新增能力主线",
"`[x]` 已适配",
"`[~]` 部分适配",
"`[ ]` 待适配",
"[scdm_probe_job.json -> /RunScript 扫描 STEP]",
"[scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"[参数化建模 -> 多个目标值统一提交,不再每行一个操作按钮]",
"[公式输入 -> 支持 Face85.直径 = Face87.半径",
"[批量联动 -> 多条公式先求值成一组目标参数",
"[关系式管理 -> 公式启停、删除回滚、基础单位字面量 mm/cm/m、JSON 导入/导出已接",
"[Face 偏移 -> face.offset / OffsetFaces]",
"[槽宽 -> slot.width / OffsetFaces",
"[槽深 -> slot.depth / Move 或 OffsetFaces",
"[本地 OCCT -> 只保留已验证兜底能力,不再作为新主线扩展]",
"Face 阶段的当前验收口径(本地 OCCT 兜底基线,R1 已收口)",
"已验收:平面 Face 的 `偏移`,稳定矩形/简单全平面 Face 的 `面内长度`、`面内宽度`",
"未实现/不承诺:原 CAD 历史恢复、任意复杂 Face 的通用局部重建",
"孔/槽阶段的当前验收口径(本地 OCCT 兜底基线,R2/R3 已收口)",
"已验收:圆柱孔/盲孔的 `直径`、`半径`、`轴心`、`盲孔深度`",
"已验收:槽/半孔/长圆槽的 `槽宽`、`槽深`、`圆弧长度`",
"未实现/不承诺:孔组、阵列孔、同尺寸孔联动、多槽组联动",
"2026-08-11,在 `pyocc` 环境下已通过 `python scripts\\verify_first_level_edit_suites.py --stage hole-slot`",
"覆盖 R2/R3 孔槽专项套件、隔离执行、逻辑 Face ID 保持和孔槽阶段收口口径",
"本地 OCCT 兜底能力的当前基线",
"verify_first_level_edit_suites.py --quick",
"verify_first_level_edit_suites.py --stage face",
"verify_first_level_edit_suites.py --stage hole-slot",
@@ -79,42 +98,61 @@ def _verify_readme_mentions(readme: str) -> None:
def _verify_roadmap_scope(readme: str) -> None:
start_marker = "STEP/B-Rep 参数化编辑主线"
end_marker = "└── 8. 二级 / 三级关系"
start_marker = "SCDM-first 主路线"
end_marker = "└── 8. 交付与兜底边界"
start = readme.find(start_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]
deferred_scope = readme[end:]
required_active_fragments = (
"├── 0. 先让用户知道“能不能改”",
"│ ├── [x] [不能修改 -> 立即说明原因]",
"│ └── [x] [路线图 -> 验收脚本守门]",
"├── 1. 平面 Face,第一条主线",
"├── 2. 孔,第二条主线",
"├── 3. 槽 / 长圆孔,从孔扩展到组合切除特征",
"├── 4. 凸台 / Boss,从切除特征扩展到加料特征",
"├── 5. 圆角 / 倒角,从主形体扩展到边修饰",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"├── 7. 壳体 / 解析曲面,补齐高价值但边界更窄的能力",
"├── 0. 后端发现与可用性",
"│ ├── [x] [自动发现 SpaceClaim.exe -> 缓存路径、来源、版本和验证结果]",
"├── 1. SCDM 识别与缓存",
"│ ├── [x] [scdm_probe_job.json -> /RunScript 扫描 STEP]",
"│ ├── [x] [scdm_feature_cache.json -> 映射为本软件能力字典和中文参数]",
"├── 2. 能力字典、参数表和用户入口",
"├── 3. 关系式与参数联动",
"[公式输入 -> 支持 Face85.直径 = Face87.半径 这类对象.参数表达式和补全]",
"[添加守门 -> 阻止自引用、重复目标、循环依赖和当前无可执行参数的公式]",
"[批量联动 -> 多条公式先求值成一组目标参数,再合并为一个参数化建模任务]",
"├── 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:
_assert(fragment in active_scope, f"README active roadmap missing: {fragment}")
forbidden_active_fragments = (
"SCDM-first 统一实施路线",
"SCDM-first Capability 适配路线图",
"SCDM-first Capability 适配路线",
"├── 6. Edge 一级编辑,补齐底层直接改边能力",
"└── 8. 二级 / 三级关系",
"[Face 二级传播",
"[孔组 ->",
"多槽组 ->",
"二级传播 ->",
"三级传播 ->",
"二级 / 三级关系",
)
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("0~7 不混入二级/三级传播任务" in active_scope, "README should document the active roadmap guard")
_assert("[Analysis Situs -> 只做辅助定位、兜底识别和开源对照]" in deferred_scope, "README should keep Analysis Situs as auxiliary boundary")
_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:
@@ -70,6 +70,15 @@ QUICK_COMMANDS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Smoke test", ("main.py", "--smoke-test")),
("Property editor specs", ("verify_property_editor_specs.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",)),
("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",)),
("ICEPAK cylindrical same-domain hole", ("verify_icepak_cylindrical_region_selection.py",)),
("Feature recognition priority", ("verify_feature_recognition_summary.py",)),
("First-level fact graph", ("verify_first_level_fact_graph.py",)),
("Associated feature probe and display budget", ("verify_associated_features.py",)),
+111
View File
@@ -10,10 +10,13 @@ for path in (PROJECT_ROOT, SCRIPTS_DIR):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from step_editor.geometry_utils import _int_values
from step_editor.model import StepModel
from step_editor.ui_helpers import INFO_LABELS
from step_editor.window_state import WindowStateMixin
from verify_face_coplanar_push_pull import _top_faces, _write_split_top_box # noqa: E402
from verify_face_mixed_surface_guard import _top_planar_cap_face, _write_hollow_cylinder # noqa: E402
from verify_hole_resize import _first_hole_face, _write_through_hole_model # noqa: E402
from verify_shell_thickness_resize import _first_shell_face, _write_plate_model # noqa: E402
from verify_slot_resize import _first_slot_face, _write_half_round_slot_model # noqa: E402
@@ -80,6 +83,7 @@ def _assert_common_facts(facts: dict[str, object], label: str) -> None:
ignored = tuple(facts.get("first_level_fact_ignored_relation_depths", ()) or ())
_assert("second-level" in ignored and "third-level" in ignored, f"{label}: ignored depths are missing")
_assert(str(facts.get("first_level_fact_summary") or ""), f"{label}: summary is missing")
_assert("first_level_planar_relation_summary" in facts, f"{label}: planar relation summary is missing")
def _assert_plan_facts(plan: dict[str, object], label: str, scope: str) -> None:
@@ -104,6 +108,35 @@ def _verify_planar_face_facts() -> None:
_assert(int(facts.get("first_level_fact_boundary_vertex_count", 0) or 0) == 4, f"cube vertex count: {facts}")
_assert(int(facts.get("first_level_fact_adjacent_face_count", 0) or 0) == 4, f"cube adjacent count: {facts}")
_assert(int(facts.get("first_level_fact_included_face_count", 0) or 0) == 5, f"cube included count: {facts}")
_assert(
facts.get("first_level_planar_relation_status") == "ready",
f"cube planar Face should expose planar relation facts: {facts}",
)
_assert(
int(facts.get("first_level_planar_relation_count", 0) or 0) == 4,
f"cube top Face should have four subject-adjacent planar relations: {facts}",
)
_assert(
int(facts.get("first_level_planar_relation_perpendicular_count", 0) or 0) == 4,
f"cube top Face should have four perpendicular adjacent side Faces: {facts}",
)
_assert(
"一级平面关系" in str(facts.get("first_level_fact_summary") or ""),
f"cube planar Face fact summary should include planar relation facts: {facts}",
)
_assert(facts.get("first_level_same_domain_status") == "ready", f"cube same-domain status: {facts}")
_assert(
facts.get("first_level_same_domain_relation") == "coplanar",
f"cube same-domain relation should be coplanar: {facts}",
)
_assert(
int(facts.get("first_level_same_domain_face_count", 0) or 0) == 1,
f"cube should expose a single same-domain planar Face: {facts}",
)
_assert(
"first_level_coaxial_cylinder_summary" in facts,
f"cube should still expose coaxial-cylinder fact fields: {facts}",
)
probe = _FactProbe(model)
fields = probe._face_first_level_selection_fields(face_id)
@@ -137,6 +170,80 @@ def _verify_planar_face_facts() -> None:
_assert_plan_facts(plan, label, "face")
def _verify_coplanar_same_domain_facts(root: Path) -> None:
split_path = root / "split_top_box.step"
_write_split_top_box(split_path)
model = StepModel.load(split_path)
top_faces = _top_faces(model, 10.0, 2e-4)
_assert(len(top_faces) == 2, f"split-top box should have two coplanar top Faces, got {top_faces}")
face_id = top_faces[0]
facts = model.face_first_level_facts(face_id, scope="face")
_assert_common_facts(facts, "split coplanar Face")
_assert(
facts.get("first_level_same_domain_status") == "ready",
f"split coplanar Face should expose same-domain facts: {facts}",
)
_assert(
facts.get("first_level_same_domain_relation") == "coplanar",
f"split coplanar Face should be marked coplanar: {facts}",
)
_assert(
set(_int_values(facts.get("first_level_same_domain_face_ids"))) == set(top_faces),
f"split coplanar facts should include both top Faces: {facts}",
)
_assert(
int(facts.get("first_level_same_domain_fragment_face_count", 0) or 0) == 1,
f"split coplanar facts should expose one synchronized fragment: {facts}",
)
_assert(
"共面" in str(facts.get("first_level_same_domain_summary") or ""),
f"split coplanar summary should mention coplanar relation: {facts}",
)
plan = model.push_pull_plan(face_id, 1.0)
_assert_plan_facts(plan, "split coplanar push-pull plan", "face")
_assert(
set(_int_values(plan.get("first_level_same_domain_face_ids"))) == set(top_faces),
f"split coplanar plan should carry same-domain facts: {plan}",
)
def _verify_coaxial_cylinder_facts(root: Path) -> None:
hollow_path = root / "hollow_cylinder.step"
_write_hollow_cylinder(hollow_path)
model = StepModel.load(hollow_path)
cap_face_id = _top_planar_cap_face(model)
facts = model.face_first_level_facts(cap_face_id, scope="face")
_assert_common_facts(facts, "hollow cylinder cap")
_assert(
facts.get("first_level_coaxial_cylinder_status") == "ready",
f"hollow cylinder cap should expose ready coaxial-cylinder facts: {facts}",
)
_assert(
int(facts.get("first_level_coaxial_cylinder_count", 0) or 0) >= 1,
f"hollow cylinder cap should have at least one coaxial cylinder relation: {facts}",
)
_assert(
int(facts.get("first_level_coaxial_cylinder_face_count", 0) or 0) >= 2,
f"hollow cylinder cap should include inner and outer cylindrical Faces: {facts}",
)
rows = tuple(row for row in facts.get("first_level_coaxial_cylinder_rows", ()) or () if isinstance(row, dict))
radii = sorted({round(float(radius), 4) for row in rows for radius in tuple(row.get("radii") or ())})
_assert(radii == [3.0, 8.0], f"hollow cylinder cap should expose inner/outer coaxial radii, got {radii}: {facts}")
_assert(
"同轴圆柱" in str(facts.get("first_level_coaxial_cylinder_summary") or ""),
f"hollow cylinder cap summary should mention coaxial cylinders: {facts}",
)
plan = model.push_pull_plan(cap_face_id, 1.0)
_assert_plan_facts(plan, "hollow cylinder cap push-pull plan", "face")
_assert(
plan.get("first_level_coaxial_cylinder_status") == "ready",
f"hollow cylinder cap plan should carry coaxial-cylinder facts: {plan}",
)
def _verify_shell_plan_facts(root: Path) -> None:
shell_path = root / "thin_plate.step"
_write_plate_model(shell_path)
@@ -200,12 +307,16 @@ def main() -> int:
"first_level_fact_summary",
"first_level_fact_subject_face_count",
"first_level_fact_adjacent_face_count",
"first_level_same_domain_summary",
"first_level_coaxial_cylinder_summary",
):
_assert(key in INFO_LABELS, f"{key} should have a user-facing label")
_verify_planar_face_facts()
with tempfile.TemporaryDirectory(prefix="geom_param_first_level_facts_") as temp_dir:
root = Path(temp_dir)
_verify_coplanar_same_domain_facts(root)
_verify_coaxial_cylinder_facts(root)
_verify_shell_plan_facts(root)
_verify_cylindrical_facts(root)
_verify_slot_facts(root)
@@ -0,0 +1,402 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
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.isolated_edit_worker import run_request
from step_editor.model import StepModel
from step_editor.window_core import WindowCoreMixin
from step_editor.window_state import WindowStateMixin
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
class _SelectionHarness(WindowCoreMixin, WindowStateMixin):
def __init__(self, model: StepModel) -> None:
self.model = model
self.feature_detection_level = "current-only"
self.operation_in_progress = False
self.scan_in_progress = False
self.load_in_progress = False
self.selected_face_id = None
self.selected_edge_id = None
self.selected_kind = None
self.selected_part_id = None
self.selected_solid_id = None
self.manual_bottom_face_id = None
self.manual_slot_pair_face_id = None
self.multi_selected_feature_face_ids = []
self.multi_selected_hole_entries = []
self.multi_selection_active = False
self.current_info_values = {}
def _current_feature_detection_level(self) -> str:
return str(self.feature_detection_level)
def select_feature_face(self, face_id: int) -> None:
self.selected_face_id = face_id
self.selected_edge_id = None
self.selected_kind = "feature"
self.selected_part_id = self.model.face_part_ids[face_id]
self.selected_solid_id = self.model.face_solid_ids[face_id]
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root: Path) -> str:
input_path = root / "face87_input.brep"
output_path = root / "face87_output.brep"
request_path = root / "face87_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 Face {face_id} resize should pass: {response}")
_assert(output_path.exists(), f"isolated Face {face_id} resize should write output BREP")
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(
model: StepModel,
face_id: int,
target_center: tuple[float, float, float],
root: Path,
) -> str:
input_path = root / "face85_axis_input.brep"
output_path = root / "face85_axis_output.brep"
request_path = root / "face85_axis_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": "move_cylindrical_hole_axis",
"args": [face_id, list(target_center)],
},
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 Face {face_id} axis move should pass: {response}")
_assert(output_path.exists(), f"isolated Face {face_id} axis move should write output BREP")
return str(response.get("message") or "")
def _edge_ids_from_polydata(polydata) -> set[int]:
edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None
if edge_arr is None:
return set()
return {int(edge_arr.GetValue(index)) for index in range(edge_arr.GetNumberOfTuples())}
def _polydata_edge_sample_counts(polydata) -> dict[int, list[int]]:
edge_arr = polydata.GetCellData().GetArray("edge_id") if polydata is not None else None
if edge_arr is None:
return {}
counts: dict[int, list[int]] = {}
for cell_id in range(polydata.GetNumberOfCells()):
edge_id = int(edge_arr.GetValue(cell_id))
counts.setdefault(edge_id, []).append(int(polydata.GetCell(cell_id).GetNumberOfPoints()))
return counts
def _assert_default_edges_hide_same_domain_internal_edges(model: StepModel, label: str) -> None:
visible_edge_ids = _edge_ids_from_polydata(model.build_edge_polydata(show_same_domain_internal_edges=False))
hidden_internal_ids = set(model._same_domain_internal_edge_ids()) # noqa: SLF001
leaked = sorted(visible_edge_ids & hidden_internal_ids)
_assert(not leaked, f"{label} default edge display should hide same-domain/internal duplicate edges: {leaked[:12]}")
def _assert_small_circle_edges_are_smooth(model: StepModel, label: str) -> None:
edge_polydata = model.build_edge_polydata(deflection=1.6, show_same_domain_internal_edges=False)
sample_counts = _polydata_edge_sample_counts(edge_polydata)
rough_edges: list[tuple[int, int, float]] = []
for edge_id, counts in sample_counts.items():
info = model.edge_info(edge_id)
if info.get("curve") != "circle":
continue
radius = float(info.get("radius") or 0.0)
if radius <= 0.0 or radius > 0.35:
continue
point_count = max(counts or [0])
if point_count < 9:
rough_edges.append((edge_id, point_count, radius))
_assert(not rough_edges, f"{label} small circular display edges should not be drawn as triangles: {rough_edges[:12]}")
def _assert_rectangular_face_parameters(model: StepModel, harness: _SelectionHarness) -> None:
face_id = 9
info = model.feature_info(face_id)
_assert(info.get("surface") == "plane", "ICEPAK Face 9 should be a planar rectangular face")
_assert(info.get("prismatic_profile_status") == "candidate", "ICEPAK Face 9 should be a rectangular profile")
_assert(bool(info.get("local_face_size_edit_ready")), f"ICEPAK Face 9 size parameters should be visible: {info}")
width = float(info.get("local_face_width") or 0.0)
height = float(info.get("local_face_height") or 0.0)
_assert(abs(width - 8.0) <= 1e-7, f"ICEPAK Face 9 length should be read from rectangle edges, got {width:g}")
_assert(abs(height - 4.9) <= 1e-7, f"ICEPAK Face 9 width should be read from rectangle edges, got {height:g}")
harness.select_feature_face(face_id)
specs = harness._property_editor_specs(info, info)
editable_keys = {str(spec.get("key") or "") for spec in specs if bool(spec.get("editable"))}
expected = {"local_face_width", "local_face_height", "face_target_normal_position"}
missing = sorted(expected - editable_keys)
_assert(not missing, f"ICEPAK Face 9 feature parameters should expose length, width and offset: {missing}")
def _assert_face85_hole_axis_move(model: StepModel, harness: _SelectionHarness) -> tuple[float, float, float]:
face_id = 85
info = model.quick_face_info(face_id)
_assert(info.get("surface") == "cylinder", "ICEPAK Face 85 should be cylindrical")
_assert(
list(info.get("feature_highlight_face_ids") or []) == [85, 96],
f"ICEPAK Face 85 should highlight the complete split hole region: {info.get('feature_highlight_face_ids')}",
)
harness.select_feature_face(face_id)
feature = model.feature_info(face_id)
specs = harness._property_editor_specs(feature, feature)
editable_keys = {str(spec.get("key") or "") for spec in specs if bool(spec.get("editable"))}
_assert("hole_axis_center" in editable_keys, "ICEPAK Face 85 should expose the hole axis-center move parameter")
current_center = info.get("axis_center") or feature.get("axis_center")
if not (isinstance(current_center, (list, tuple)) and len(current_center) == 3):
axis_point = feature.get("axis_point") or info.get("axis_point")
axis_direction = feature.get("axis") or info.get("axis")
axis_range = feature.get("same_domain_v_range") or info.get("same_domain_v_range") or feature.get("v_range")
if (
isinstance(axis_point, (list, tuple))
and len(axis_point) == 3
and isinstance(axis_direction, (list, tuple))
and len(axis_direction) == 3
and isinstance(axis_range, (list, tuple))
and len(axis_range) >= 2
):
axis_mid = (float(axis_range[0]) + float(axis_range[1])) * 0.5
current_center = tuple(float(axis_point[index]) + float(axis_direction[index]) * axis_mid for index in range(3))
_assert(isinstance(current_center, (list, tuple)) and len(current_center) == 3, "Face 85 should have an axis center")
near_target_center = (float(current_center[0]) + 0.1, float(current_center[1]), float(current_center[2]))
plan = model.cylindrical_axis_move_plan(face_id, near_target_center)
_assert(plan.get("status") != "blocked", f"Face 85 axis move should not be blocked as a half cylinder: {plan}")
_assert(list(plan.get("same_domain_face_ids") or []) == [85, 96], "Face 85 axis move should use both side fragments")
_assert(float(plan.get("angular_span") or 0.0) > 6.0, "Face 85 axis move should use full same-domain span")
_assert(
abs(float(plan.get("selected_angular_span") or 0.0) - 3.141592653589793) <= 1e-6,
"Face 85 selected fragment span should still be recorded separately",
)
target_center = (float(current_center[0]), float(current_center[1]), 6.0)
high_risk_plan = model.cylindrical_axis_move_plan(face_id, target_center)
_assert(
high_risk_plan.get("status") != "blocked",
f"Face 85 axis move to Z=6 should be high-risk but still plannable: {high_risk_plan}",
)
_assert(high_risk_plan.get("risk") == "high", f"Face 85 far axis move should be classified high risk: {high_risk_plan}")
_assert(
bool(high_risk_plan.get("supports_isolation")),
f"Face 85 high-risk axis move should be routed to isolated execution: {high_risk_plan}",
)
_assert(
tuple(round(float(item), 7) for item in high_risk_plan.get("target_axis_center", ())) == (0.5, 1.0, 6.0),
f"Face 85 high-risk axis move target should match the requested location: {high_risk_plan}",
)
return target_center
def _assert_face85_face87_multi_hole_specs(model: StepModel, harness: _SelectionHarness) -> None:
entry85 = harness._hole_multi_select_entry(85)
entry87 = harness._hole_multi_select_entry(87)
_assert(entry85 is not None, "Face 85 should be eligible for multi-hole selection")
_assert(entry87 is not None, "Face 87 should be eligible for multi-hole selection")
_assert(entry85["logical_id"] != entry87["logical_id"], "Face 85 and Face 87 should be separate hole groups")
harness.multi_selected_hole_entries = [entry85, entry87]
harness.multi_selected_feature_face_ids = [85, 87]
harness.multi_selection_active = True
harness.selected_kind = "multi_feature"
harness.selected_face_id = 87
info = harness._selected_action_info()
_assert(info.get("multi_selection_kind") == "holes", f"multi selection info should describe holes: {info}")
_assert(int(info.get("multi_selected_count") or 0) == 2, f"multi selection should include two holes: {info}")
_assert(
set(info.get("feature_highlight_face_ids") or ()) == {85, 96, 87, 94},
f"multi-hole highlight should include both split-cylinder regions: {info.get('feature_highlight_face_ids')}",
)
specs = harness._property_editor_specs(info, info)
keys = [str(spec.get("key") or "") for spec in specs]
_assert(
keys == ["multi_hole_diameter", "multi_hole_radius", "multi_hole_position_delta", "multi_hole_suppress"],
f"multi-hole specs should expose batch hole dimensions and commands: {keys}",
)
actions = {str(spec.get("action") or "") for spec in specs}
_assert(
actions
== {"resize_multi_selected_holes", "move_multi_selected_holes_by_offset", "suppress_multi_selected_holes"},
f"multi-hole specs should expose batch edit actions: {actions}",
)
def main() -> int:
if not MODEL_PATH.exists():
print("icepak cylindrical region selection skipped: local ICEPAK-NATURAL.stp is not present")
return 0
model = StepModel.load(MODEL_PATH)
_assert_default_edges_hide_same_domain_internal_edges(model, "ICEPAK before edit")
harness = _SelectionHarness(model)
_assert_rectangular_face_parameters(model, harness)
_assert_face85_hole_axis_move(model, harness)
_assert_face85_face87_multi_hole_specs(model, harness)
expected_regions = {
85: [85, 96],
96: [85, 96],
87: [87, 94],
94: [87, 94],
84: [84, 97],
97: [84, 97],
}
for face_id, expected in expected_regions.items():
info = model.quick_face_info(face_id)
_assert(info.get("surface") == "cylinder", f"Face {face_id} should be cylindrical")
_assert(model.face_region_ids(face_id) == expected, f"Face {face_id} region should be {expected}")
_assert(
list(info.get("feature_highlight_face_ids") or []) == expected,
f"Face {face_id} quick info highlight should include the whole same-domain region: {info.get('feature_highlight_face_ids')}",
)
_assert(
harness._selection_same_domain_face_ids(face_id) == expected,
f"Face {face_id} UI selection highlight should include the whole same-domain region",
)
context = harness._feature_context_info(face_id)
_assert(
list(context.get("feature_highlight_face_ids") or []) == expected,
f"Face {face_id} feature context highlight should include the whole same-domain region: {context.get('feature_highlight_face_ids')}",
)
feature = model.feature_info(face_id)
_assert(
list(feature.get("same_domain_face_ids") or []) == expected,
f"Face {face_id} feature info should use the whole same-domain region",
)
_assert(bool(feature.get("is_full_cylinder")), f"Face {face_id} should be treated as a full cylinder")
axis_move_model = StepModel.load(MODEL_PATH)
axis_move_target = _assert_face85_hole_axis_move(axis_move_model, _SelectionHarness(axis_move_model))
axis_move_result = axis_move_model.move_cylindrical_hole_axis(85, axis_move_target)
_assert(
"Cylindrical hole axis move completed" in axis_move_result,
f"Face 85 axis move should complete: {axis_move_result}",
)
_assert_default_edges_hide_same_domain_internal_edges(axis_move_model, "ICEPAK after Face 85 axis move")
resize_model = StepModel.load(MODEL_PATH)
plan = resize_model.cylindrical_resize_plan(87, 0.3)
_assert(plan.get("feature_type") == "圆柱孔候选", "Face 87 should plan as a cylindrical hole")
_assert(list(plan.get("same_domain_face_ids") or []) == [87, 94], "Face 87 resize plan should use both side fragments")
_assert(bool(plan.get("is_full_cylinder")), "Face 87 resize plan should preserve full-cylinder semantics")
_assert(float(plan.get("angular_span") or 0.0) > 6.0, "Face 87 resize plan should not use the selected half-face span")
result = resize_model.resize_cylindrical_hole(87, 0.3)
_assert("diameter 0.5 -> 0.3" in result, "Face 87 diameter shrink should complete")
_assert_default_edges_hide_same_domain_internal_edges(resize_model, "ICEPAK after Face 87 shrink")
_assert_small_circle_edges_are_smooth(resize_model, "ICEPAK after Face 87 shrink")
with tempfile.TemporaryDirectory(prefix="icepak_face87_isolated_") as 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_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:
worker_message = _isolated_hole_axis_move(model, 85, axis_move_target, Path(temp_dir))
_assert("Cylindrical hole axis move completed" in worker_message, "Face 85 isolated axis move should complete")
print("icepak cylindrical region selection ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.GeomAbs import GeomAbs_Plane
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.window_actions import WindowActionMixin
from step_editor.isolated_edit_worker import run_request
from step_editor.model import StepModel
MODEL_PATH = PROJECT_ROOT / "assets" / "models" / "ICEPAK-NATURAL.stp"
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float:
return sum(left[index] * right[index] for index in range(3))
def _plane_axis(model: StepModel, face_id: int) -> tuple[float, float, float]:
surf = BRepAdaptor_Surface(model.faces[face_id])
_assert(surf.GetType() == GeomAbs_Plane, f"Face {face_id} should be planar")
direction = surf.Plane().Axis().Direction()
return (float(direction.X()), float(direction.Y()), float(direction.Z()))
def _isolated_push_pull(model: StepModel, face_id: int, distance: float, temp_dir: Path, stem: str) -> tuple[StepModel, str]:
input_path = temp_dir / f"{stem}_input.brep"
output_path = temp_dir / f"{stem}_output.brep"
request_path = temp_dir / f"{stem}_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": "push_pull_face",
"args": [face_id, distance],
},
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 Face {face_id} push-pull should pass: {response}")
_assert(output_path.exists(), f"isolated Face {face_id} push-pull should write an output BREP")
return StepModel.load_internal_brep(output_path), str(response.get("message") or "")
def _ui_push_pull_context(model: StepModel, face_id: int, plan: dict[str, object]) -> dict[str, object]:
return {
"operation_name": "拉伸/切除平面",
"target": f"Face {face_id}",
"target_kind": "face",
"target_id": face_id,
"target_logical_id": model.face_logical_id(face_id),
"parameters": {
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"semantic_distance": plan.get("distance"),
"distance_rule": "positive=outward fuse, negative=inward cut",
"surface": plan.get("surface"),
"outward_direction": plan.get("outward_direction"),
"plane_direction": plan.get("plane_direction"),
"current_plane_position": plan.get("current_plane_position"),
"target_plane_position": plan.get("target_plane_position"),
"current_plane_center": plan.get("current_plane_center"),
"target_plane_center": plan.get("target_plane_center"),
"direction_confidence": plan.get("direction_confidence"),
"direction_note": plan.get("direction_note"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"push_pull_risk": plan.get("risk"),
"push_pull_status": plan.get("status"),
"push_pull_message": plan.get("message"),
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
"push_pull_scope_note": plan.get("push_pull_scope_note"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"selected_boundary_wires": plan.get("selected_boundary_wires"),
"selected_inner_boundary_wires": plan.get("selected_inner_boundary_wires"),
"selected_has_inner_boundaries": plan.get("selected_has_inner_boundaries"),
},
}
def main() -> int:
if not MODEL_PATH.exists():
print("icepak face45 push-pull skipped: local ICEPAK-NATURAL.stp is not present")
return 0
model = StepModel.load(MODEL_PATH)
face_id = 45
plan = model.push_pull_plan(face_id, 10.0)
_assert(plan.get("status") != "blocked", f"Face 45 positive push-pull should be plannable: {plan}")
_assert(float(plan.get("current_plane_position") or 0.0) == 5.0, f"Face 45 should use outward offset +5: {plan}")
_assert(float(plan.get("target_plane_position") or 0.0) == 15.0, f"Face 45 target should be +15: {plan}")
_assert(plan.get("target_plane_center") is not None, "Face push-pull plan should carry target_plane_center")
inward_plan = model.push_pull_plan(face_id, -10.0)
_assert(inward_plan.get("status") == "blocked", f"Face 45 inward cut through material should be blocked: {inward_plan}")
_assert("材料厚度" in str(inward_plan.get("blockers") or ""), f"blocked reason should mention material depth: {inward_plan}")
direct_model = StepModel.load(MODEL_PATH)
direct_model.push_pull_face(face_id, 10.0)
direct_followup_plan = direct_model.push_pull_plan(47, 10.0)
direct_direction = tuple(float(item) for item in (direct_followup_plan.get("plane_direction") or ()))
_assert(
len(direct_direction) == 3 and abs(_dot(direct_direction, _plane_axis(direct_model, 47))) >= 0.92,
f"Face 47 follow-up direction should stay aligned with its selected plane: {direct_followup_plan}",
)
direct_followup_message = direct_model.push_pull_face(47, 10.0, plan=dict(direct_followup_plan))
direct_followup_target = float(direct_followup_plan.get("target_plane_position") or 0.0)
_assert(
f"target={direct_followup_target:g}" in direct_followup_message,
f"direct second push-pull should match Face 47 target: {direct_followup_message}",
)
with tempfile.TemporaryDirectory(prefix="icepak_face45_isolated_") as temp_dir:
root = Path(temp_dir)
edited_model, message = _isolated_push_pull(model, face_id, 10.0, root, "face45")
_assert("actual=15" in message and "target=15" in message, f"result check should match target face: {message}")
followup_face_id = 47
followup_plan = edited_model.push_pull_plan(followup_face_id, 10.0)
followup_context = _ui_push_pull_context(edited_model, followup_face_id, followup_plan)
followup_current = float(followup_plan.get("current_plane_position") or 0.0)
followup_target = float(followup_plan.get("target_plane_position") or 0.0)
_assert(followup_plan.get("status") != "blocked", f"Face 47 follow-up should be plannable: {followup_plan}")
_assert(
abs(followup_target - followup_current - 10.0) <= 1e-8,
f"Face 47 follow-up target should preserve the requested +10 offset: {followup_plan}",
)
_second_model, followup_message = _isolated_push_pull(edited_model, followup_face_id, 10.0, root, "face47")
_assert(
f"actual={followup_target:g}" in followup_message and f"target={followup_target:g}" in followup_message,
f"second isolated push-pull should match Face 47 target: {followup_message}",
)
checker = WindowActionMixin()
blocker = checker._face_target_integrity_blocker(_second_model, followup_context)
_assert(blocker == "", f"UI target integrity check should accept the second push-pull: {blocker}")
print("icepak face45/face47 push-pull ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+85 -2
View File
@@ -19,13 +19,19 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.records import OperationRecord
from step_editor.window_actions import WindowActionMixin
from step_editor.window_state import WindowStateMixin
class _WindowActionProbe(WindowActionMixin):
pass
class _WindowStateProbe(WindowStateMixin):
pass
def _wire_count(face) -> int:
count = 0
explorer = TopExp_Explorer(face, TopAbs_WIRE)
@@ -136,11 +142,18 @@ def main() -> int:
face_id = _large_stepped_cap_face(model)
logical_id = model.face_region_logical_id(face_id)
before_topology = _face_topology_counts(model, face_id)
started = time.perf_counter()
plan = model.push_pull_plan(face_id, 89.0)
plan_elapsed = time.perf_counter() - started
if plan_elapsed > 5.0:
raise SystemExit(f"large stepped cap push/pull plan took too long: {plan_elapsed:.3f}s; plan={plan}")
if plan.get("cylindrical_cap_extension_kind") != "coaxial-stepped-cap":
raise SystemExit(f"large stepped cap should be recognized as stepped cap: {plan}")
if plan.get("cylindrical_cap_extension_method") != "local-shell-rebuild":
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()
result = model.push_pull_face(face_id, 89.0)
@@ -229,7 +242,13 @@ def main() -> int:
multi_face_id = _large_multi_boundary_cap_face(multi_model)
multi_logical_id = multi_model.face_region_logical_id(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_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:
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:
@@ -238,6 +257,22 @@ def main() -> int:
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":
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()
multi_inward_plan = multi_model.push_pull_plan(multi_face_id, -1.0)
multi_inward_elapsed = time.perf_counter() - started
@@ -342,6 +377,53 @@ def main() -> int:
multi_after_topology,
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:
temp_root = Path(temp_dir)
@@ -432,12 +514,13 @@ def main() -> int:
print(
"large stepped cap push/pull ok: "
f"face_id={face_id}, elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, "
f"face_id={face_id}, plan_elapsed={plan_elapsed:.3f}s, "
f"elapsed={elapsed:.3f}s, isolated_elapsed={isolated_elapsed:.3f}s, "
f"topology_before={before_topology}, topology_after={after_topology}, result={result}"
)
print(
"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"topology_before={multi_before_topology}, topology_after={multi_after_topology}, result={multi_result}"
)
@@ -0,0 +1,90 @@
from __future__ import annotations
import json
from pathlib import Path
import py_compile
import sys
import tempfile
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.parametric_component import export_parametric_component
def _assert(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def main() -> int:
with tempfile.TemporaryDirectory(prefix="geom_param_component_export_") as temp_dir:
root = Path(temp_dir)
source_step = root / "source.step"
source_step.write_text("ISO-10303-21;\nEND-ISO-10303-21;\n", encoding="utf-8")
parameters = [
{
"name": "面内长度",
"displayName": "面内长度",
"type": "number",
"ioRole": "input",
"default": "10",
}
]
edits = [
{
"parameter": "面内长度",
"displayName": "面内长度",
"targetKind": "feature",
"targetId": 0,
"uiAction": "resize_face_width_local",
"operation": "resize_face_size_local",
"args": [0, {"param": "面内长度"}, "width"],
"default": 10.0,
"valueType": "positive",
"scope": "local",
"scopeLabel": "局部重建",
"sourceStep": str(source_step),
"parameterKey": "local_face_width",
}
]
main_py = export_parametric_component(
parameters=parameters,
edits=edits,
source_step=source_step,
component_root=root / "nodes",
component_name="测试组件",
)
text = main_py.read_text(encoding="utf-8")
_assert("INPUT_PARAMETERS = " in text, "generated main.py should embed selected input parameter list")
_assert("PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS" in text, "generated main.py should expose FlowEditor parameters")
_assert("NODE_INFO = " in text, "generated main.py should expose FlowEditor node info")
_assert("def execute(inputs, params, context):" in text, "generated main.py should expose FlowEditor execute entry")
_assert("COMPONENT = " in text, "generated main.py should embed execution config")
_assert("step_edit_config.json" not in text, "generated component should not require a sidecar config JSON")
_assert(not (main_py.parent / "data.json").exists(), "component directory should not contain data.json")
_assert(not (main_py.parent / "step_edit_config.json").exists(), "component directory should not contain step_edit_config.json")
py_compile.compile(str(main_py), doraise=True)
namespace: dict[str, object] = {}
exec(compile(text, str(main_py), "exec"), namespace)
embedded_input_parameters = namespace.get("INPUT_PARAMETERS")
embedded_parameters = namespace.get("PARAMETERS")
node_info = namespace.get("NODE_INFO")
embedded_component = namespace.get("COMPONENT")
_assert(embedded_input_parameters == parameters, f"embedded INPUT_PARAMETERS mismatch: {embedded_input_parameters}")
_assert(isinstance(embedded_parameters, list), "embedded PARAMETERS should be a list")
_assert(embedded_parameters[: len(parameters)] == parameters, f"embedded input parameter prefix mismatch: {embedded_parameters}")
_assert(any(row.get("name") == "output_step" and row.get("ioRole") == "output" for row in embedded_parameters if isinstance(row, dict)), "generated PARAMETERS should include output_step output port")
_assert(isinstance(node_info, dict), "NODE_INFO should be a dict")
_assert(node_info.get("parameters") == embedded_parameters, "NODE_INFO should point to PARAMETERS")
_assert(isinstance(embedded_component, dict), "embedded COMPONENT should be a dict")
_assert(embedded_component.get("edits") == edits, f"embedded edits mismatch: {json.dumps(embedded_component, ensure_ascii=False)}")
print("parametric component export ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+57 -3
View File
@@ -146,6 +146,62 @@ def assert_rectangular_feature_ui(
raise AssertionError(f"unexpected rectangular feature UI parameters: {actual}")
def find_multistep_transition_face(model: StepModel) -> tuple[int, dict[str, object]]:
matches: list[tuple[int, dict[str, object]]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("multistep_prismatic_status") != "blocked":
continue
center = info.get("area_center")
z_value = float(center[2]) if isinstance(center, (list, tuple)) and len(center) >= 3 else -1.0
matches.append((face_id, info | {"_z_sort": z_value}))
if not matches:
raise AssertionError("expected at least one blocked multistep transition face")
matches.sort(key=lambda item: float(item[1].get("_z_sort", -1.0)), reverse=True)
face_id, info = matches[0]
info.pop("_z_sort", None)
return face_id, info
def assert_multistep_transition_guard(model: StepModel) -> None:
face_id, info = find_multistep_transition_face(model)
blockers = str(info.get("multistep_prismatic_blockers") or info.get("recognition_blockers") or "")
if "多台阶" not in blockers or "暂不支持" not in blockers:
raise AssertionError(f"multistep transition blocker should explain unsupported scope: {info}")
if "复杂多台阶凸台" not in str(info.get("feature_type")):
raise AssertionError(f"unexpected multistep transition label: {info.get('feature_type')}")
if feature_dimension_rows(model, face_id, info) != ():
raise AssertionError("blocked multistep transition face should not expose editable dimensions")
target_center = info.get("area_center")
if not isinstance(target_center, (list, tuple)) or len(target_center) != 3:
raise AssertionError(f"multistep transition face lacks a stable center: {info}")
center_target = (float(target_center[0]) + 0.5, float(target_center[1]), float(target_center[2]))
plans = (
model.push_pull_plan(face_id, 1.0),
model.shell_thickness_plan(face_id, 2.5),
model.face_size_local_resize_plan(face_id, 9.0, "width"),
model.face_center_local_move_plan(face_id, center_target),
)
for plan in plans:
if plan.get("status") != "blocked":
raise AssertionError(f"multistep transition plan should be blocked: {plan}")
message = str(plan.get("message") or plan.get("blockers") or "")
if "多台阶" not in message or "暂不支持" not in message:
raise AssertionError(f"multistep transition plan should explain unsupported scope: {plan}")
leaked_entries = [
item
for item in model.editable_feature_candidates(limit=80, detailed=False)
if int(item.get("target_id", -1)) == face_id
and str(item.get("operation_key") or "") in {"push_pull_plane", "resize_shell_thickness"}
]
if leaked_entries:
raise AssertionError(
f"blocked multistep transition face should not leak editable scan entries: {leaked_entries}"
)
def assert_prismatic_feature_display_labels() -> None:
state = object.__new__(WindowStateMixin)
if state._feature_display_label("矩形口袋候选") != "矩形槽/口袋":
@@ -361,7 +417,6 @@ def main() -> int:
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "8"),
("shell_thickness_estimate", "高度/深度", "3"),
("face_center_position", "中心", "(15, 10, 2)"),
),
)
@@ -382,7 +437,6 @@ def main() -> int:
("local_face_width", "长度", "10"),
("local_face_height", "宽度", "8"),
("shell_thickness_estimate", "高度/深度", "3"),
("face_center_position", "中心", "(15, 10, 8)"),
),
)
@@ -432,6 +486,7 @@ def main() -> int:
multistep_path = temp_path / "rectangular-multistep-boss.step"
write_rectangular_multistep_boss_model(multistep_path)
multistep_model = StepModel.load(multistep_path)
assert_multistep_transition_guard(multistep_model)
multistep_face_id, multistep_info = find_prismatic_semantics(multistep_model, "additive-boss")
assert_prismatic_sizes(multistep_info, 6.0, 4.0, 2.0)
assert_rectangular_feature_ui(
@@ -442,7 +497,6 @@ def main() -> int:
("local_face_width", "长度", "6"),
("local_face_height", "宽度", "4"),
("shell_thickness_estimate", "高度/深度", "2"),
("face_center_position", "中心", "(15, 10, 10)"),
),
)
File diff suppressed because it is too large Load Diff
+251 -16
View File
@@ -27,6 +27,18 @@ class _PropertySpecProbe(WindowStateMixin):
self.manual_slot_pair_face_id = None
class _PlaneOffsetDirectionModel:
faces = (object(),)
def face_info(self, face_id: int) -> dict[str, object]:
if face_id != 0:
raise ValueError(f"unexpected face id {face_id}")
return {
"plane_origin": (4.25, -5.0, 0.0),
"push_pull_outward_direction": (0.0, -1.0, 0.0),
}
def _spec_keys(info: dict[str, object]) -> set[str]:
return {str(spec.get("key", "")) for spec in _specs(info)}
@@ -332,20 +344,54 @@ def _assert_target_change_detection() -> None:
raise SystemExit("changed vector Face target was not detected")
def _assert_plane_offset_uses_push_pull_direction() -> None:
probe = _PropertySpecProbe()
probe.model = _PlaneOffsetDirectionModel()
probe.selected_face_id = 0
info = {
"face_id": 0,
"topological_face_id": 0,
"surface": "plane",
"plane_origin": (4.25, -5.0, 0.0),
"normal": (0.0, 1.0, 0.0),
"area_center": (4.25, -5.0, 2.5),
"bbox_diagonal": 8.0,
"local_face_deform_ready": True,
"local_face_width": 1.5,
"local_face_height": 4.0,
}
specs, _used = probe._editable_property_specs(info)
offset_spec = _spec(specs, "face_target_normal_position")
current = float(offset_spec.get("current_raw"))
if abs(current - 5.0) > 1e-9:
raise SystemExit(f"plane offset should use push-pull outward direction, got current={current:g}")
push_pull = _scoped_effective_spec(specs, "face_target_normal_position", "push_pull")
distance = probe._transform_property_scalar_target(push_pull, 5.0)
if abs(distance) > 1e-9:
raise SystemExit(f"unchanged plane offset target should convert to zero distance, got {distance:g}")
inward_distance = probe._transform_property_scalar_target(push_pull, -5.0)
if abs(inward_distance + 10.0) > 1e-9:
raise SystemExit(f"opposite plane offset target should convert relative to outward current, got {inward_distance:g}")
def _assert_property_table_column_widths() -> None:
for width in (320, 340, 360, 400, 520):
columns = _property_table_column_widths(width)
if len(columns) != 4:
raise SystemExit(f"property table should have four column widths, got {columns}")
if len(columns) != 5:
raise SystemExit(f"property table should have five column widths, got {columns}")
if sum(columns) != width:
raise SystemExit(f"property table widths should fill viewport {width}, got {columns} sum={sum(columns)}")
label_width, current_width, scope_width, target_width = columns
if current_width < 96:
label_width, current_width, scope_width, target_width, input_width = columns
if label_width < 58:
raise SystemExit(f"dimension name column should stay visible at {width}: {columns}")
if current_width < 64:
raise SystemExit(f"current value column should stay readable at {width}: {columns}")
if target_width < 56:
if target_width < 54:
raise SystemExit(f"target value column should stay usable at {width}: {columns}")
if scope_width < 44:
if scope_width < 52:
raise SystemExit(f"modeling-intent column should stay usable at {width}: {columns}")
if input_width < 48:
raise SystemExit(f"input-parameter checkbox column should stay usable at {width}: {columns}")
def _assert_holed_plane_local_scopes_disabled() -> None:
@@ -365,9 +411,16 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
"has_inner_boundaries": True,
"local_face_deform_ready": False,
"local_face_deform_blocker": "has inner boundary",
"local_face_size_edit_ready": False,
"local_face_size_edit_blocker": "has inner boundary",
}
)
for key in ("local_face_width", "local_face_height", "face_center_position"):
keys = {str(spec.get("key", "")) for spec in specs}
for key in ("local_face_width", "local_face_height"):
if key in keys:
raise SystemExit(f"{key} should be hidden for a holed planar Face, got {keys}")
for key in ("face_center_position",):
local_mode = _scope_mode(specs, key, "local")
if bool(local_mode.get("enabled", True)):
raise SystemExit(f"{key} local Face scope should be disabled for a holed planar Face")
@@ -434,6 +487,19 @@ def main() -> int:
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
}
quick_plane_display_specs = _display_specs(plane_info)
quick_plane_display_keys = {str(spec.get("key", "")) for spec in quick_plane_display_specs}
if "local_face_width" in quick_plane_display_keys or "local_face_height" in quick_plane_display_keys:
raise SystemExit(
"quick plane Face should not show面内长度/面内宽度 until local size edit is explicitly ready: "
f"{sorted(quick_plane_display_keys)}"
)
plane_info.update(
{
"local_face_size_edit_ready": True,
"local_face_size_edit_blocker": "",
}
)
plane_specs = _specs(plane_info)
plane_keys = {str(spec.get("key", "")) for spec in plane_specs}
_assert_label(plane_specs, "cad_modeling_form", "建模形式")
@@ -465,6 +531,8 @@ def main() -> int:
plane_display_specs = _display_specs(plane_info)
if any(str(spec.get("key", "")) == "area" for spec in plane_display_specs):
raise SystemExit("Face property table should keep area in diagnostics, not in the parameter table")
if any(str(spec.get("key", "")) == "face_center_position" for spec in plane_display_specs):
raise SystemExit("Face property table should temporarily hide center editing")
_assert_keys_absent(
plane_display_specs,
(
@@ -473,6 +541,7 @@ def main() -> int:
"face_first_level_topology",
"face_edit_semantics",
"feature_context_note",
"face_center_position",
),
"plane Face display specs",
)
@@ -491,12 +560,45 @@ def main() -> int:
_assert_label(plane_specs, "local_face_width", "面内长度")
_assert_label(plane_specs, "local_face_height", "面内宽度")
_assert_label(plane_specs, "face_target_normal_position", "偏移")
offset_keep_relations = _scope_mode(plane_specs, "face_target_normal_position", "keep_relations")
if str(offset_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face plane-offset keep-relations scope should be labelled clearly: {offset_keep_relations}")
if str(offset_keep_relations.get("action") or "") != "push_pull_face_keep_relations":
raise SystemExit(f"Face plane-offset keep-relations scope should use its own action: {offset_keep_relations}")
if not bool(offset_keep_relations.get("enabled", False)):
raise SystemExit(f"Face plane-offset keep-relations scope should be available on simple planar Face: {offset_keep_relations}")
width_keep_relations = _scope_mode(plane_specs, "local_face_width", "keep_relations")
if str(width_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face width keep-relations scope should be labelled clearly: {width_keep_relations}")
if str(width_keep_relations.get("action") or "") != "resize_face_width_keep_relations":
raise SystemExit(f"Face width keep-relations scope should use its own action: {width_keep_relations}")
if not bool(width_keep_relations.get("enabled", False)):
raise SystemExit(f"Face width keep-relations scope should be available on simple planar Face: {width_keep_relations}")
height_keep_relations = _scope_mode(plane_specs, "local_face_height", "keep_relations")
if str(height_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face height keep-relations scope should be labelled clearly: {height_keep_relations}")
if str(height_keep_relations.get("action") or "") != "resize_face_height_keep_relations":
raise SystemExit(f"Face height keep-relations scope should use its own action: {height_keep_relations}")
if not bool(height_keep_relations.get("enabled", False)):
raise SystemExit(f"Face height keep-relations scope should be available on simple planar Face: {height_keep_relations}")
center_keep_relations = _scope_mode(plane_specs, "face_center_position", "keep_relations")
if str(center_keep_relations.get("label") or "") != "保持关系":
raise SystemExit(f"Face center keep-relations scope should be labelled clearly: {center_keep_relations}")
if str(center_keep_relations.get("action") or "") != "move_selected_face_center_keep_relations":
raise SystemExit(f"Face center keep-relations scope should use its own action: {center_keep_relations}")
if not bool(center_keep_relations.get("enabled", False)):
raise SystemExit(f"Face center keep-relations scope should be available on simple planar Face: {center_keep_relations}")
_assert_scoped_hint_fragments(
plane_specs,
"face_target_normal_position",
"keep_relations",
("一级", "平行/垂直", "会直接阻止"),
)
_assert_actionable_rows_first(
plane_info,
(
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
),
"plane Face display order",
@@ -521,7 +623,6 @@ def main() -> int:
expected_plane_feature_keys = {
"local_face_width",
"local_face_height",
"face_center_position",
"face_target_normal_position",
}
missing_plane_feature_keys = expected_plane_feature_keys - plane_feature_keys
@@ -568,21 +669,21 @@ def main() -> int:
if "push_pull_distance" in plane_keys:
raise SystemExit("plane Face should not expose a separate push_pull_distance row")
for key in ("local_face_width", "local_face_height"):
for mode in ("local", "owning"):
for mode in ("local", "keep_relations", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
key,
mode,
("5%", "5 倍", "会被阻止"),
)
for mode in ("push_pull", "local", "owning"):
for mode in ("push_pull", "keep_relations", "local", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_target_normal_position",
mode,
("会被阻止",),
)
for mode in ("local", "owning"):
for mode in ("local", "keep_relations", "owning"):
_assert_scoped_hint_fragments(
plane_specs,
"face_center_position",
@@ -639,7 +740,15 @@ def main() -> int:
cylinder_specs = _specs(cylinder_info)
cylinder_keys = {str(spec.get("key", "")) for spec in cylinder_specs}
_assert_no_generic_face_leak(cylinder_keys, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius"}, "cylindrical hole feature")
_assert_contains(cylinder_keys, {"diameter", "hole_cylinder_radius", "hole_axis_center"}, "cylindrical hole feature")
hole_axis_spec = _spec(cylinder_specs, "hole_axis_center")
hole_axis_local = _scope_mode(cylinder_specs, "hole_axis_center", "local")
if str(hole_axis_spec.get("label") or "") != "位置":
raise SystemExit(f"hole axis center should be shown to users as position: {hole_axis_spec}")
if str(hole_axis_spec.get("value_type") or "") != "vector3":
raise SystemExit(f"hole axis center should use an X/Y/Z vector target: {hole_axis_spec}")
if str(hole_axis_local.get("action") or "") != "move_cylindrical_hole_axis":
raise SystemExit(f"hole axis center should move the hole itself by default: {hole_axis_local}")
_assert_current_text_contains(
cylinder_specs,
"cad_modeling_form",
@@ -649,7 +758,7 @@ def main() -> int:
_assert_current_text_contains(
cylinder_specs,
"cad_recommended_operation",
("孔径", "盲孔", "轴心"),
("孔径", "盲孔", "位置"),
"cylindrical hole feature",
)
@@ -688,6 +797,12 @@ def main() -> int:
cylinder_feature_probe = _PropertySpecProbe()
cylinder_feature_specs, _used = cylinder_feature_probe._editable_property_specs(cylinder_topology_info)
cylinder_feature_rows = cylinder_feature_probe._feature_property_specs(cylinder_feature_specs, cylinder_topology_info)
cylinder_feature_keys = {str(spec.get("key", "")) for spec in cylinder_feature_rows}
_assert_contains(
cylinder_feature_keys,
{"diameter", "hole_axis_center"},
"cylindrical feature mode display specs",
)
_assert_keys_absent(
cylinder_feature_rows,
(
@@ -775,7 +890,7 @@ def main() -> int:
"feature_bottom_face_ids": (),
}
)
_assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "split full cylindrical hole")
_assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius", "hole_axis_center"}, "split full cylindrical hole")
if "slot_chord_width_estimate" in split_full_hole_keys:
raise SystemExit(f"split full cylindrical hole should not be shown as a slot: {split_full_hole_keys}")
@@ -788,6 +903,8 @@ def main() -> int:
"slot_chord_width_estimate": 6.0,
"slot_sagitta_depth_estimate": 3.0,
"slot_arc_length_estimate": 9.42477796076938,
"slot_status": "candidate",
"slot_kind": "partial-cylindrical-groove",
"area": 188.0,
"area_center": (0.0, 0.0, 0.0),
"bbox_center": (0.0, 0.0, 0.0),
@@ -812,9 +929,30 @@ def main() -> int:
_assert_current_text_contains(
slot_specs,
"cad_recommended_operation",
("槽宽", "槽深", "轴心"),
("槽宽", "槽深", "位置"),
"slot/half-hole feature",
)
blocked_slot_specs = _display_specs(
{
**slot_info,
"slot_status": "blocked",
"slot_blockers": "交叉槽/多槽组暂未实现稳定修改。",
"recognition_risk": "blocked",
"recognition_blockers": "交叉槽/多槽组暂未实现稳定修改。",
}
)
_assert_keys_absent(
blocked_slot_specs,
(
"slot_axis_center",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_arc_length_estimate",
"slot_angular_span_degrees",
"slot_open_angle_degrees",
),
"blocked complex slot display specs",
)
boss_info = {
"surface": "cylinder",
@@ -878,6 +1016,94 @@ def main() -> int:
("工程特征", "倒圆角", "重新倒圆"),
"existing fillet feature",
)
fillet_arc_local = _scope_mode(fillet_specs, "existing_fillet_arc_length_estimate", "local")
if fillet_arc_local.get("action") != "resize_existing_fillet":
raise SystemExit(f"existing fillet arc length should rebuild existing fillet: {fillet_arc_local}")
if fillet_arc_local.get("target_transform") != "arc_length_to_radius":
raise SystemExit(f"existing fillet arc length should transform to target radius: {fillet_arc_local}")
chain_fillet_info = dict(fillet_info)
chain_fillet_info.update(
{
"feature_type": "简单等半径圆角链候选",
"feature_existing_fillet_chain_face_ids": (10, 11),
"feature_existing_fillet_chain_adjacent_face_ids": (11,),
"existing_fillet_chain_status": "same-radius-chain-candidate",
"existing_fillet_status": "candidate",
"existing_fillet_risk": "high",
}
)
chain_fillet_specs = _specs(chain_fillet_info)
chain_fillet_keys = {str(spec.get("key", "")) for spec in chain_fillet_specs}
_assert_contains(
chain_fillet_keys,
{"existing_fillet_radius_estimate", "existing_fillet_arc_length_estimate"},
"same-radius existing fillet chain feature",
)
chain_arc_local = _scope_mode(chain_fillet_specs, "existing_fillet_arc_length_estimate", "local")
if chain_arc_local.get("action") != "resize_existing_fillet":
raise SystemExit(f"same-radius fillet chain arc length should rebuild the chain: {chain_arc_local}")
if chain_arc_local.get("target_transform") != "arc_length_to_radius":
raise SystemExit(f"same-radius fillet chain arc length should transform to target radius: {chain_arc_local}")
blocked_fillet_info = dict(fillet_info)
blocked_fillet_info.update(
{
"existing_fillet_status": "blocked",
"existing_fillet_risk": "blocked",
"existing_fillet_blockers": "当前圆角与不同半径的圆角面直接相连,属于变半径圆角链。",
"feature_existing_fillet_chain_face_ids": (10, 11),
"feature_existing_fillet_mixed_radius_chain_face_ids": (11,),
}
)
blocked_fillet_keys = _spec_keys(blocked_fillet_info)
if "existing_fillet_radius_estimate" in blocked_fillet_keys or "existing_fillet_arc_length_estimate" in blocked_fillet_keys:
raise SystemExit(f"blocked fillet chain should not expose editable fillet dimensions: {blocked_fillet_keys}")
complex_same_radius_fillet_info = dict(fillet_info)
complex_same_radius_fillet_info.update(
{
"existing_fillet_status": "blocked",
"existing_fillet_risk": "blocked",
"existing_fillet_blockers": "当前圆角链包含至少 5 个同半径圆角面;复杂长链暂未实现稳定重建。",
"existing_fillet_chain_status": "complex-same-radius-chain-candidate",
"feature_existing_fillet_chain_face_ids": (10, 11, 12, 13, 14),
"feature_existing_fillet_same_radius_chain_face_ids": (11, 12, 13, 14),
}
)
complex_same_radius_fillet_keys = _spec_keys(complex_same_radius_fillet_info)
if (
"existing_fillet_radius_estimate" in complex_same_radius_fillet_keys
or "existing_fillet_arc_length_estimate" in complex_same_radius_fillet_keys
):
raise SystemExit(
"complex same-radius fillet chain should not expose editable fillet dimensions: "
f"{complex_same_radius_fillet_keys}"
)
chamfer_info = {
"kind": "feature",
"surface": "plane",
"feature_guess": "chamfer candidate",
"feature_type": "已有倒角平面候选",
"existing_chamfer_status": "candidate",
"existing_chamfer_distance_estimate": 1.5,
"existing_chamfer_cross_edge_length_estimate": 2.121320343559643,
"feature_existing_chamfer_support_face_ids": (1, 2),
"feature_existing_chamfer_long_edge_ids": (10, 11),
"local_face_width": 10.0,
"local_face_height": 2.121320343559643,
"area_center": (0.75, 0.75, 5.0),
"face_center_position": (0.75, 0.75, 5.0),
}
chamfer_specs = _specs(chamfer_info)
chamfer_keys = {str(spec.get("key", "")) for spec in chamfer_specs}
if "existing_chamfer_distance_estimate" not in chamfer_keys:
raise SystemExit(f"existing chamfer should expose distance: {chamfer_keys}")
for leaked_key in ("local_face_width", "local_face_height", "face_center_position", "face_target_normal_position"):
if leaked_key in chamfer_keys:
raise SystemExit(f"existing chamfer should not expose generic Face edit {leaked_key}: {chamfer_keys}")
_assert_label(chamfer_specs, "existing_chamfer_distance_estimate", "倒角距离")
chamfer_distance_spec = _spec(chamfer_specs, "existing_chamfer_distance_estimate")
if chamfer_distance_spec.get("action") != "resize_existing_chamfer":
raise SystemExit(f"existing chamfer distance should call resize_existing_chamfer: {chamfer_distance_spec}")
analytic_cases = (
(
@@ -1002,6 +1228,14 @@ def main() -> int:
{"length", "edge_first_level_topology"},
"line Edge",
)
keep_relation_mode = _scope_mode(edge_specs, "length", "keep-first-level-planar-relations")
if str(keep_relation_mode.get("label") or "") != "保持关系":
raise SystemExit(f"line Edge should expose a compact keep-relations scope: {keep_relation_mode}")
if not bool(keep_relation_mode.get("enabled")):
raise SystemExit(f"line Edge keep-relations scope should be enabled: {keep_relation_mode}")
keep_relation_tip = f"{keep_relation_mode.get('enabled_tip', '')} {keep_relation_mode.get('range_hint', '')}"
if "一级平面" not in keep_relation_tip or "平行/垂直" not in keep_relation_tip:
raise SystemExit(f"line Edge keep-relations scope should explain the planar relation constraint: {keep_relation_tip}")
_assert_contains(
{str(spec.get("key", "")) for spec in _edge_display_specs({"curve": "line", "length": 10.0})},
{"length", "edge_length_anchor_mode"},
@@ -1059,6 +1293,7 @@ def main() -> int:
raise SystemExit(f"{key} disabled tip should explain the recognition blocker: {spec}")
_assert_target_change_detection()
_assert_plane_offset_uses_push_pull_direction()
_assert_property_table_column_widths()
_assert_holed_plane_local_scopes_disabled()
_assert_no_legacy_face_source_terms()
+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())
+1 -1
View File
@@ -12,7 +12,7 @@ SCRIPT_DIR = PROJECT_ROOT / "scripts"
CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
(
"Edge fillet/chamfer and existing fillet rebuild checks",
"Edge fillet/chamfer plus existing fillet/chamfer rebuild checks",
("verify_edge_round_chamfer.py",),
),
(
+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())
+123 -1
View File
@@ -5,7 +5,9 @@ from pathlib import Path
import sys
import tempfile
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox
from OCC.Core.gp import gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(PROJECT_ROOT) not in sys.path:
@@ -20,6 +22,29 @@ def _write_plate_model(path: Path) -> None:
_write_step(shape, path)
def _fuse_shapes(left, right):
op = BRepAlgoAPI_Fuse(left, right)
op.SetFuzzyValue(1e-6)
op.Build()
if not op.IsDone():
raise RuntimeError("open shell fixture fuse failed")
return op.Shape()
def _write_open_thin_wall_box_model(path: Path) -> None:
bottom = BRepPrimAPI_MakeBox(30.0, 20.0, 2.0).Shape()
walls = (
BRepPrimAPI_MakeBox(gp_Pnt(0.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(28.0, 0.0, 2.0), 2.0, 20.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 0.0, 2.0), 26.0, 2.0, 10.0).Shape(),
BRepPrimAPI_MakeBox(gp_Pnt(2.0, 18.0, 2.0), 26.0, 2.0, 10.0).Shape(),
)
shape = bottom
for wall in walls:
shape = _fuse_shapes(shape, wall)
_write_step(shape, path)
def _first_shell_face(model: StepModel, thickness: float, tolerance: float) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
@@ -258,9 +283,101 @@ def _run_case(mode: str, source_thickness: float, target_thickness: float, toler
print(result.encode("ascii", "backslashreplace").decode("ascii"))
def _first_open_shell_wall_face(model: StepModel, thickness: float, tolerance: float) -> int:
open_shell_faces: list[int] = []
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
info = model.feature_info(face_id)
if info.get("open_shell_context_status") == "limited":
open_shell_faces.append(face_id)
if info.get("surface") != "plane":
continue
if info.get("shell_region_status") != "candidate":
continue
if info.get("open_shell_context_status") != "limited":
continue
current = float(info.get("shell_thickness_estimate") or 0.0)
if abs(current - thickness) > tolerance:
continue
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(info.get("shell_confidence")), 3)
candidates.append((confidence_rank, face_id))
if not open_shell_faces:
raise SystemExit("open thin-wall box should expose an open_shell limited context")
if not candidates:
raise SystemExit(f"no open shell wall face with thickness near {thickness:g}")
candidates.sort()
return candidates[0][1]
def _assert_no_open_shell_context(model: StepModel, label: str) -> None:
leaked = [
face_id
for face_id in range(len(model.faces))
if model.feature_info(face_id).get("open_shell_context_status") == "limited"
]
if leaked:
raise SystemExit(f"{label} should not be recognized as open shell context: {leaked}")
def _run_open_shell_context_case(source_thickness: float, target_thickness: float, tolerance: float) -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_open_shell_context_") as temp_dir:
root = Path(temp_dir)
open_path = root / "open_thin_wall_box.step"
_write_open_thin_wall_box_model(open_path)
model = StepModel.load(open_path)
face_id = _first_open_shell_wall_face(model, source_thickness, tolerance)
info = model.feature_info(face_id)
limited_actions = str(info.get("recognition_limited_actions") or "")
if "完整抽壳/开口面编辑" not in limited_actions:
raise SystemExit(f"open shell should show full shell/opening edit as limited: {info}")
ready_actions = str(info.get("recognition_ready_actions") or "")
if "壳体厚度" not in ready_actions:
raise SystemExit(f"open shell wall should keep local thickness editable: {info}")
evidence_keys = tuple(info.get("recognition_evidence_keys") or ())
if "open_shell_context" not in evidence_keys:
raise SystemExit(f"open shell recognition evidence should be explicit: {info}")
local_plan = model.shell_thickness_plan(face_id, target_thickness)
if local_plan.get("status") == "blocked":
raise SystemExit(f"open shell local thickness should remain available: {local_plan}")
if local_plan.get("open_shell_context_status") != "limited":
raise SystemExit(f"open shell fields should be present in local plan: {local_plan}")
if "完整抽壳/开口面" not in str(local_plan.get("message") or ""):
raise SystemExit(f"open shell local plan should explain the limitation: {local_plan}")
owning_plan = model.shell_thickness_owning_scale_plan(face_id, target_thickness)
if owning_plan.get("status") == "blocked":
raise SystemExit(f"open shell owning thickness should remain available: {owning_plan}")
if owning_plan.get("open_shell_context_status") != "limited":
raise SystemExit(f"open shell fields should be present in owning plan: {owning_plan}")
editable_candidates = model.editable_feature_candidates(limit=30, detailed=False)
shell_entries = [
item
for item in editable_candidates
if item.get("operation_key") == "resize_shell_thickness" and int(item.get("target_id", -1)) == face_id
]
if not shell_entries:
raise SystemExit(f"open shell wall should be visible as editable shell thickness candidate: {editable_candidates}")
shell_note = str(shell_entries[0].get("note") or "")
if "完整抽壳/开口面" not in shell_note:
raise SystemExit(f"open shell candidate should explain full shell limitation: {shell_entries[0]}")
closed_path = root / "closed_plate.step"
_write_plate_model(closed_path)
closed_model = StepModel.load(closed_path)
_assert_no_open_shell_context(closed_model, "closed plate")
print("open_shell_context=limited")
print(f"face_id={face_id}")
print(f"limited_actions={limited_actions}")
print(f"local_plan_status={local_plan.get('status')}")
print(f"owning_plan_status={owning_plan.get('status')}")
def main() -> int:
parser = argparse.ArgumentParser(description="Verify shell/thin-wall thickness edit operations.")
parser.add_argument("--mode", default="all", choices=["all", "local", "owning"])
parser.add_argument("--mode", default="all", choices=["all", "local", "owning", "open-shell-context"])
parser.add_argument("--source-thickness", type=float, default=2.0)
parser.add_argument("--target-thickness", type=float, default=3.0)
parser.add_argument("--tolerance", type=float, default=2e-4)
@@ -268,7 +385,12 @@ def main() -> int:
modes = ["local", "owning"] if args.mode == "all" else [args.mode]
for mode in modes:
if mode == "open-shell-context":
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
else:
_run_case(mode, args.source_thickness, args.target_thickness, args.tolerance)
if args.mode == "all":
_run_open_shell_context_case(args.source_thickness, args.target_thickness, args.tolerance)
return 0
+100 -6
View File
@@ -45,6 +45,32 @@ def _write_obround_slot_model(path: Path) -> None:
_write_step(shape, path)
def _write_cross_obround_slot_model(path: Path) -> None:
plate = BRepPrimAPI_MakeBox(40.0, 40.0, 6.0).Shape()
def capsule_tool_x() -> object:
axis_1 = gp_Ax2(gp_Pnt(14.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(26.0, 20.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape()
cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape()
connector = BRepPrimAPI_MakeBox(gp_Pnt(14.0, 17.0, -1.0), 12.0, 6.0, 8.0).Shape()
fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot x tool fuse")
return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot x tool second fuse")
def capsule_tool_y() -> object:
axis_1 = gp_Ax2(gp_Pnt(20.0, 14.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
axis_2 = gp_Ax2(gp_Pnt(20.0, 26.0, -1.0), gp_Dir(0.0, 0.0, 1.0))
cylinder_1 = BRepPrimAPI_MakeCylinder(axis_1, 3.0, 8.0).Shape()
cylinder_2 = BRepPrimAPI_MakeCylinder(axis_2, 3.0, 8.0).Shape()
connector = BRepPrimAPI_MakeBox(gp_Pnt(17.0, 14.0, -1.0), 6.0, 12.0, 8.0).Shape()
fuse_1 = _finalize_boolean_result(BRepAlgoAPI_Fuse(cylinder_1, connector), "verify cross slot y tool fuse")
return _finalize_boolean_result(BRepAlgoAPI_Fuse(fuse_1, cylinder_2), "verify cross slot y tool second fuse")
cut_x = _finalize_boolean_result(BRepAlgoAPI_Cut(plate, capsule_tool_x()), "verify cross slot x cut")
cut_xy = _finalize_boolean_result(BRepAlgoAPI_Cut(cut_x, capsule_tool_y()), "verify cross slot y cut")
_write_step(cut_xy, path)
def _slot_face_ids(model: StepModel) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
@@ -151,6 +177,69 @@ def _obround_total_lengths(model: StepModel) -> list[tuple[int, int, float, floa
return rows
def _complex_slot_face_ids(model: StepModel) -> list[int]:
face_ids: list[int] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
feature = model.feature_info(face_id)
if feature.get("slot_kind") == "partial-cylindrical-groove" and feature.get("slot_status") == "blocked":
face_ids.append(face_id)
return face_ids
def _run_complex_slot_guard() -> None:
with tempfile.TemporaryDirectory(prefix="geom_param_cross_slot_guard_") as temp_dir:
model_path = Path(temp_dir) / "cross_obround_slot.step"
_write_cross_obround_slot_model(model_path)
model = StepModel.load(model_path)
face_ids = _complex_slot_face_ids(model)
if len(face_ids) < 4:
raise SystemExit(f"cross obround slot should expose blocked slot ends, got {face_ids}")
face_id = face_ids[0]
feature = model.feature_info(face_id)
blockers = str(feature.get("slot_blockers") or feature.get("recognition_blockers") or "")
if "交叉槽" not in blockers and "多槽组" not in blockers and "复杂草图槽" not in blockers:
raise SystemExit(f"cross slot blocker should explain the unsupported reason: {feature}")
if "复杂槽" not in str(feature.get("feature_type") or ""):
raise SystemExit(f"cross slot should be labeled as a complex slot: {feature.get('feature_type')}")
candidates = model.editable_feature_candidates(limit=80, detailed=False)
forbidden_actions = {
"resize_cylinder",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_angular_span",
}
leaked = [
(item.get("operation_key"), item.get("target_id"))
for item in candidates
if int(item.get("target_id", -1)) in face_ids and item.get("operation_key") in forbidden_actions
]
if leaked:
raise SystemExit(f"cross slot should not leak editable slot entries: {leaked}")
width_plan = model.cylindrical_slot_resize_plan(face_id, 7.0, "width")
angle_plan = model.cylindrical_slot_angular_span_plan(face_id, math.pi * 0.75)
current_center = _slot_axis_center(model, face_id)
axis_plan = model.cylindrical_slot_axis_move_plan(face_id, (current_center[0] + 1.0, current_center[1], current_center[2]))
total_plan = model.cylindrical_slot_total_length_plan(face_id, 16.0)
for label, plan in (
("width", width_plan),
("angle", angle_plan),
("axis", axis_plan),
("total_length", total_plan),
):
if plan.get("status") != "blocked":
raise SystemExit(f"cross slot {label} plan should be blocked: {plan}")
text = f"{plan.get('message')} {plan.get('blockers')}"
if "交叉槽" not in text and "多槽组" not in text and "复杂草图槽" not in text:
raise SystemExit(f"cross slot {label} plan should explain unsupported complex slot: {plan}")
print(f"complex_slot_guard=blocked_faces {face_ids}")
def _nearest_obround_total_length(model: StepModel, target_total_length: float) -> tuple[int, int, float, float, float]:
best: tuple[int, int, float, float, float] | None = None
for face_id, pair_face_id, total_length, center_distance in _obround_total_lengths(model):
@@ -404,6 +493,7 @@ def main() -> int:
"obround_axis_center",
"total_length",
"center_distance",
"complex_slot_guard",
],
help="Slot metric to verify.",
)
@@ -418,8 +508,8 @@ def main() -> int:
parser.add_argument("--tolerance", type=float, default=2e-4)
args = parser.parse_args()
cases = (
[
if args.mode == "all":
cases = [
("width", args.width),
("depth", args.depth),
("arc_length", args.arc_length),
@@ -428,12 +518,16 @@ def main() -> int:
("obround_axis_center", args.obround_axis_center),
("total_length", args.total_length),
("center_distance", args.center_distance),
("complex_slot_guard", 0.0),
]
if args.mode == "all"
else [(args.mode, getattr(args, args.mode.replace("-", "_")))]
)
elif args.mode == "complex_slot_guard":
cases = [("complex_slot_guard", 0.0)]
else:
cases = [(args.mode, getattr(args, args.mode.replace("-", "_")))]
for mode, target in cases:
if mode == "total_length":
if mode == "complex_slot_guard":
_run_complex_slot_guard()
elif mode == "total_length":
_run_total_length_case(float(target), args.tolerance)
elif mode == "center_distance":
_run_center_distance_case(float(target), args.tolerance)
+4 -2
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
from .model import StepModel
__all__ = ["StepEditorWindow", "StepModel", "main"]
def __getattr__(name: str):
if name == "StepModel":
from .model import StepModel
return StepModel
if name in {"StepEditorWindow", "main"}:
from .app import StepEditorWindow, main
+259 -31
View File
@@ -12,13 +12,14 @@ import vtkmodules.vtkInteractionWidgets # noqa: F401
import vtkmodules.vtkInteractionStyle # noqa: F401
import vtkmodules.vtkRenderingFreeType # noqa: F401
import vtkmodules.vtkRenderingOpenGL2 # noqa: F401
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot, QStringListModel
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QCompleter,
QFileDialog,
QFrame,
QGridLayout,
@@ -54,7 +55,7 @@ from .info_panel import InfoPanelMixin
from .ui_helpers import * # noqa: F403
from .window_actions import WindowActionMixin
from .window_core import WindowCoreMixin
from .window_state import WindowStateMixin
from .window_state import PROPERTY_TABLE_HEADERS, WindowStateMixin
_CRASH_LOG_HANDLE = None
@@ -145,7 +146,7 @@ def _isolated_edit_worker_request(argv: list[str]) -> Path | None:
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
ui_task_requested = Signal(object)
def __init__(self, step_path: str | Path, *, background_load: bool = False):
def __init__(self, step_path: str | Path | None = None, *, background_load: bool = False):
_suppress_vtk_output_window()
super().__init__()
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
@@ -154,13 +155,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.resize(1280, 820)
self.model: StepModel | None = None
self.step_path = Path(step_path)
self.step_path = Path(step_path) if step_path else None
self.selected_kind: str | None = None
self.selected_part_id: int | None = None
self.selected_solid_id: int | None = None
self.selected_face_id: int | None = None
self.selected_edge_id: int | None = None
self.selected_pick_position: tuple[float, float, float] | None = None
self.multi_selected_feature_face_ids: list[int] = []
self.multi_selected_hole_entries: list[dict[str, object]] = []
self.multi_selection_active = False
self.model_actor = None
self.edge_actor = None
@@ -169,7 +173,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.orientation_marker_prop = None
self.step_coordinate_axes_actor = None
self.hide_edges_during_camera_interaction = False
self.hide_overlays_during_camera_interaction = False
self.edge_visibility_before_camera_interaction: int | None = None
self.overlay_visibility_before_camera_interaction: dict[str, int] = {}
self.prefer_fxaa_antialiasing = True
self.fallback_multi_samples = 2
self.interactive_multi_samples = 0
@@ -180,6 +186,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.hover_face_actor = None
self.hover_edge_actor = 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_move_threshold_px = 10
self.pending_hover_position: tuple[int, int] | None = None
@@ -220,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.edge_overlay_polydata_cache: dict[int, object] = {}
self.overlay_cache_limit = 160
self.scene_rebuild_in_progress = False
self.show_internal_edges_checkbox: QCheckBox | None = None
self.scene_isolated = False
self.undo_stack: list[dict[int, object]] = []
@@ -253,6 +262,38 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.pending_scan_kind: str | None = None
self.pending_scan_context: dict[str, object] | None = None
self.scan_wait_cursor_active = False
self.asitus_thread: QThread | None = None
self.asitus_worker: ScanWorker | 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_thread: QThread | None = None
self.load_worker: LoadWorker | None = None
@@ -278,6 +319,18 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.property_editor_selected_row: int | None = None
self.property_command_active_key = ""
self.property_command_buttons: dict[str, QPushButton] = {}
self.relation_formula_items: list[dict[str, object]] = []
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_vtk()
@@ -486,10 +539,40 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
border-color: #bbf7d0;
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 {
color: #14532d;
font-weight: 800;
}
QLabel#scdmBackendStatus {
color: #166534;
font-size: 11px;
font-weight: 700;
}
QLabel#scdmBackendDetail {
color: #3f6212;
font-size: 11px;
}
QLabel#capabilityDetail {
color: #166534;
font-size: 11px;
@@ -585,6 +668,32 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
color: #8f99a8;
font-weight: 650;
}
QPushButton#exportParametersButton {
background: #f0fdfa;
border: 1px solid #0f766e;
border-bottom-color: #115e59;
border-radius: 6px;
color: #134e4a;
font-weight: 750;
min-height: 30px;
padding: 6px 11px;
}
QPushButton#exportParametersButton:hover {
background: #ccfbf1;
border-color: #0d9488;
}
QPushButton#exportParametersButton:pressed {
background: #99f6e4;
border-color: #0f766e;
padding-top: 7px;
padding-bottom: 5px;
}
QPushButton#exportParametersButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
font-weight: 650;
}
QPushButton#propertyRowEditButton {
background: #ea580c;
border: 1px solid #c2410c;
@@ -853,6 +962,47 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
QLineEdit#propertyCardTargetEditor:focus {
border-color: #2563eb;
}
QGroupBox#relationFormulaBox {
margin-top: 2px;
}
QLineEdit#relationFormulaInput {
background: #ffffff;
border: 1px solid #b7c6d9;
border-radius: 5px;
color: #172033;
min-height: 24px;
padding: 3px 6px;
}
QLineEdit#relationFormulaInput:focus {
border: 1px solid #2563eb;
}
QPushButton#relationFormulaAddButton,
QPushButton#relationFormulaRemoveButton {
background: #f8fafc;
border: 1px solid #94a3b8;
border-radius: 5px;
color: #1f2937;
font-weight: 700;
min-height: 26px;
padding: 3px 8px;
}
QPushButton#relationFormulaAddButton:hover,
QPushButton#relationFormulaRemoveButton:hover {
background: #eef6ff;
border-color: #2563eb;
color: #1e3a8a;
}
QPushButton#relationFormulaAddButton:disabled,
QPushButton#relationFormulaRemoveButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
}
QListWidget#relationFormulaList {
background: #ffffff;
border: 1px solid #d8e0eb;
border-radius: 5px;
}
QTabWidget::pane {
border: 1px solid #d8e0eb;
border-radius: 6px;
@@ -885,12 +1035,13 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.open_button.setMinimumWidth(96)
help_tip(self.open_button, "选择并导入一个 .step 或 .stp 几何模型。打开失败时会保留当前模型。")
self.open_button.clicked.connect(self.open_step)
self.path_label = QLineEdit(str(self.step_path))
self.path_label = QLineEdit(str(self.step_path) if self.step_path is not None else "")
self.path_label.setObjectName("stepPathDisplay")
self.path_label.setReadOnly(True)
self.path_label.setMinimumWidth(120)
help_tip(self.path_label, "当前 STEP 文件的完整路径。可以选中文字复制路径。")
self.path_label.setToolTip(str(self.step_path))
self.path_label.setPlaceholderText("未选择 STEP 文件")
self.path_label.setToolTip(str(self.step_path) if self.step_path is not None else "未选择 STEP 文件")
self.path_label.setCursorPosition(0)
self.reload_button = QPushButton("读取模型")
self.reload_button.setMinimumWidth(78)
@@ -918,7 +1069,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
mode_box = QWidget()
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.setObjectName("modeSectionTitle")
self.mode_section_title.setFixedSize(74, 20)
@@ -953,7 +1104,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.mode_combo.setMaximumWidth(112)
help_tip(
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()))
mode_pick_layout.addWidget(self.mouse_mode_label)
@@ -1086,7 +1237,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
export_box.setObjectName("exportSection")
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
export_layout = QVBoxLayout(export_box)
self.export_all_button = QPushButton("导出当前完整 STEP")
self.export_all_button = QPushButton("导出模型")
help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。")
self.export_all_button.clicked.connect(self.export_all)
self.export_part_button = QPushButton("导出选中零件")
@@ -1132,9 +1283,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
object_edit_layout = QVBoxLayout(self.object_edit_box)
object_edit_layout.setContentsMargins(0, 8, 0, 4)
object_edit_layout.setSpacing(4)
self.property_table = QTableWidget(0, 4, self.object_edit_box)
self.property_table = QTableWidget(0, len(PROPERTY_TABLE_HEADERS), self.object_edit_box)
self.property_table.setObjectName("propertyTable")
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值"])
self.property_table.setHorizontalHeaderLabels(list(PROPERTY_TABLE_HEADERS))
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.property_table.setAlternatingRowColors(True)
@@ -1153,17 +1304,19 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
self.property_table.setColumnWidth(0, 148)
self.property_table.setColumnWidth(1, 92)
self.property_table.setColumnWidth(2, 112)
self.property_table.setColumnWidth(3, 96)
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
self.property_table.setColumnWidth(0, 112)
self.property_table.setColumnWidth(1, 104)
self.property_table.setColumnWidth(2, 88)
self.property_table.setColumnWidth(3, 82)
self.property_table.setColumnWidth(4, 66)
self.property_table.installEventFilter(self)
help_tip(
self.property_table,
"特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。",
)
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)
self.property_command_summary_label = QLabel("未选择可编辑对象")
self.property_command_summary_label.setObjectName("propertyCommandSummary")
@@ -1209,22 +1362,99 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.property_expand_button.setMaximumHeight(22)
self.property_expand_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
object_edit_layout.addWidget(self.property_expand_button)
self.relation_formula_box = QGroupBox("关系式")
self.relation_formula_box.setObjectName("relationFormulaBox")
help_tip(
self.relation_formula_box,
"用 FaceID.参数 = 表达式 的形式建立关系式。添加后会立即按当前公式组重新计算并修改模型。",
)
relation_layout = QVBoxLayout(self.relation_formula_box)
relation_layout.setContentsMargins(6, 8, 6, 6)
relation_layout.setSpacing(5)
relation_input_row = QHBoxLayout()
relation_input_row.setContentsMargins(0, 0, 0, 0)
relation_input_row.setSpacing(5)
self.relation_formula_input = QLineEdit("")
self.relation_formula_input.setObjectName("relationFormulaInput")
self.relation_formula_input.setPlaceholderText("Face87.直径 = Face85.直径")
help_tip(
self.relation_formula_input,
"示例:Face87.直径 = Face85.直径,或 Face87.位置 = Face85.位置 + (0, 0, -3.5)。输入 Face87. 后会提示可用参数。",
)
self.relation_formula_completer_model = QStringListModel(self)
self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self)
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.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.textChanged.connect(self._on_relation_formula_input_changed)
self.relation_formula_input.returnPressed.connect(self.add_relation_formula)
self.add_relation_formula_button = QPushButton("添加公式")
self.add_relation_formula_button.setObjectName("relationFormulaAddButton")
self.add_relation_formula_button.clicked.connect(self.add_relation_formula)
relation_input_row.addWidget(self.relation_formula_input, stretch=1)
relation_input_row.addWidget(self.add_relation_formula_button)
relation_layout.addLayout(relation_input_row)
self.relation_formula_list = QListWidget()
self.relation_formula_list.setObjectName("relationFormulaList")
self.relation_formula_list.setMinimumHeight(54)
self.relation_formula_list.setMaximumHeight(86)
self.relation_formula_list.itemSelectionChanged.connect(self._update_relation_formula_buttons)
help_tip(self.relation_formula_list, "已建立的关系式。失效或暂不能映射的公式会在这里标出原因。")
relation_layout.addWidget(self.relation_formula_list)
relation_button_row = QHBoxLayout()
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)
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.setObjectName("relationFormulaRemoveButton")
self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula)
relation_button_row.addWidget(self.remove_relation_formula_button)
relation_layout.addLayout(relation_button_row)
object_edit_layout.addWidget(self.relation_formula_box)
property_action_row = QHBoxLayout()
property_action_row.setContentsMargins(0, 0, 0, 0)
self.apply_property_button = QPushButton("参数化建模")
self.apply_property_button.setObjectName("parametricModelButton")
self.apply_property_button.setMinimumHeight(34)
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.quick_export_all_button = QPushButton("导出当前完整STEP")
self.quick_export_all_button = QPushButton("导出模型")
self.quick_export_all_button.setObjectName("quickExportStepButton")
self.quick_export_all_button.setMinimumHeight(34)
self.quick_export_all_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.quick_export_all_button, "把当前编辑后的完整模型导出为 STEP 文件。导出前会做基础质量检查。")
self.quick_export_all_button.clicked.connect(self.export_all)
self.export_parameters_button = QPushButton("导出参数")
self.export_parameters_button.setObjectName("exportParametersButton")
self.export_parameters_button.setMinimumHeight(34)
self.export_parameters_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.export_parameters_button, "把已勾选的尺寸输入导出为 data.json,并生成可外部调用的参数化组件 main.py。")
self.export_parameters_button.clicked.connect(self.export_selected_parameters)
property_action_row.addWidget(self.apply_property_button)
property_action_row.addWidget(self.quick_export_all_button)
property_action_row.addWidget(self.export_parameters_button)
object_edit_layout.addLayout(property_action_row)
edit_box = QGroupBox(panel)
@@ -1544,16 +1774,14 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
edit_layout.addWidget(self.rotate_solid_button, 27, 0, 1, 2)
panel_layout.addWidget(self.object_edit_box)
self.current_capability_box = QGroupBox("软件进度")
self.current_capability_box.setObjectName("capabilitySection")
capability_layout = QVBoxLayout(self.current_capability_box)
capability_layout.setContentsMargins(8, 8, 8, 7)
capability_layout.setSpacing(2)
self.current_capability_headline = QLabel("当前支持:Face、孔/槽、Edge、凸台、圆角/倒角、壳体")
self.current_capability_headline.setObjectName("capabilityHeadline")
self.current_capability_headline.setWordWrap(True)
capability_layout.addWidget(self.current_capability_headline)
panel_layout.addWidget(self.current_capability_box)
self.current_capability_button = QPushButton("软件进度")
self.current_capability_button.setObjectName("softwareProgressButton")
self.current_capability_button.setMinimumHeight(32)
self.current_capability_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
help_tip(self.current_capability_button, "点击查看当前参数化能力、SCDM 后端状态和后续实施路线。")
self.current_capability_button.clicked.connect(self.show_software_progress_dialog)
panel_layout.addWidget(self.current_capability_button)
self._update_current_capability_panel()
if ENABLE_EXPORT_PANEL:
panel_layout.addWidget(export_box)
if ENABLE_VIEW_PANEL:
@@ -1741,10 +1969,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
def _parse_args(argv: list[str]) -> tuple[Path, bool]:
def _parse_args(argv: list[str]) -> tuple[Path | None, bool]:
smoke_test = "--smoke-test" in argv
paths = [arg for arg in argv[1:] if not arg.startswith("--")]
path = Path(paths[0]) if paths else DEFAULT_MODEL_PATH
path = Path(paths[0]) if paths else None
return path, smoke_test
@@ -1764,7 +1992,7 @@ def main() -> int:
app.setApplicationDisplayName("几何参数化")
app.setOrganizationName("GeometryParametric")
app.setWindowIcon(_application_icon())
window = StepEditorWindow(path, background_load=not smoke_test)
window = StepEditorWindow(path, background_load=bool(path) and not smoke_test)
if smoke_test:
print("smoke test ok")
window.close()
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Iterable
ASITUS_RECOGNIZE_HOLES_ENV = "STEP_EDITOR_ASITUS_RECOGNIZE_HOLES"
ASITUS_DISABLE_ENV = "STEP_EDITOR_DISABLE_ASITUS"
ASITUS_TIMEOUT_ENV = "STEP_EDITOR_ASITUS_TIMEOUT"
def _project_root(project_root: Path | None = None) -> Path:
return project_root or Path(__file__).resolve().parent.parent
def default_asitus_recognize_holes_path(project_root: Path | None = None) -> Path | None:
env_path = os.environ.get(ASITUS_RECOGNIZE_HOLES_ENV, "").strip()
if env_path:
path = Path(env_path).expanduser()
return path if path.is_file() else None
if os.environ.get(ASITUS_DISABLE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}:
return None
root = _project_root(project_root)
candidates = (
root / "third_party" / "asitus_probe_tools_build" / "Release" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_tools_build" / "RelWithDebInfo" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_tools_build" / "Debug" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_build" / "Release" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_build" / "RelWithDebInfo" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_build" / "Debug" / "recognize_holes.exe",
)
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def asitus_runtime_path_entries(project_root: Path | None = None) -> list[Path]:
root = _project_root(project_root)
third_party = root / "third_party" / "3rdparty"
candidates = (
root / "third_party" / "AnalysisSitus_build_algo_occt77" / "win64" / "vc14" / "bin",
third_party / "OCCT" / "win64" / "vc14" / "bin",
third_party / "freeimage-3.17.0-vc14-64" / "bin",
third_party / "freetype-2.5.5-vc14-64" / "bin",
third_party / "tbb_2021.5-vc14-64" / "bin",
third_party / "tcltk-86-64" / "bin",
third_party / "ffmpeg-3.3.4-64" / "bin",
third_party / "openvr-1.14.15-64" / "bin" / "win64",
third_party / "3rdparty-vc14-64" / "freeimage-3.18.0-x64" / "bin",
third_party / "3rdparty-vc14-64" / "freetype-2.13.3-x64" / "bin",
third_party / "3rdparty-vc14-64" / "tbb-2021.13.0-x64" / "bin",
third_party / "3rdparty-vc14-64" / "tcltk-8.6.15-x64" / "bin",
third_party / "3rdparty-vc14-64" / "ffmpeg-3.3.4-64" / "bin",
third_party / "3rdparty-vc14-64" / "openvr-1.14.15-64" / "bin" / "win64",
)
return [path for path in candidates if path.is_dir()]
def parse_asitus_hole_groups(payload: object) -> list[tuple[int, ...]]:
payload = _json_payload(payload)
if not isinstance(payload, dict):
return []
groups: list[tuple[int, ...]] = []
holes = payload.get("holes")
if isinstance(holes, list):
for item in holes:
if not isinstance(item, dict):
continue
group = _int_tuple(item.get("faceIds"))
if group:
groups.append(group)
if groups:
return _dedupe_groups(groups)
flat_ids = _int_tuple(payload.get("holeFaceIds"))
return [flat_ids] if flat_ids else []
def parse_asitus_probe_payload(payload: object) -> dict[str, object]:
data = _json_payload(payload)
if not isinstance(data, dict):
return {
"groups": (),
"faces": (),
"adjacency": (),
"geometric_relations": (),
"surface_summary": {},
"angle_summary": {},
"geometric_relation_summary": {},
"geometric_relation_mode": "",
}
faces: list[dict[str, object]] = []
raw_faces = data.get("faces")
if isinstance(raw_faces, list):
for item in raw_faces:
if not isinstance(item, dict):
continue
face_id = _int_or_none(item.get("id"))
if face_id is None:
continue
faces.append(
{
"id": face_id,
"surface": str(item.get("surface") or ""),
"neighbor_ids": _int_tuple(item.get("neighbors")),
}
)
adjacency: list[dict[str, object]] = []
raw_adjacency = data.get("adjacency")
if isinstance(raw_adjacency, list):
for item in raw_adjacency:
if not isinstance(item, dict):
continue
face_ids = _int_tuple(item.get("faceIds"))
if len(face_ids) != 2:
continue
adjacency.append(
{
"face_ids": face_ids,
"angle_type": str(item.get("angleType") or item.get("type") or ""),
"angle_rad": _float_or_none(item.get("angleRad")),
"edge_ids": _int_tuple(item.get("edgeIds")),
}
)
geometric_relations: list[dict[str, object]] = []
raw_geometric_relations = data.get("geometricRelations")
if isinstance(raw_geometric_relations, list):
for item in raw_geometric_relations:
if not isinstance(item, dict):
continue
face_ids = _int_tuple(item.get("faceIds"))
if len(face_ids) != 2:
continue
geometric_relations.append(
{
"face_ids": face_ids,
"relation_type": str(item.get("type") or item.get("relationType") or ""),
"residual": _float_or_none(item.get("residual")),
"source": str(item.get("source") or "analysis-situs-probe"),
}
)
return {
"groups": tuple(parse_asitus_hole_groups(data)),
"valid_brep": data.get("validBreP"),
"face_count": _int_or_none(data.get("faceCount")),
"aag_node_count": _int_or_none(data.get("aagNodeCount")),
"faces": tuple(faces),
"adjacency": tuple(adjacency),
"geometric_relations": tuple(geometric_relations),
"surface_summary": _str_int_dict(data.get("surfaceSummary")),
"angle_summary": _str_int_dict(data.get("angleSummary")),
"geometric_relation_summary": _str_int_dict(data.get("geometricRelationSummary")),
"geometric_relation_mode": str(data.get("geometricRelationMode") or ""),
}
def _json_payload(payload: object) -> object:
if not isinstance(payload, str):
return payload
text = payload.strip()
json_start = text.find("{")
if json_start > 0:
text = text[json_start:]
return json.loads(text)
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _float_or_none(value: object) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _str_int_dict(value: object) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for key, item in value.items():
try:
result[str(key)] = int(item)
except (TypeError, ValueError):
continue
return result
def _int_tuple(values: object) -> tuple[int, ...]:
if values is None:
return ()
if isinstance(values, (str, bytes)):
return ()
try:
items = list(values) # type: ignore[arg-type]
except TypeError:
return ()
result: list[int] = []
for item in items:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return tuple(sorted(set(result)))
def _dedupe_groups(groups: Iterable[tuple[int, ...]]) -> list[tuple[int, ...]]:
result: list[tuple[int, ...]] = []
seen: set[tuple[int, ...]] = set()
for group in groups:
if not group or group in seen:
continue
seen.add(group)
result.append(group)
return result
def run_asitus_hole_recognition(
step_path: str | Path,
*,
cli_path: str | Path | None = None,
project_root: Path | None = None,
timeout_seconds: float | None = None,
) -> dict[str, object]:
source = Path(step_path).expanduser()
if not source.is_file():
return {"ok": False, "reason": "missing-step", "groups": (), "message": f"STEP file not found: {source}"}
cli = Path(cli_path).expanduser() if cli_path else default_asitus_recognize_holes_path(project_root)
if cli is None or not cli.is_file():
return {"ok": False, "reason": "missing-cli", "groups": (), "message": "Analysis Situs recognize_holes CLI is not available."}
if timeout_seconds is None:
try:
timeout_seconds = float(os.environ.get(ASITUS_TIMEOUT_ENV, "") or 3.0)
except ValueError:
timeout_seconds = 3.0
env = os.environ.copy()
path_entries = [str(path) for path in asitus_runtime_path_entries(project_root)]
env["PATH"] = os.pathsep.join([*path_entries, env.get("PATH", "")])
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
completed = subprocess.run(
[str(cli), str(source)],
cwd=str(_project_root(project_root)),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout_seconds), 0.1),
creationflags=creationflags,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout", "groups": (), "message": "Analysis Situs hole recognition timed out."}
except OSError as exc:
return {"ok": False, "reason": "launch-failed", "groups": (), "message": str(exc)}
if completed.returncode != 0:
message = (completed.stderr or completed.stdout or "").strip()
return {
"ok": False,
"reason": "recognizer-failed",
"returncode": completed.returncode,
"groups": (),
"message": message,
}
try:
parsed = parse_asitus_probe_payload(completed.stdout)
except (json.JSONDecodeError, TypeError, ValueError) as exc:
return {"ok": False, "reason": "bad-json", "groups": (), "message": str(exc), "stdout": completed.stdout}
groups = tuple(parsed.get("groups", ()))
return {
"ok": True,
"reason": "ok",
"groups": groups,
"hole_count": len(groups),
"valid_brep": parsed.get("valid_brep"),
"face_count": parsed.get("face_count"),
"aag_node_count": parsed.get("aag_node_count"),
"faces": tuple(parsed.get("faces", ())),
"adjacency": tuple(parsed.get("adjacency", ())),
"geometric_relations": tuple(parsed.get("geometric_relations", ())),
"surface_summary": dict(parsed.get("surface_summary", {}) or {}),
"angle_summary": dict(parsed.get("angle_summary", {}) or {}),
"geometric_relation_summary": dict(parsed.get("geometric_relation_summary", {}) or {}),
"geometric_relation_mode": str(parsed.get("geometric_relation_mode") or ""),
"cli": str(cli),
"message": f"Analysis Situs recognized {len(groups)} hole groups.",
}
+4 -1
View File
@@ -61,7 +61,7 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .step_io import _prepare_shape_for_step_export, _write_step
from .step_io import _prepare_shape_for_step_export, _write_brep, _write_step
class ExportMixin:
@@ -71,6 +71,9 @@ class ExportMixin:
)
_write_step(export_shape, Path(filename))
def export_internal_brep(self, filename: str | Path) -> None:
_write_brep(self.shape, Path(filename))
def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]:
if scope == "all":
return _shape_quality_info("当前完整模型", self.shape, expect_solid=False)
+319 -43
View File
@@ -61,10 +61,78 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .recognition_priority import feature_recognition_sort_key
from .recognition_priority import external_relation_score_bonus, feature_recognition_sort_key
class FeatureMixin:
def _external_candidate_relation_fields(self, candidate: dict[str, object]) -> dict[str, object]:
face_ids = (
_int_values(candidate.get("feature_highlight_face_ids"))
or _int_values(candidate.get("feature_face_ids"))
or _int_values(candidate.get("face_region_ids"))
or _int_values(candidate.get("face_id"))
)
if not face_ids and str(candidate.get("target_kind") or "") == "face":
face_ids = _int_values(candidate.get("target_id"))
relation_summary_by_type: dict[str, int] = {}
relation_face_count = 0
summary_getter = getattr(self, "_asitus_face_summary_fields", None)
if not callable(summary_getter):
return {}
for face_id in sorted(set(face_ids)):
try:
fields = summary_getter(face_id)
except Exception:
continue
if not isinstance(fields, dict):
continue
relation_count = int(fields.get("asitus_geometric_relation_count") or 0)
if relation_count <= 0:
continue
relation_face_count += 1
for relation_type in fields.get("asitus_geometric_relation_types", ()) or ():
relation_key = str(relation_type or "").strip()
if relation_key:
relation_summary_by_type[relation_key] = relation_summary_by_type.get(relation_key, 0) + 1
if not relation_summary_by_type:
return {}
relation_summary = ", ".join(
f"{key}:{value}" for key, value in sorted(relation_summary_by_type.items())
)
result = {
"external_recognition_relation_source": "analysis-situs",
"external_recognition_relation_summary": relation_summary,
"external_recognition_relation_types": tuple(sorted(relation_summary_by_type)),
"external_recognition_relation_count": sum(relation_summary_by_type.values()),
"external_recognition_relation_face_count": relation_face_count,
}
hint_getter = getattr(self, "_asitus_cylindrical_feature_hint_fields", None)
if callable(hint_getter) and len(set(face_ids)) == 1:
try:
hint_face_id = int(face_ids[0])
hint_context = {**candidate, **result}
cached_feature_getter = getattr(self, "cached_feature_info", None)
cached_feature = cached_feature_getter(hint_face_id) if callable(cached_feature_getter) else None
if isinstance(cached_feature, dict):
hint_context = {**cached_feature, **hint_context}
result.update(hint_getter(hint_face_id, hint_context))
except Exception:
pass
result["recognition_external_relation_score_bonus"] = external_relation_score_bonus(result)
return result
def _with_external_candidate_relation_support(self, candidate: dict[str, object]) -> dict[str, object]:
result = dict(candidate)
fields = self._external_candidate_relation_fields(result)
if not fields:
return result
result.update(fields)
bonus = int(result.get("recognition_external_relation_score_bonus") or 0)
confidence = str(result.get("confidence") or "")
if bonus >= 8 and confidence in {"", "pending", "unchecked", "none", "low"}:
result["confidence"] = "medium"
return result
def _first_level_fact_plan_fields(self, face_id: int, scope: str) -> dict[str, object]:
try:
return self.face_first_level_facts(face_id, scope=scope)
@@ -288,6 +356,19 @@ class FeatureMixin:
max_scan_edges: int | None = None,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
limit = max(1, int(limit))
normalized_max_faces = None if max_scan_faces is None else max(0, int(max_scan_faces))
normalized_max_edges = None if max_scan_edges is None else max(0, int(max_scan_edges))
cache_key = (
"editable",
limit,
bool(detailed),
normalized_max_faces,
normalized_max_edges,
)
cache = getattr(self, "_editable_feature_candidates_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return [dict(item) for item in cache[cache_key]]
per_type_limit = max(1, limit // 5)
candidates: list[dict[str, object]] = []
@@ -302,9 +383,11 @@ class FeatureMixin:
depth_count = 0
suppress_count = 0
existing_fillet_count = 0
existing_chamfer_count = 0
depth_limit = max(2, min(per_type_limit, limit // 12))
suppress_limit = max(2, min(per_type_limit, limit // 12))
existing_fillet_limit = max(2, min(per_type_limit, limit // 12))
existing_chamfer_limit = max(2, min(per_type_limit, limit // 12))
slot_width_limit = max(2, min(per_type_limit, limit // 10))
slot_depth_limit = max(2, min(per_type_limit, limit // 10))
slot_arc_length_limit = max(2, min(per_type_limit, limit // 10))
@@ -313,7 +396,7 @@ class FeatureMixin:
for item in self.cylindrical_feature_candidates(
limit=cylinder_scan_limit,
include_end_info=True,
max_scan_faces=max_scan_faces,
max_scan_faces=normalized_max_faces,
progress_callback=progress_callback,
):
feature_guess = str(item["feature_guess"])
@@ -323,15 +406,27 @@ class FeatureMixin:
if existing_fillet_status == "blocked":
continue
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
chain_face_ids = tuple(feature.get("feature_existing_fillet_chain_face_ids", ()))
chain_status = str(feature.get("existing_fillet_chain_status") or "")
is_same_radius_chain = chain_status == "same-radius-chain-candidate" and len(chain_face_ids) > 1
support_note = (
f"支撑 Face: {support_face_ids}"
if support_face_ids
else "暂未识别出稳定支撑 Face。"
)
fillet_note = (
f"这是简单等半径圆角链候选,链上 Face: {chain_face_ids};点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试一起 defeature 后重新倒圆。"
if is_same_radius_chain
else (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径/弧长,"
"在特征参数表修改后点击“参数化建模”,会尝试 defeature 后重新倒圆。"
)
)
candidates.append(
{
"operation_key": "inspect_existing_fillet",
"operation": "修改已有圆角半径",
"operation": "修改已有圆角半径/弧长" if is_same_radius_chain else "修改已有圆角半径/弧长",
"target_kind": "face",
"target_id": item["face_id"],
"face_id": item["face_id"],
@@ -342,13 +437,9 @@ class FeatureMixin:
"current_value": feature.get("existing_fillet_radius_estimate", item["radius"]),
"current_value_label": "radius",
"status": "caution",
"risk": "medium" if len(support_face_ids) >= 2 else "high",
"risk": "high" if is_same_radius_chain or len(support_face_ids) < 2 else "medium",
"confidence": item["confidence"],
"note": (
"这是已有圆角/倒圆候选;点击后会选中并预填目标半径,"
"再点击“修改已有圆角半径”会尝试 defeature 后重新倒圆。"
f" {support_note}"
),
"note": f"{fillet_note} {support_note}",
}
)
existing_fillet_count += 1
@@ -356,7 +447,7 @@ class FeatureMixin:
if diameter_count < per_type_limit and feature_guess not in {
"round/fillet candidate",
"boss/outer-round candidate",
}:
} and str(item.get("resize_status") or "") != "blocked":
candidates.append(
{
"operation_key": "resize_cylinder",
@@ -388,6 +479,8 @@ class FeatureMixin:
and float(item.get("angular_span", 0.0)) < math.tau * 0.92
):
feature = self.feature_info(int(item["face_id"]))
if str(feature.get("slot_status") or "") != "candidate":
continue
slot_width = feature.get("slot_chord_width_estimate")
if slot_width_count < slot_width_limit and isinstance(slot_width, (int, float)) and float(slot_width) > 0:
candidates.append(
@@ -666,17 +759,70 @@ class FeatureMixin:
plane_count = 0
shell_thickness_count = 0
shell_thickness_limit = max(2, min(per_type_limit, limit // 10))
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
shell_candidate_rows: list[dict[str, object]] = []
shell_scan_pool_limit = max(shell_thickness_limit * 4, 12)
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
if plane_count >= per_type_limit and shell_thickness_count >= shell_thickness_limit:
if (
plane_count >= per_type_limit
and len(shell_candidate_rows) >= shell_scan_pool_limit
and existing_chamfer_count >= existing_chamfer_limit
):
break
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
continue
props = GProp_GProps()
brepgprop.SurfaceProperties(face, props)
feature: dict[str, object] | None = None
quick_info: dict[str, object] | None = None
if existing_chamfer_count < existing_chamfer_limit:
quick_info = self.quick_face_info(face_id)
width = _float_or_none(quick_info.get("local_face_width"))
height = _float_or_none(quick_info.get("local_face_height"))
aspect = max(width or 0.0, height or 0.0) / max(min(width or 0.0, height or 0.0), 1e-9)
if (
int(quick_info.get("boundary_edges", 0) or 0) == 4
and not bool(quick_info.get("has_inner_boundaries"))
and aspect >= 1.8
):
feature = self.feature_info(face_id)
if str(feature.get("existing_chamfer_status") or "") == "candidate":
support_face_ids = tuple(feature.get("feature_existing_chamfer_support_face_ids", ()))
long_edge_ids = tuple(feature.get("feature_existing_chamfer_long_edge_ids", ()))
candidates.append(
{
"operation_key": "inspect_existing_chamfer",
"operation": "修改已有倒角距离",
"target_kind": "face",
"target_id": face_id,
"face_id": face_id,
"part_id": self.face_part_ids[face_id],
"solid_id": self.face_solid_ids[face_id],
"surface": "plane",
"feature_guess": "chamfer candidate",
"current_value": feature.get("existing_chamfer_distance_estimate"),
"current_value_label": "chamfer_distance",
"status": "caution",
"risk": str(feature.get("existing_chamfer_risk") or "medium"),
"confidence": str(feature.get("recognition_confidence") or "medium"),
"note": (
"这是简单已有倒角斜面;点击后会选中该 Face,并把倒角距离填入参数表。"
f" 支撑 Face: {support_face_ids}; 长边 Edge: {long_edge_ids}"
),
}
)
existing_chamfer_count += 1
continue
if plane_count < per_type_limit or len(shell_candidate_rows) < shell_scan_pool_limit:
quick_info = quick_info or self.quick_face_info(face_id)
boundary_edges = int(quick_info.get("boundary_edges", 0) or 0)
if bool(quick_info.get("has_inner_boundaries")) or boundary_edges > 4:
feature = feature or self.feature_info(face_id)
if feature.get("multistep_prismatic_status") == "blocked":
continue
if plane_count < per_type_limit:
if detailed:
direction_info = self._plane_push_pull_direction(face_id, surf)
@@ -709,12 +855,21 @@ class FeatureMixin:
}
)
plane_count += 1
if shell_thickness_count < shell_thickness_limit:
feature = self.feature_info(face_id)
if len(shell_candidate_rows) < shell_scan_pool_limit:
feature = feature or self.feature_info(face_id)
if feature.get("shell_region_status") == "candidate":
shell_confidence = str(feature.get("shell_confidence", "low"))
shell_risk = "low" if shell_confidence == "high" else "medium" if shell_confidence == "medium" else "high"
candidates.append(
shell_note = (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部壳体厚度。"
)
if feature.get("open_shell_context_status") == "limited":
shell_note = (
f"{shell_note} 已识别为开口薄壁壳体上下文;"
"完整抽壳/开口面编辑暂未实现,当前只开放壳体厚度、平面推拉或整体缩放。"
)
shell_candidate_rows.append(
{
"operation_key": "resize_shell_thickness",
"operation": "调整壳体厚度",
@@ -730,12 +885,25 @@ class FeatureMixin:
"status": "ready" if shell_confidence == "high" else "caution",
"risk": shell_risk,
"confidence": shell_confidence,
"note": (
"快速扫描:已找到投影重叠的相对平面;点击后会填入参考目标厚度,"
"执行时会移动当前平面区域来改变局部壳体厚度。"
),
"note": shell_note,
"open_shell_context_status": feature.get("open_shell_context_status", ""),
}
)
def _shell_candidate_sort_key(item: dict[str, object]) -> tuple[int, int, int, float, int]:
confidence_rank = {"high": 0, "medium": 1, "low": 2}.get(str(item.get("confidence") or ""), 3)
risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3}.get(str(item.get("risk") or ""), 3)
thickness = _float_or_none(item.get("current_value"))
return (
0 if item.get("open_shell_context_status") == "limited" else 1,
confidence_rank,
risk_rank,
thickness if thickness is not None else float("inf"),
int(item.get("face_id", 10**9) or 10**9),
)
for shell_candidate in sorted(shell_candidate_rows, key=_shell_candidate_sort_key)[:shell_thickness_limit]:
candidates.append(shell_candidate)
shell_thickness_count += 1
fillet_edge_count = 0
@@ -745,7 +913,7 @@ class FeatureMixin:
ellipse_edge_major_radius_count = 0
ellipse_edge_minor_radius_count = 0
edge_type_limit = max(1, per_type_limit // 3)
edge_scan_limit = len(self.edges) if max_scan_edges is None else min(len(self.edges), max(0, int(max_scan_edges)))
edge_scan_limit = len(self.edges) if normalized_max_edges is None else min(len(self.edges), normalized_max_edges)
for edge_id, edge in enumerate(self.edges[:edge_scan_limit]):
if progress_callback is not None and edge_id % 80 == 0:
progress_callback()
@@ -905,10 +1073,14 @@ class FeatureMixin:
)
ellipse_edge_minor_radius_count += 1
candidates = [self._with_external_candidate_relation_support(candidate) for candidate in candidates]
for candidate in candidates:
candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0]
candidates.sort(key=feature_recognition_sort_key)
return candidates[:limit]
result = [dict(item) for item in candidates[:limit]]
if isinstance(cache, dict):
cache[cache_key] = [dict(item) for item in result]
return [dict(item) for item in result]
def cylindrical_feature_candidates(
self,
@@ -917,8 +1089,19 @@ class FeatureMixin:
max_scan_faces: int | None = None,
progress_callback: Callable[[], None] | None = None,
) -> list[dict[str, object]]:
limit = max(1, int(limit))
normalized_max_faces = None if max_scan_faces is None else max(0, int(max_scan_faces))
cache_key = (
"cylinder",
limit,
bool(include_end_info),
normalized_max_faces,
)
cache = getattr(self, "_cylindrical_feature_candidates_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return [dict(item) for item in cache[cache_key]]
candidates: list[dict[str, object]] = []
face_scan_limit = len(self.faces) if max_scan_faces is None else min(len(self.faces), max(0, int(max_scan_faces)))
face_scan_limit = len(self.faces) if normalized_max_faces is None else min(len(self.faces), normalized_max_faces)
for face_id, face in enumerate(self.faces[:face_scan_limit]):
if progress_callback is not None and face_id % 30 == 0:
progress_callback()
@@ -959,10 +1142,48 @@ class FeatureMixin:
candidate.update(self._cylinder_end_opening_info(face_id, surf))
candidate.update(_cylinder_resize_readiness(candidate))
candidate.update(_cylinder_boss_resize_readiness(candidate))
candidates.append(candidate)
try:
feature = self.feature_info(face_id)
for key in (
"feature_guess",
"feature_type",
"feature_edit_actions",
"confidence",
"slot_kind",
"slot_status",
"slot_risk",
"slot_blockers",
"resize_status",
"resize_risk",
"resize_blockers",
"resize_note",
"boss_resize_status",
"boss_resize_risk",
"boss_resize_blockers",
"boss_resize_note",
"recognition_risk",
"recognition_blockers",
"analysis_situs_feature_hint_status",
"analysis_situs_feature_hint_preferred",
"analysis_situs_feature_hint_label",
"analysis_situs_feature_hint_score",
"analysis_situs_feature_hint_summary",
"analysis_situs_feature_hint_related_face_ids",
"analysis_situs_slot_hint_score",
"analysis_situs_boss_hint_score",
"analysis_situs_fillet_hint_score",
):
if feature.get(key) not in {None, ""}:
candidate[key] = feature.get(key)
except Exception:
pass
candidates.append(self._with_external_candidate_relation_support(candidate))
if len(candidates) >= limit:
break
return candidates
result = [dict(item) for item in candidates]
if isinstance(cache, dict):
cache[cache_key] = [dict(item) for item in result]
return [dict(item) for item in result]
def cylindrical_resize_plan(self, face_id: int, new_diameter: float) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
@@ -976,7 +1197,31 @@ class FeatureMixin:
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
if str(feature.get("slot_status") or "") == "blocked":
blocker = str(feature.get("slot_blockers") or "当前槽/半孔属于复杂槽或多槽组,暂不开放稳定修改。")
return {
"status": "blocked",
"risk": "blocked",
"message": blocker,
"warnings": "",
"blockers": blocker,
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"current_diameter": current_diameter,
"target_diameter": new_diameter,
"feature_type": feature.get("feature_type"),
"feature_guess": feature.get("feature_guess"),
"slot_status": feature.get("slot_status"),
"slot_blockers": blocker,
}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
feature_angular_span = (
_float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(info.get("angular_span"))
)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -985,6 +1230,10 @@ class FeatureMixin:
scoped_info = dict(info)
scoped_info["height_estimate"] = axis_range["span"]
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
if feature_angular_span is not None:
scoped_info["angular_span"] = feature_angular_span
scoped_info["same_domain_angular_span"] = feature_angular_span
scoped_info["is_full_cylinder"] = feature_angular_span >= math.tau * 0.92
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
@@ -1029,7 +1278,6 @@ class FeatureMixin:
"feature_bottom_note": feature.get("feature_bottom_note"),
"feature_guess": info.get("feature_guess"),
"confidence": info.get("confidence"),
"angular_span": info.get("angular_span"),
"height_estimate": scoped_info.get("height_estimate"),
"same_domain_face_ids": axis_range["same_domain_face_ids"],
"same_domain_face_count": axis_range["same_domain_face_count"],
@@ -1043,6 +1291,9 @@ class FeatureMixin:
**topology_fields,
**cutter_plan,
**fill_plan,
"angular_span": scoped_info.get("angular_span"),
"same_domain_angular_span": scoped_info.get("same_domain_angular_span"),
"is_full_cylinder": scoped_info.get("is_full_cylinder", feature.get("is_full_cylinder")),
}
def cylindrical_axis_move_plan(
@@ -1075,7 +1326,13 @@ class FeatureMixin:
blockers.append("Target cylinder axis center must be three numeric coordinates.")
current_diameter = _float_or_none(info.get("diameter"))
angular_span = _float_or_none(info.get("angular_span"))
selected_angular_span = _float_or_none(info.get("angular_span"))
angular_span = (
_float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or selected_angular_span
)
feature_guess = str(info.get("feature_guess", ""))
confidence = str(info.get("confidence", "low"))
surf = BRepAdaptor_Surface(self.faces[face_id])
@@ -1184,13 +1441,17 @@ class FeatureMixin:
"axis_move_radial_distance": radial_distance,
"axis": axis_direction,
"angular_span": angular_span,
"selected_angular_span": selected_angular_span,
"same_domain_angular_span": feature.get("same_domain_angular_span") or info.get("same_domain_angular_span"),
"is_full_cylinder": feature.get("is_full_cylinder", info.get("is_full_cylinder")),
"same_domain_face_ids": axis_range.get("same_domain_face_ids", ()),
"same_domain_face_count": axis_range.get("same_domain_face_count", 0),
"same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
"same_domain_range_source": axis_range.get("range_source", ""),
"supports_isolation": True,
"resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder",
"edit_strategy_label": "填旧孔并切新孔",
"edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。",
"edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标位置切出新孔;这会改变孔的位置,不会整体平移零件。",
}
def cylindrical_slot_resize_plan(
@@ -1271,8 +1532,7 @@ class FeatureMixin:
if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92:
blockers.append("Selected slot does not have a stable partial-cylinder angular span.")
if slot_status and slot_status != "candidate":
risk = _max_risk(risk, "high")
warnings.append(f"Slot candidate status is {slot_status}.")
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
span = min(max(float(angular_span or 0.0), 1e-6), math.tau - 1e-6)
sin_half_span = math.sin(span / 2.0)
@@ -1464,8 +1724,7 @@ class FeatureMixin:
if target_angular_span >= math.tau * 0.92:
blockers.append("Target slot angular span must remain below a near-full cylinder.")
if slot_status and slot_status != "candidate":
risk = _max_risk(risk, "high")
warnings.append(f"Slot candidate status is {slot_status}.")
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
delta_span = None if current_span <= 0 else target_angular_span - current_span
delta_ratio = (
@@ -1676,8 +1935,7 @@ class FeatureMixin:
risk = _max_risk(risk, "medium")
warnings.append("Slot recognition confidence is low.")
if slot_status and slot_status != "candidate":
risk = _max_risk(risk, "high")
warnings.append(f"Slot candidate status is {slot_status}.")
blockers.append(str(feature.get("slot_blockers") or info.get("slot_blockers") or f"Slot candidate status is {slot_status}."))
cutter_plan: dict[str, object] = {}
fill_plan: dict[str, object] = {}
@@ -2053,6 +2311,8 @@ class FeatureMixin:
blockers.append("Current slot face is not recognized as one end of a paired obround slot.")
else:
blockers.append("Manual paired Face ID could not be used as a compatible obround slot end.")
if str(feature.get("slot_status") or "") not in {"", "candidate"}:
blockers.append(str(feature.get("slot_blockers") or f"Slot candidate status is {feature.get('slot_status')}."))
if target_total_length <= 0:
blockers.append("Target slot total length must be greater than 0.")
@@ -2479,7 +2739,9 @@ class FeatureMixin:
"radius": info.get("radius"),
"axis": info.get("axis"),
"axis_point": info.get("axis_point"),
"angular_span": info.get("angular_span"),
"angular_span": scoped_info.get("angular_span"),
"same_domain_angular_span": scoped_info.get("same_domain_angular_span"),
"is_full_cylinder": scoped_info.get("is_full_cylinder", feature.get("is_full_cylinder")),
"height_estimate": scoped_info.get("height_estimate"),
"feature_type": feature.get("feature_type"),
"feature_guess": info.get("feature_guess"),
@@ -3171,6 +3433,7 @@ class FeatureMixin:
candidates: list[tuple[float, tuple[float, float, float], dict[str, object]]] = []
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
plane_axis = surf.Plane().Axis().Direction()
for adjacent_id in adjacent_face_ids:
if adjacent_id < 0 or adjacent_id >= len(self.faces):
continue
@@ -3186,20 +3449,31 @@ class FeatureMixin:
axis = cylinder.Axis()
axis_point = axis.Location()
axis_dir = axis.Direction()
try:
axis_range = self._cylindrical_axis_range(adjacent_id, side_surf)
except Exception:
axis_range = {
"v_min": min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter())),
"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"])
plane_axis_alignment = abs(_direction_dot(plane_axis, axis_dir))
if plane_axis_alignment < 0.92:
continue
source_v_min = min(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
source_v_max = max(float(side_surf.FirstVParameter()), float(side_surf.LastVParameter()))
v_min = source_v_min
v_max = source_v_max
range_source = "selected-face-v-range-fast"
height = max(v_max - v_min, 1e-9)
cap_parameter = _axis_parameter(axis_point, axis_dir, plane_point)
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)
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:
outward = _neg_tuple(_dir_tuple(axis_dir))
end_label = "start"
@@ -3217,9 +3491,11 @@ class FeatureMixin:
{
"cap_axis_face_id": adjacent_id,
"cap_axis_end": end_label,
"cap_plane_axis_alignment": plane_axis_alignment,
"cap_axis_parameter": cap_parameter,
"cap_axis_start_parameter": v_min,
"cap_axis_end_parameter": v_max,
"cap_axis_range_source": range_source,
},
)
)
+10
View File
@@ -625,7 +625,16 @@ def _axis_aligned_edge_candidates(
return [edge for _score, edge in candidates]
def _enable_occt_builder_parallel(builder) -> None:
if hasattr(builder, "SetRunParallel"):
try:
builder.SetRunParallel(True)
except Exception:
pass
def _finalize_boolean_result(op, operation_name: str, *, use_glue: bool | None = None) -> TopoDS_Shape:
_enable_occt_builder_parallel(op)
op.SetNonDestructive(True)
glue_enabled = "cut" not in operation_name.lower() if use_glue is None else bool(use_glue)
if glue_enabled and hasattr(op, "SetGlue"):
@@ -668,6 +677,7 @@ def _simplify_boolean_builder(builder) -> None:
def _finalize_builder_result(builder, operation_name: str) -> TopoDS_Shape:
_enable_occt_builder_parallel(builder)
builder.Build()
if hasattr(builder, "IsDone") and not builder.IsDone():
raise RuntimeError(f"{operation_name} operation failed.")
+56 -2
View File
@@ -15,6 +15,9 @@ def _optional_int(value: object) -> int | None:
def _point3(value: object, operation: str) -> tuple[float, float, float]:
if isinstance(value, str):
point = [chunk.strip() for chunk in value.strip().strip("()[]").replace(";", ",").split(",") if chunk.strip()]
else:
point = list(value) if isinstance(value, (list, tuple)) else []
if len(point) != 3:
raise ValueError(f"{operation} requires a 3D target center.")
@@ -24,14 +27,20 @@ def _point3(value: object, operation: str) -> tuple[float, float, float]:
def _execute(model: StepModel, operation: str, args: list[object]) -> str:
if operation == "push_pull_face":
return model.push_pull_face(int(args[0]), float(args[1]))
if operation == "push_pull_face_keep_relations":
return model.push_pull_face_keep_relations(int(args[0]), float(args[1]))
if operation == "move_face_plane_offset_local":
return model.move_face_plane_offset_local(int(args[0]), float(args[1]))
if operation == "translate_face_plane_offset_owning":
return model.translate_face_plane_offset_owning(int(args[0]), float(args[1]))
if operation == "resize_face_area_local":
return model.resize_face_area_local(int(args[0]), float(args[1]))
if operation == "resize_face_area":
return model.resize_face_area(int(args[0]), float(args[1]))
if operation == "resize_face_size_local":
return model.resize_face_size_local(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_face_size_local_keep_relations":
return model.resize_face_size_local_keep_relations(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_face_size_owning_scale":
return model.resize_face_size_owning_scale(int(args[0]), float(args[1]), str(args[2]))
if operation == "move_face_center_local":
@@ -42,6 +51,14 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
int(args[0]),
(float(center[0]), float(center[1]), float(center[2])),
)
if operation == "move_face_center_local_keep_relations":
center = list(args[1])
if len(center) != 3:
raise ValueError("move_face_center_local_keep_relations requires a 3D target center.")
return model.move_face_center_local_keep_relations(
int(args[0]),
(float(center[0]), float(center[1]), float(center[2])),
)
if operation == "resize_shell_thickness":
return model.resize_shell_thickness(int(args[0]), float(args[1]))
if operation == "resize_shell_thickness_owning_scale":
@@ -50,6 +67,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_cylindrical_height(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_boss_height":
return model.resize_cylindrical_boss_height(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_boss":
return model.resize_cylindrical_boss(int(args[0]), float(args[1]))
if operation == "resize_cylindrical_height_owning_scale":
return model.resize_cylindrical_height_owning_scale(int(args[0]), float(args[1]))
if operation == "resize_cone_reference_radius":
@@ -62,10 +81,22 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_toroidal_radius(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_cylindrical_hole":
return model.resize_cylindrical_hole(int(args[0]), float(args[1]))
if operation == "edit_cylindrical_holes_by_refs":
offset = _point3(args[2], operation) if len(args) > 2 and args[2] is not None and args[2] != "" else None
diameter = None if len(args) <= 1 or args[1] in {None, ""} else float(args[1])
return model.edit_cylindrical_holes_by_refs(list(args[0]), target_diameter=diameter, offset=offset)
if operation == "resize_cylindrical_holes_by_refs":
return model.resize_cylindrical_holes_by_refs(list(args[0]), float(args[1]))
if operation == "move_cylindrical_holes_by_offset":
return model.move_cylindrical_holes_by_offset(list(args[0]), _point3(args[1], operation))
if operation == "suppress_cylindrical_holes_by_refs":
return model.suppress_cylindrical_holes_by_refs(list(args[0]))
if operation == "resize_cylindrical_owning_scale":
return model.resize_cylindrical_owning_scale(int(args[0]), float(args[1]))
if operation == "move_cylindrical_hole_axis":
return model.move_cylindrical_hole_axis(int(args[0]), _point3(args[1], operation))
if operation == "move_cylindrical_boss_axis":
return model.move_cylindrical_boss_axis(int(args[0]), _point3(args[1], operation))
if operation == "suppress_cylindrical_hole":
return model.suppress_cylindrical_hole(int(args[0]))
if operation == "resize_cylindrical_depth":
@@ -117,6 +148,8 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_ellipse_edge_axis_radius(int(args[0]), float(args[1]), axis_kind=axis_kind)
if operation == "resize_existing_fillet":
return model.resize_existing_fillet(int(args[0]), float(args[1]))
if operation == "resize_existing_chamfer":
return model.resize_existing_chamfer(int(args[0]), float(args[1]))
if operation == "fillet_edge":
return model.fillet_edge(int(args[0]), float(args[1]))
if operation == "chamfer_edge":
@@ -130,6 +163,24 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
raise ValueError(f"Unsupported isolated edit operation: {operation}")
def _load_request_model(path: Path, file_format: str) -> StepModel:
if file_format == "brep":
return StepModel.load_internal_brep(path)
if file_format == "step":
return StepModel.load(path)
raise ValueError(f"Unsupported isolated edit input format: {file_format}")
def _export_response_model(model: StepModel, path: Path, file_format: str) -> None:
if file_format == "brep":
model.export_internal_brep(path)
return
if file_format == "step":
model.export_all(path)
return
raise ValueError(f"Unsupported isolated edit output format: {file_format}")
def run_request(request: str | Path) -> int:
request_path = Path(request)
response_path = request_path.with_suffix(".response.json")
@@ -139,10 +190,12 @@ def run_request(request: str | Path) -> int:
output_path = Path(str(request["output_path"]))
operation = str(request["operation"])
args = list(request.get("args") or [])
input_format = str(request.get("input_format") or "step").strip().lower()
output_format = str(request.get("output_format") or input_format).strip().lower()
model = StepModel.load(input_path)
model = _load_request_model(input_path, input_format)
message = _execute(model, operation, args)
model.export_all(output_path)
_export_response_model(model, output_path, output_format)
response_path.write_text(
json.dumps(
{
@@ -150,6 +203,7 @@ def run_request(request: str | Path) -> int:
"message": message,
"stats": model.stats().__dict__,
"output_path": str(output_path),
"output_format": output_format,
},
ensure_ascii=False,
indent=2,
+2269 -64
View File
File diff suppressed because it is too large Load Diff
+1180 -80
View File
File diff suppressed because it is too large Load Diff
+609
View File
@@ -0,0 +1,609 @@
from __future__ import annotations
import argparse
from datetime import datetime
import json
import re
from pathlib import Path
import sys
import traceback
from .isolated_edit_worker import _execute
from .model import StepModel
COMPONENT_SCHEMA = "step-editor-parametric-component-v1"
_COMPONENT_SEQUENCE_RE = re.compile(r"^(?P<index>\d{3,})_(?P<name>.+)$")
_ACTION_OPERATION_MAP = {
"push_pull_face": "push_pull_face",
"push_pull_face_keep_relations": "push_pull_face_keep_relations",
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
"move_selected_face_plane_position_by_translation": "translate_face_plane_offset_owning",
"resize_face_width_local": "resize_face_size_local",
"resize_face_height_local": "resize_face_size_local",
"resize_face_width_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_height_keep_relations": "resize_face_size_local_keep_relations",
"resize_face_width_owning_scale": "resize_face_size_owning_scale",
"resize_face_height_owning_scale": "resize_face_size_owning_scale",
"resize_shell_thickness": "resize_shell_thickness",
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
"resize_hole": "resize_cylindrical_hole",
"resize_multi_selected_holes": "resize_cylindrical_holes_by_refs",
"move_multi_selected_holes_by_offset": "move_cylindrical_holes_by_offset",
"suppress_multi_selected_holes": "suppress_cylindrical_holes_by_refs",
"resize_cylindrical_owning_scale": "resize_cylindrical_owning_scale",
"resize_hole_depth": "resize_cylindrical_depth",
"resize_hole_depth_owning_scale": "resize_cylindrical_depth_owning_scale",
"resize_slot_width": "resize_cylindrical_slot_width",
"resize_slot_depth": "resize_cylindrical_slot_depth",
"resize_slot_arc_length": "resize_cylindrical_slot_arc_length",
"resize_slot_angular_span": "resize_cylindrical_slot_angular_span",
"resize_slot_total_length": "resize_cylindrical_slot_total_length",
"resize_slot_center_distance": "resize_cylindrical_slot_center_distance",
"move_cylindrical_hole_axis": "move_cylindrical_hole_axis",
"move_cylindrical_slot_axis": "move_cylindrical_slot_axis",
"suppress_hole": "suppress_cylindrical_hole",
"resize_boss": "resize_cylindrical_boss",
"resize_boss_height": "resize_cylindrical_boss_height",
"resize_cylinder_height": "resize_cylindrical_height",
"resize_cylindrical_height_owning_scale": "resize_cylindrical_height_owning_scale",
"move_cylindrical_boss_axis": "move_cylindrical_boss_axis",
"resize_cone_reference_radius": "resize_cone_reference_radius",
"resize_cone_semi_angle": "resize_cone_semi_angle",
"resize_sphere_radius": "resize_sphere_radius",
"resize_torus_major_radius": "resize_torus_radius",
"resize_torus_minor_radius": "resize_torus_radius",
"resize_any_edge_length": "resize_general_edge_length",
"move_edge_start_point": "move_edge_endpoint",
"move_edge_end_point": "move_edge_endpoint",
"move_edge_center_point": "move_edge_center",
"move_circular_edge_axis_center": "move_circular_edge_axis_center",
"resize_ellipse_edge_major_radius": "resize_ellipse_edge_axis_radius",
"resize_ellipse_edge_minor_radius": "resize_ellipse_edge_axis_radius",
"resize_existing_fillet": "resize_existing_fillet",
"resize_existing_chamfer": "resize_existing_chamfer",
"fillet_edge": "fillet_edge",
"chamfer_edge": "chamfer_edge",
"chamfer_edge_asymmetric": "chamfer_edge_asymmetric",
"chamfer_edge_distance_angle": "chamfer_edge_distance_angle",
}
def sanitize_component_name(value: object, fallback: str = "STEP_Parametric") -> str:
text = str(value or "").strip() or fallback
text = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', "_", text)
text = re.sub(r"\s+", "_", text).strip(" ._")
return text or fallback
def default_component_root(project_root: Path | None = None) -> Path:
root = project_root or Path(__file__).resolve().parent.parent
return root / "nodes"
def next_component_dir(root: Path, component_name: object) -> Path:
base_name = sanitize_component_name(component_name)
max_index = -1
if root.is_dir():
for child in root.iterdir():
if not child.is_dir():
continue
match = _COMPONENT_SEQUENCE_RE.match(child.name)
if match:
max_index = max(max_index, int(match.group("index")))
return root / f"{max_index + 1:03d}_{base_name}"
def component_name_from_step(step_path: object) -> str:
try:
stem = Path(str(step_path)).stem
except Exception:
stem = ""
return sanitize_component_name(f"{stem}_STEP参数化组件" if stem else "STEP参数化组件")
def numeric_text(value: object) -> str:
text = str(value if value is not None else "").strip()
return text
def json_script_literal(value: object) -> str:
return json.dumps(value, ensure_ascii=False, indent=4)
def _float_or_text(value: object) -> object:
if isinstance(value, (int, float)):
return value
text = str(value if value is not None else "").strip()
try:
return float(text)
except ValueError:
return text
def _as_list3(value: object) -> list[float] | None:
if isinstance(value, str):
chunks = [chunk.strip() for chunk in value.strip().strip("()[]").replace(";", ",").split(",") if chunk.strip()]
elif isinstance(value, (tuple, list)):
chunks = list(value)
else:
return None
if len(chunks) != 3:
return None
try:
return [float(chunks[0]), float(chunks[1]), float(chunks[2])]
except (TypeError, ValueError):
return None
def _target_object_id(spec: dict[str, object], selected_kind: str | None, selected_face_id: int | None, selected_edge_id: int | None) -> int | None:
if spec.get("source_face_id") not in {"", None}:
return int(spec["source_face_id"])
action = str(spec.get("action") or "")
kind = str(selected_kind or "")
if "edge" in action and selected_edge_id is not None and kind == "edge":
return int(selected_edge_id)
if selected_face_id is not None:
return int(selected_face_id)
if selected_edge_id is not None:
return int(selected_edge_id)
return None
def _target_kind_for_action(action: str, selected_kind: str | None) -> str:
if "edge" in action:
return "edge"
if selected_kind in {"face", "feature", "edge"}:
return str(selected_kind)
return "face"
def operation_for_action(action: object) -> str | None:
return _ACTION_OPERATION_MAP.get(str(action or ""))
def _axis_arg_for_face_size(action: str) -> str | None:
if "face_width" in action:
return "width"
if "face_height" in action:
return "height"
return None
def _mode_arg_for_torus(action: str) -> str | None:
if action == "resize_torus_major_radius":
return "major"
if action == "resize_torus_minor_radius":
return "minor"
return None
def _endpoint_arg_for_edge(action: str) -> str | None:
if action == "move_edge_start_point":
return "start"
if action == "move_edge_end_point":
return "end"
return None
def _ellipse_axis_arg(action: str) -> str | None:
if action == "resize_ellipse_edge_major_radius":
return "major"
if action == "resize_ellipse_edge_minor_radius":
return "minor"
return None
def component_edit_config_from_spec(
*,
parameter_row: dict[str, str],
spec: dict[str, object],
selected_kind: str | None,
selected_face_id: int | None,
selected_edge_id: int | None,
step_path: Path | None,
) -> dict[str, object] | None:
action = str(spec.get("action") or "")
operation = operation_for_action(action)
if action in {"resize_multi_selected_holes", "move_multi_selected_holes_by_offset"}:
refs = [dict(item) for item in (spec.get("multi_hole_refs") or []) if isinstance(item, dict)]
if not operation or len(refs) < 2:
return None
value_type = str(spec.get("value_type", "number"))
default_value = parameter_row.get("default", "")
target_value: object
if value_type == "vector3":
target_value = _as_list3(default_value) or _as_list3(spec.get("current_raw")) or default_value
elif value_type in {"number", "positive", "integer", "integer_or_empty"}:
target_value = _float_or_text(default_value)
else:
target_value = default_value
target_arg: object = {"param": parameter_row["name"]}
transform = str(spec.get("target_transform") or "")
if transform:
target_arg = {
"param": parameter_row["name"],
"transform": transform,
"context": spec.get("transform_context", {}),
}
return {
"parameter": parameter_row["name"],
"displayName": parameter_row.get("displayName", parameter_row["name"]),
"targetKind": "multi_feature",
"targetId": -1,
"uiAction": action,
"operation": operation,
"args": [refs, target_arg],
"default": target_value,
"valueType": value_type,
"scope": spec.get("scope_key", spec.get("scope_default", "")),
"scopeLabel": spec.get("scope_label", spec.get("scope_text", "")),
"sourceStep": str(step_path or ""),
"parameterKey": spec.get("key", ""),
}
target_id = _target_object_id(spec, selected_kind, selected_face_id, selected_edge_id)
if not operation or target_id is None:
return None
value_type = str(spec.get("value_type", "number"))
default_value = parameter_row.get("default", "")
target_value: object
if value_type == "vector3":
target_value = _as_list3(default_value) or _as_list3(spec.get("current_raw")) or default_value
elif value_type in {"number", "positive", "integer", "integer_or_empty"}:
target_value = _float_or_text(default_value)
else:
target_value = default_value
args: list[object] = [int(target_id)]
target_arg: object = {"param": parameter_row["name"]}
transform = str(spec.get("target_transform") or "")
if transform:
target_arg = {
"param": parameter_row["name"],
"transform": transform,
"context": spec.get("transform_context", {}),
}
if action == "suppress_hole":
target_value = ""
else:
args.append(target_arg)
axis_arg = _axis_arg_for_face_size(action)
if axis_arg is not None:
args.append(axis_arg)
elif action in {"resize_torus_major_radius", "resize_torus_minor_radius"}:
args.append(_mode_arg_for_torus(action))
elif action in {"move_edge_start_point", "move_edge_end_point"}:
args.insert(1, _endpoint_arg_for_edge(action))
elif action in {"resize_ellipse_edge_major_radius", "resize_ellipse_edge_minor_radius"}:
args.append(_ellipse_axis_arg(action))
elif action in {
"resize_hole_depth",
"resize_hole_depth_owning_scale",
"resize_slot_width",
"resize_slot_depth",
"resize_slot_arc_length",
"resize_slot_total_length",
"resize_slot_center_distance",
}:
manual_id = spec.get("manual_bottom_face_id")
if manual_id in {"", None}:
manual_id = spec.get("slot_pair_manual_face_id")
args.append("" if manual_id in {"", None} else manual_id)
return {
"parameter": parameter_row["name"],
"displayName": parameter_row.get("displayName", parameter_row["name"]),
"targetKind": _target_kind_for_action(action, selected_kind),
"targetId": int(target_id),
"uiAction": action,
"operation": operation,
"args": args,
"default": target_value,
"valueType": value_type,
"scope": spec.get("scope_key", spec.get("scope_default", "")),
"scopeLabel": spec.get("scope_label", spec.get("scope_text", "")),
"sourceStep": str(step_path or ""),
"parameterKey": spec.get("key", ""),
}
def render_component_main_py(component: dict[str, object]) -> str:
project_root = str(Path(__file__).resolve().parent.parent)
component_name = sanitize_component_name(component.get("componentName") or component_name_from_step(component.get("sourceStep")))
output_parameter = {
"name": "output_step",
"displayName": "输出STEP",
"type": "file",
"ioRole": "output",
"default": "",
}
return f'''# -*- coding: utf-8 -*-
"""
STEP 参数化组件
这个文件按 FlowEditor 节点脚本方式生成
1. INPUT_PARAMETERS 是从软件导出参数勾选行直接嵌入的输入参数
2. PARAMETERS 会额外加上输出 STEP 文件参数供节点设计器生成输出端口
3. execute(inputs, params, context) FlowEditor 调用入口
4. main() 只用于本地命令行调试
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import sys
PROJECT_ROOT = {json.dumps(project_root, ensure_ascii=False)}
if PROJECT_ROOT and PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from step_editor.parametric_component import run_embedded_component
INPUT_PARAMETERS = {json_script_literal(component.get("parameters", []))}
OUTPUT_PARAMETERS = [
{json_script_literal(output_parameter)}
]
PARAMETERS = INPUT_PARAMETERS + OUTPUT_PARAMETERS
COMPONENT = {json_script_literal(component)}
NODE_INFO = {{
"typeName": {json.dumps(component_name, ensure_ascii=False)},
"displayName": {json.dumps(component_name, ensure_ascii=False)},
"category": "几何参数化",
"icon": "icon.svg",
"parameters": PARAMETERS,
}}
def _value_from_inputs(name, inputs, params, default=""):
value = inputs.get(name) if isinstance(inputs, dict) else None
if value in (None, "") and isinstance(params, dict):
value = params.get(name)
if value in (None, ""):
value = default
return value
def _component_input_values(inputs, params):
values = {{}}
for item in INPUT_PARAMETERS:
name = item.get("name")
if not name:
continue
values[name] = _value_from_inputs(name, inputs, params, item.get("default", ""))
return values
def _default_output_step(work_dir, output_dir):
output_root = output_dir or os.path.join(work_dir, "output")
os.makedirs(output_root, exist_ok=True)
return os.path.join(output_root, COMPONENT.get("outputName") or "modified.step")
def run(inputs=None, output_step=None, work_dir=None):
return run_embedded_component(COMPONENT, inputs=inputs, output_step=output_step, work_dir=work_dir)
def execute(inputs, params, context):
"""
FlowEditor 调用入口
inputs上游节点传入值优先级高于 params
params节点属性面板参数
contextFlowEditor 上下文常见字段包括 work_dir / input_dir / output_dir
"""
inputs = inputs or {{}}
params = params or {{}}
context = context or {{}}
work_dir = context.get("work_dir") or os.getcwd()
output_dir = context.get("output_dir") or os.path.join(work_dir, "output")
output_step = _default_output_step(work_dir, output_dir)
result = run(
inputs=_component_input_values(inputs, params),
output_step=output_step,
work_dir=work_dir,
)
if not result.get("ok"):
raise RuntimeError(result.get("error") or json.dumps(result, ensure_ascii=False))
return {{
"output_step": result.get("outputStep", output_step),
"outputStep": result.get("outputStep", output_step),
"sourceStep": result.get("sourceStep", ""),
"messages": result.get("messages", []),
}}
def main(argv=None):
parser = argparse.ArgumentParser(description="Run generated STEP parametric component.")
parser.add_argument("--inputs", default="", help="JSON file or JSON object with input parameter values.")
parser.add_argument("--output-step", default="", help="Output STEP file path.")
parser.add_argument("--work-dir", default="", help="Runtime output directory.")
args = parser.parse_args(argv)
inputs = args.inputs
if inputs:
candidate = Path(inputs)
if candidate.is_file():
inputs = json.loads(candidate.read_text(encoding="utf-8-sig"))
else:
inputs = json.loads(inputs)
else:
inputs = {{}}
result = run(inputs=inputs, output_step=args.output_step or None, work_dir=args.work_dir or None)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())
'''
def export_parametric_component(
*,
parameters: list[dict[str, str]],
edits: list[dict[str, object]],
source_step: Path | None,
component_root: Path | None = None,
component_name: str | None = None,
) -> Path:
if not parameters:
raise ValueError("No input parameters selected.")
root = component_root or default_component_root()
target_dir = next_component_dir(root, component_name or component_name_from_step(source_step))
target_dir.mkdir(parents=True, exist_ok=True)
component = {
"schema": COMPONENT_SCHEMA,
"createdAt": datetime.now().isoformat(timespec="seconds"),
"componentName": component_name or component_name_from_step(source_step),
"sourceStep": str(source_step or ""),
"parameters": parameters,
"edits": edits,
"outputName": "modified.step",
}
target = target_dir / "main.py"
target.write_text(render_component_main_py(component), encoding="utf-8")
return target
def _input_values(inputs: object) -> dict[str, object]:
if not isinstance(inputs, dict):
return {}
if isinstance(inputs.get("inputs"), dict):
return dict(inputs["inputs"])
rows = inputs.get("parameters")
if isinstance(rows, list):
result: dict[str, object] = {}
for row in rows:
if isinstance(row, dict) and row.get("name"):
result[str(row["name"])] = row.get("value", row.get("default", ""))
return result
return dict(inputs)
def _parse_vector3(value: object) -> list[float]:
vector = _as_list3(value)
if vector is None:
raise ValueError(f"Expected 3D vector value, got {value!r}.")
return vector
def _apply_arg_transform(value: object, transform: str, context: object) -> object:
context = context if isinstance(context, dict) else {}
if transform == "plane_target_position_to_offset":
current = float(context.get("current_plane_position"))
return float(value) - current
if transform == "radius_to_diameter":
return float(value) * 2.0
if transform == "diameter_to_radius":
return float(value) * 0.5
if transform == "degrees_to_radians":
import math
return math.radians(float(value))
if transform == "slot_open_angle_degrees_to_angular_span":
import math
return math.tau - math.radians(float(value))
if transform == "target_center_to_translation":
current = _parse_vector3(context.get("current_center"))
target = _parse_vector3(value)
return [target[index] - current[index] for index in range(3)]
return value
def _resolve_arg(value: object, values: dict[str, object], defaults: dict[str, object]) -> object:
if isinstance(value, dict) and "param" in value:
name = str(value.get("param") or "")
resolved = values.get(name, defaults.get(name, ""))
transform = str(value.get("transform") or "")
if transform:
return _apply_arg_transform(resolved, transform, value.get("context"))
return resolved
return value
def run_embedded_component(
component: dict[str, object],
*,
inputs: object | None = None,
output_step: str | Path | None = None,
work_dir: str | Path | None = None,
) -> dict[str, object]:
started = datetime.now().isoformat(timespec="seconds")
try:
source_step = Path(str(component.get("sourceStep") or "")).expanduser()
if not source_step.is_file():
return {"ok": False, "error": f"Source STEP does not exist: {source_step}", "startedAt": started}
output_path = Path(output_step) if output_step else None
if output_path is None:
output_root = Path(work_dir) if work_dir else Path.cwd()
output_path = output_root / str(component.get("outputName") or "modified.step")
output_path.parent.mkdir(parents=True, exist_ok=True)
parameters = [row for row in component.get("parameters", []) if isinstance(row, dict)]
defaults = {str(row.get("name") or ""): row.get("default", "") for row in parameters if row.get("name")}
values = _input_values(inputs)
model = StepModel.load(source_step)
messages: list[str] = []
for edit in component.get("edits", []):
if not isinstance(edit, dict):
continue
operation = str(edit.get("operation") or "")
args = [_resolve_arg(arg, values, defaults) for arg in list(edit.get("args") or [])]
messages.append(_execute(model, operation, args))
model.export_all(output_path)
return {
"ok": True,
"startedAt": started,
"finishedAt": datetime.now().isoformat(timespec="seconds"),
"sourceStep": str(source_step),
"outputStep": str(output_path),
"messages": messages,
"parameters": parameters,
}
except Exception as exc:
return {
"ok": False,
"startedAt": started,
"finishedAt": datetime.now().isoformat(timespec="seconds"),
"error": str(exc),
"traceback": traceback.format_exc(),
}
def _load_inputs(path_or_json: str) -> object:
if not path_or_json:
return {}
candidate = Path(path_or_json)
if candidate.is_file():
return json.loads(candidate.read_text(encoding="utf-8-sig"))
return json.loads(path_or_json)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Run a STEP parametric component JSON.")
parser.add_argument("component", help="Component JSON file.")
parser.add_argument("--inputs", default="", help="JSON file or inline JSON object.")
parser.add_argument("--output-step", default="", help="Output STEP path.")
parser.add_argument("--work-dir", default="", help="Runtime work directory.")
parsed = parser.parse_args(argv)
try:
component = json.loads(Path(parsed.component).read_text(encoding="utf-8-sig"))
result = run_embedded_component(
component,
inputs=_load_inputs(parsed.inputs),
output_step=parsed.output_step or None,
work_dir=parsed.work_dir or None,
)
except Exception as exc:
result = {"ok": False, "error": str(exc), "traceback": traceback.format_exc()}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result.get("ok") else 2
if __name__ == "__main__":
raise SystemExit(main())
+27 -1
View File
@@ -69,6 +69,32 @@ def _polydata_id_key(values: Iterable[int] | None) -> tuple[int, ...] | None:
return tuple(sorted({int(value) for value in values}))
def _display_edge_samples(edge: TopoDS_Shape, deflection: float) -> list[tuple[float, float, float]]:
try:
curve = BRepAdaptor_Curve(edge)
curve_type = curve.GetType()
first = float(curve.FirstParameter())
last = float(curve.LastParameter())
except Exception:
return list(discretize_edge(edge, deflection))
if curve_type in {GeomAbs_Circle, GeomAbs_Ellipse} and math.isfinite(first) and math.isfinite(last):
span = abs(last - first)
if span > 1e-9:
min_segments = 24 if span >= math.tau * 0.75 else 8
segments = max(min_segments, int(math.ceil(span / math.radians(7.5))))
segments = min(max(segments, 2), 128)
samples: list[tuple[float, float, float]] = []
for index in range(segments + 1):
parameter = first + (last - first) * (index / segments)
point = curve.Value(parameter)
samples.append((float(point.X()), float(point.Y()), float(point.Z())))
if len(samples) >= 2:
return samples
return list(discretize_edge(edge, deflection))
class PolydataMixin:
def build_face_polydata(
self,
@@ -272,7 +298,7 @@ class PolydataMixin:
continue
if edge_id in hidden_edge_ids:
continue
samples = discretize_edge(edge, deflection)
samples = _display_edge_samples(edge, deflection)
if len(samples) < 2:
continue
polyline = vtk.vtkPolyLine()
+539
View File
@@ -0,0 +1,539 @@
from __future__ import annotations
import math
from collections import Counter
from dataclasses import dataclass
from typing import Iterable
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
from OCC.Core.BRepGProp import brepgprop
from OCC.Core.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane
from OCC.Core.GProp import GProp_GProps
from OCC.Core.TopAbs import TopAbs_EDGE
from OCC.Core.TopExp import topexp
from OCC.Core.TopTools import TopTools_IndexedMapOfShape
from OCC.Core.TopoDS import TopoDS_Shape
from .geometry_utils import (
_axis_parameter,
_direction_dot,
_point_axis_distance,
_shape_axis_interval,
_shape_diagonal,
_surface_center,
)
ANGULAR_TOLERANCE = 1.0e-7
COVERAGE_TOLERANCE = 0.82
EXTERNAL_COAXIAL_CONFIDENCE_BOOST = 0.08
EXTERNAL_TANGENT_CONFIDENCE_BOOST = 0.03
EXTERNAL_OPENING_PLANE_CONFIDENCE_BOOST = 0.04
@dataclass(frozen=True)
class RecognitionFace:
face_id: int
solid_id: int
surface_type: str
area: float
centroid: tuple[float, float, float]
boundary_edge_ids: tuple[int, ...]
adjacent_face_ids: tuple[int, ...]
axis_point: object | None = None
axis_direction: object | None = None
radius: float | None = None
axis_interval: tuple[float, float] | None = None
angular_span: float | None = None
plane_parameter: float | None = None
@dataclass(frozen=True)
class RecognitionRelation:
relation_type: str
face_ids: tuple[int, ...]
residual: float
@dataclass(frozen=True)
class RecognitionGraph:
solid_id: int
face_ids: tuple[int, ...]
faces: tuple[RecognitionFace, ...]
relation_counts: dict[str, int]
relations: tuple[RecognitionRelation, ...]
def face(self, face_id: int) -> RecognitionFace | None:
for item in self.faces:
if item.face_id == int(face_id):
return item
return None
@dataclass(frozen=True)
class ThroughHoleRegion:
face_ids: tuple[int, ...]
solid_id: int
diameter: float
axis_interval: tuple[float, float]
angular_coverage: float
opening_face_ids: tuple[int, ...]
confidence: float
def build_recognition_graph(model: object, solid_id: int) -> RecognitionGraph:
face_ids = tuple(
face_id
for face_id, item in enumerate(getattr(model, "face_solid_ids", ()))
if int(item) == int(solid_id)
)
faces: list[RecognitionFace] = []
for face_id in face_ids:
face = getattr(model, "faces")[face_id]
boundary_edge_ids = tuple(_face_boundary_edge_ids(model, face_id))
adjacent_face_ids = tuple(sorted(_adjacent_face_ids(model, boundary_edge_ids, face_id)))
surf = BRepAdaptor_Surface(face)
surface_type = "other"
axis_point = None
axis_direction = None
radius: float | None = None
axis_interval: tuple[float, float] | None = None
angular_span: float | None = None
plane_parameter: float | None = None
if surf.GetType() == GeomAbs_Cylinder:
surface_type = "cylinder"
cylinder = surf.Cylinder()
axis = cylinder.Axis()
axis_point = axis.Location()
axis_direction = axis.Direction()
radius = float(cylinder.Radius())
axis_interval = _shape_axis_interval(face, axis_point, axis_direction)
angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter()))
elif surf.GetType() == GeomAbs_Plane:
surface_type = "plane"
plane = surf.Plane()
axis_point = plane.Location()
axis_direction = plane.Axis().Direction()
plane_parameter = _axis_parameter(axis_point, axis_direction, plane.Location())
area, centroid = _surface_metrics(face)
faces.append(
RecognitionFace(
face_id=face_id,
solid_id=int(solid_id),
surface_type=surface_type,
area=area,
centroid=centroid,
boundary_edge_ids=boundary_edge_ids,
adjacent_face_ids=adjacent_face_ids,
axis_point=axis_point,
axis_direction=axis_direction,
radius=radius,
axis_interval=axis_interval,
angular_span=angular_span,
plane_parameter=plane_parameter,
)
)
relations = infer_recognition_relations(faces, _recognition_tolerance(model))
relations.extend(_external_recognition_relations(model, face_ids))
relation_counts = dict(Counter(item.relation_type for item in relations))
return RecognitionGraph(
solid_id=int(solid_id),
face_ids=face_ids,
faces=tuple(faces),
relation_counts=relation_counts,
relations=tuple(relations),
)
def infer_recognition_relations(
faces: Iterable[RecognitionFace],
tolerance: float,
) -> list[RecognitionRelation]:
items = list(faces)
relations: list[RecognitionRelation] = []
for face in items:
for adjacent_id in face.adjacent_face_ids:
if face.face_id < adjacent_id:
relations.append(RecognitionRelation("adjacent", (face.face_id, adjacent_id), 0.0))
for index, left in enumerate(items):
for right in items[index + 1 :]:
if left.surface_type == "plane" and right.surface_type == "plane":
relation = _plane_relation(left, right, tolerance)
if relation is not None:
relations.append(relation)
if left.surface_type == "cylinder" and right.surface_type == "cylinder":
relation = _cylinder_relation(left, right, tolerance)
if relation is not None:
relations.append(relation)
return relations
def recognize_through_hole_regions(model: object, solid_id: int | None = None) -> list[ThroughHoleRegion]:
solid_ids = _solid_ids(model, solid_id)
cache_key = ("all", solid_ids) if solid_id is None else ("solid", int(solid_id))
cache = getattr(model, "_through_hole_regions_cache", None)
if isinstance(cache, dict) and cache_key in cache:
return list(cache[cache_key])
regions: list[ThroughHoleRegion] = []
for current_solid_id in solid_ids:
graph = _cached_recognition_graph(model, current_solid_id)
regions.extend(_recognize_graph_through_hole_regions(model, graph))
result = _dedupe_regions(regions)
if isinstance(cache, dict):
cache[cache_key] = list(result)
return result
def recognition_summary(model: object) -> dict[str, object]:
solid_ids = _solid_ids(model, None)
relation_counts: Counter[str] = Counter()
hole_count = 0
face_count = 0
for solid_id in solid_ids:
graph = _cached_recognition_graph(model, solid_id)
relation_counts.update(graph.relation_counts)
face_count += len(graph.face_ids)
hole_count += len(recognize_through_hole_regions(model, solid_id))
return {
"source": "internal-recognition-graph",
"solid_count": len(solid_ids),
"face_count": face_count,
"relation_counts": dict(relation_counts),
"through_hole_region_count": hole_count,
}
def _cached_recognition_graph(model: object, solid_id: int) -> RecognitionGraph:
cache = getattr(model, "_recognition_graph_cache", None)
if isinstance(cache, dict) and int(solid_id) in cache:
return cache[int(solid_id)]
graph = build_recognition_graph(model, int(solid_id))
if isinstance(cache, dict):
cache[int(solid_id)] = graph
return graph
def _recognize_graph_through_hole_regions(model: object, graph: RecognitionGraph) -> list[ThroughHoleRegion]:
cylinders = [face for face in graph.faces if face.surface_type == "cylinder" and face.radius and face.radius > 0]
if not cylinders:
return []
tolerance = _recognition_tolerance(model)
visited: set[int] = set()
regions: list[ThroughHoleRegion] = []
for source in cylinders:
if source.face_id in visited:
continue
group = _cocylindrical_interval_group(model, cylinders, source, tolerance)
visited.update(face.face_id for face in group)
if not group:
continue
coverage = sum(min(abs(float(face.angular_span or 0.0)), math.tau) for face in group)
if coverage < math.tau * COVERAGE_TOLERANCE:
continue
intervals = [face.axis_interval for face in group if face.axis_interval is not None]
if not intervals:
continue
v_min = min(float(item[0]) for item in intervals)
v_max = max(float(item[1]) for item in intervals)
opening_face_ids = _opening_plane_face_ids(graph, group, tolerance)
confidence = 0.72
if coverage >= math.tau * 0.98:
confidence += 0.12
if len(opening_face_ids) >= 2:
confidence += 0.12
if len(group) > 1:
confidence += 0.04
if _has_external_relation(model, (face.face_id for face in group), {"coaxial"}):
confidence += EXTERNAL_COAXIAL_CONFIDENCE_BOOST
if _has_external_relation(model, (face.face_id for face in group), {"tangent"}):
confidence += EXTERNAL_TANGENT_CONFIDENCE_BOOST
if len(opening_face_ids) >= 2 and _has_external_relation(
model,
opening_face_ids,
{"coplanar", "parallel"},
):
confidence += EXTERNAL_OPENING_PLANE_CONFIDENCE_BOOST
regions.append(
ThroughHoleRegion(
face_ids=tuple(sorted(face.face_id for face in group)),
solid_id=graph.solid_id,
diameter=float(group[0].radius or 0.0) * 2.0,
axis_interval=(v_min, v_max),
angular_coverage=coverage,
opening_face_ids=tuple(sorted(opening_face_ids)),
confidence=min(confidence, 0.99),
)
)
return regions
def _external_recognition_relations(model: object, face_ids: Iterable[int]) -> list[RecognitionRelation]:
cache = getattr(model, "_asitus_geometric_relation_cache", None)
if not isinstance(cache, dict):
return []
valid_face_ids = {int(item) for item in face_ids}
relations: list[RecognitionRelation] = []
for pair, items in cache.items():
try:
face_pair = tuple(sorted(int(item) for item in pair))
except (TypeError, ValueError):
continue
if len(face_pair) != 2 or face_pair[0] not in valid_face_ids or face_pair[1] not in valid_face_ids:
continue
if not isinstance(items, (tuple, list)):
continue
for item in items:
if not isinstance(item, dict):
continue
relation_type = str(item.get("relation_type") or "").strip()
if not relation_type:
continue
relations.append(
RecognitionRelation(
f"external_{relation_type}",
face_pair,
_float_or_zero(item.get("residual")),
)
)
return relations
def _has_external_relation(model: object, face_ids: Iterable[int], relation_types: set[str]) -> bool:
return _external_relation(model, face_ids, relation_types) is not None
def _external_relation(
model: object,
face_ids: Iterable[int],
relation_types: set[str],
) -> dict[str, object] | None:
cache = getattr(model, "_asitus_geometric_relation_cache", None)
if not isinstance(cache, dict):
return None
face_id_set = {int(item) for item in face_ids}
if len(face_id_set) < 2:
return None
for pair, items in cache.items():
try:
face_pair = tuple(sorted(int(item) for item in pair))
except (TypeError, ValueError):
continue
if len(face_pair) != 2 or face_pair[0] not in face_id_set or face_pair[1] not in face_id_set:
continue
if not isinstance(items, (tuple, list)):
continue
for item in items:
if isinstance(item, dict) and str(item.get("relation_type") or "").strip() in relation_types:
return item
return None
def _float_or_zero(value: object) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _cocylindrical_interval_group(
model: object,
cylinders: list[RecognitionFace],
source: RecognitionFace,
tolerance: float,
) -> list[RecognitionFace]:
pending = [source]
visited = {source.face_id}
result: list[RecognitionFace] = []
while pending:
current = pending.pop(0)
result.append(current)
for candidate in cylinders:
if candidate.face_id in visited:
continue
if candidate.solid_id != source.solid_id:
continue
if not _recognition_faces_are_cocylindrical(source, candidate, tolerance) and not (
_external_cocylindrical_hint(model, source, candidate, tolerance)
):
continue
if not _intervals_overlap_or_touch(current.axis_interval, candidate.axis_interval, tolerance * 50.0):
continue
visited.add(candidate.face_id)
pending.append(candidate)
return result
def _external_cocylindrical_hint(
model: object,
left: RecognitionFace,
right: RecognitionFace,
tolerance: float,
) -> bool:
relation = _external_relation(model, (left.face_id, right.face_id), {"coaxial"})
if relation is None:
return False
if left.radius is None or right.radius is None:
return False
radius_tolerance = max(tolerance, max(left.radius, right.radius) * 1e-6)
radius_delta = abs(float(left.radius) - float(right.radius))
residual = _float_or_zero(relation.get("residual"))
return radius_delta <= radius_tolerance or residual <= radius_tolerance
def _recognition_faces_are_cocylindrical(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> bool:
if left.axis_point is None or left.axis_direction is None or right.axis_point is None or right.axis_direction is None:
return False
if left.radius is None or right.radius is None:
return False
radius_tolerance = max(tolerance, max(left.radius, right.radius) * 1e-6)
if abs(left.radius - right.radius) > radius_tolerance:
return False
if abs(_direction_dot(left.axis_direction, right.axis_direction)) < 1.0 - 1e-6:
return False
return _point_axis_distance(left.axis_point, left.axis_direction, right.axis_point) <= max(tolerance, radius_tolerance)
def _opening_plane_face_ids(
graph: RecognitionGraph,
group: list[RecognitionFace],
tolerance: float,
) -> set[int]:
if not group or group[0].axis_point is None or group[0].axis_direction is None:
return set()
axis_point = group[0].axis_point
axis_direction = group[0].axis_direction
intervals = [face.axis_interval for face in group if face.axis_interval is not None]
if not intervals:
return set()
v_min = min(float(item[0]) for item in intervals)
v_max = max(float(item[1]) for item in intervals)
end_tolerance = max(tolerance * 80.0, abs(v_max - v_min) * 1e-4, 1e-4)
side_ids = {face.face_id for face in group}
adjacent_ids: set[int] = set()
for face in group:
adjacent_ids.update(face.adjacent_face_ids)
openings: set[int] = set()
by_id = {face.face_id: face for face in graph.faces}
for adjacent_id in adjacent_ids - side_ids:
adjacent = by_id.get(adjacent_id)
if adjacent is None or adjacent.surface_type != "plane" or adjacent.axis_direction is None:
continue
if abs(_direction_dot(adjacent.axis_direction, axis_direction)) < 1.0 - ANGULAR_TOLERANCE:
continue
try:
parameter = _axis_parameter(axis_point, axis_direction, _gp_point(adjacent.centroid))
except Exception:
continue
if abs(parameter - v_min) <= end_tolerance or abs(parameter - v_max) <= end_tolerance:
openings.add(adjacent_id)
return openings
def _plane_relation(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> RecognitionRelation | None:
if left.axis_direction is None or right.axis_direction is None:
return None
dot = abs(_direction_dot(left.axis_direction, right.axis_direction))
if dot >= 1.0 - ANGULAR_TOLERANCE:
residual = abs(_plane_offset(left, right))
if residual <= tolerance:
return RecognitionRelation("coplanar", (left.face_id, right.face_id), residual)
return RecognitionRelation("parallel", (left.face_id, right.face_id), residual)
if dot <= ANGULAR_TOLERANCE:
return RecognitionRelation("perpendicular", (left.face_id, right.face_id), dot)
return None
def _cylinder_relation(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> RecognitionRelation | None:
if not _recognition_faces_are_cocylindrical(left, right, tolerance):
if left.axis_point is not None and left.axis_direction is not None and right.axis_direction is not None:
if abs(_direction_dot(left.axis_direction, right.axis_direction)) >= 1.0 - ANGULAR_TOLERANCE:
return RecognitionRelation("parallel_axis", (left.face_id, right.face_id), 0.0)
return None
residual = 0.0
if left.axis_point is not None and left.axis_direction is not None and right.axis_point is not None:
residual = _point_axis_distance(left.axis_point, left.axis_direction, right.axis_point)
return RecognitionRelation("coaxial", (left.face_id, right.face_id), residual)
def _plane_offset(left: RecognitionFace, right: RecognitionFace) -> float:
if left.axis_point is None or left.axis_direction is None or right.axis_point is None:
return math.inf
return float(_axis_parameter(left.axis_point, left.axis_direction, right.axis_point))
def _surface_metrics(shape: TopoDS_Shape) -> tuple[float, tuple[float, float, float]]:
props = GProp_GProps()
try:
brepgprop.SurfaceProperties(shape, props)
center = props.CentreOfMass()
return float(props.Mass()), (float(center.X()), float(center.Y()), float(center.Z()))
except Exception:
center = _surface_center(shape)
return 0.0, (float(center.X()), float(center.Y()), float(center.Z()))
def _face_boundary_edge_ids(model: object, face_id: int) -> list[int]:
if hasattr(model, "_face_boundary_edge_ids"):
return list(model._face_boundary_edge_ids(face_id)) # noqa: SLF001
edges = TopTools_IndexedMapOfShape()
topexp.MapShapes(getattr(model, "faces")[face_id], TopAbs_EDGE, edges)
return list(range(edges.Size()))
def _adjacent_face_ids(model: object, edge_ids: Iterable[int], face_id: int) -> set[int]:
adjacent: set[int] = set()
if hasattr(model, "_adjacent_face_ids_for_edges"):
adjacent.update(model._adjacent_face_ids_for_edges(edge_ids, face_id)) # noqa: SLF001
else:
edge_face_ids = getattr(model, "_edge_face_ids_cache", {})
for edge_id in edge_ids:
adjacent.update(int(item) for item in edge_face_ids.get(int(edge_id), ()) if int(item) != int(face_id))
return adjacent
def _recognition_tolerance(model: object) -> float:
try:
diagonal = _shape_diagonal(getattr(model, "shape"))
except Exception:
diagonal = 1.0
return min(max(float(diagonal) * 1e-7, 1e-6), 1e-3)
def _solid_ids(model: object, solid_id: int | None) -> tuple[int, ...]:
if solid_id is not None:
return (int(solid_id),)
face_solid_ids = sorted({int(item) for item in getattr(model, "face_solid_ids", ()) if int(item) >= 0})
if face_solid_ids:
return tuple(face_solid_ids)
return tuple(range(len(getattr(model, "solids", ()) or ())))
def _intervals_overlap_or_touch(
left: tuple[float, float] | None,
right: tuple[float, float] | None,
tolerance: float,
) -> bool:
if left is None or right is None:
return True
left_min, left_max = min(left), max(left)
right_min, right_max = min(right), max(right)
return max(left_min, right_min) <= min(left_max, right_max) + max(tolerance, 0.0)
def _dedupe_regions(regions: Iterable[ThroughHoleRegion]) -> list[ThroughHoleRegion]:
result: list[ThroughHoleRegion] = []
seen: set[tuple[int, ...]] = set()
for region in sorted(regions, key=lambda item: (item.solid_id, item.face_ids)):
if region.face_ids in seen:
continue
seen.add(region.face_ids)
result.append(region)
return result
def _gp_point(values: tuple[float, float, float]):
from OCC.Core.gp import gp_Pnt
return gp_Pnt(float(values[0]), float(values[1]), float(values[2]))
+84 -2
View File
@@ -17,6 +17,7 @@ USER_OPERATION_PRIORITY: dict[str, int] = {
"resize_boss_height": 41,
"move_boss_axis": 44,
"inspect_existing_fillet": 50,
"inspect_existing_chamfer": 51,
"fillet_edge": 52,
"chamfer_edge": 53,
"resize_shell_thickness": 60,
@@ -37,6 +38,17 @@ USER_PRIORITY_BUCKETS: tuple[tuple[int, str, str], ...] = (
(90, "只读/诊断", "暂未稳定归类为可修改特征。"),
)
EXTERNAL_RELATION_SCORE_WEIGHTS: dict[str, int] = {
"coaxial": 8,
"tangent": 5,
"coplanar": 4,
"parallel": 3,
"perpendicular": 3,
"parallel_axis": 3,
}
EXTERNAL_RELATION_SCORE_LIMIT = 18
EXTERNAL_FEATURE_HINT_SCORE_LIMIT = 12
def _text(value: object) -> str:
return str(value or "").strip()
@@ -49,6 +61,69 @@ def _float_or_none(value: object) -> float | None:
return None
def _int_or_zero(value: object) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _text_values(value: object) -> tuple[str, ...]:
if value is None or value == "":
return ()
if isinstance(value, str):
return (value.strip(),) if value.strip() else ()
if isinstance(value, (list, tuple, set)):
return tuple(str(item).strip() for item in value if str(item).strip())
return ()
def _relation_types_from_summary(value: object) -> tuple[str, ...]:
text = _text(value)
if not text:
return ()
result: list[str] = []
for chunk in text.replace(";", ",").split(","):
relation_type = chunk.split(":", 1)[0].strip()
if relation_type:
result.append(relation_type)
return tuple(result)
def external_relation_score_bonus(info: Mapping[str, object]) -> int:
relation_types = _text_values(info.get("external_recognition_relation_types")) or _text_values(
info.get("asitus_geometric_relation_types")
)
if not relation_types:
relation_types = _relation_types_from_summary(
info.get("external_recognition_relation_summary")
or info.get("asitus_geometric_relation_summary")
)
relation_count = _int_or_zero(
info.get("external_recognition_relation_count")
or info.get("asitus_geometric_relation_count")
)
score = 0
for relation_type in relation_types:
score += EXTERNAL_RELATION_SCORE_WEIGHTS.get(relation_type, 1)
if relation_count and not relation_types:
score = min(relation_count * 2, EXTERNAL_RELATION_SCORE_LIMIT)
score = max(0, min(score, EXTERNAL_RELATION_SCORE_LIMIT))
hint_score = min(_int_or_zero(info.get("analysis_situs_feature_hint_score")), EXTERNAL_FEATURE_HINT_SCORE_LIMIT)
return max(0, min(score + hint_score, EXTERNAL_RELATION_SCORE_LIMIT + EXTERNAL_FEATURE_HINT_SCORE_LIMIT))
def _confidence_sort_rank(value: object) -> int:
return {
"high": 0,
"medium": 1,
"low": 2,
"pending": 3,
"unchecked": 3,
"none": 4,
}.get(_text(value), 5)
def _is_effectively_full_cylinder(info: Mapping[str, object]) -> bool:
if bool(info.get("is_full_cylinder")):
return True
@@ -77,6 +152,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int:
combined_text = "".join(item for item in (feature_type, feature_actions, ready_actions) if item)
if _text(info.get("existing_chamfer_status")) == "candidate" or feature_guess == "chamfer candidate":
return 50
if surface == "plane":
return 10
@@ -87,7 +165,9 @@ def feature_recognition_priority(info: Mapping[str, object]) -> int:
not _is_effectively_full_cylinder(info)
and angular_span is not None
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 20
if feature_guess == "boss/outer-round candidate":
@@ -142,7 +222,7 @@ def feature_recognition_priority_reason(info: Mapping[str, object]) -> str:
return reason
def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int]:
def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int, int, int]:
status_order = {"ready": 0, "candidate": 0, "caution": 1, "blocked": 2}
risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
target_id = info.get("target_id", info.get("face_id", info.get("edge_id", -1)))
@@ -154,5 +234,7 @@ def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int,
feature_recognition_priority(info),
status_order.get(_text(info.get("status")), 9),
risk_order.get(_text(info.get("risk")), 9),
_confidence_sort_rank(info.get("confidence") or info.get("recognition_confidence")),
-external_relation_score_bonus(info),
numeric_target,
)
+313
View File
@@ -0,0 +1,313 @@
from __future__ import annotations
from dataclasses import dataclass
import ast
import math
import re
from typing import Callable, Iterable
RELATION_REF_PATTERN = re.compile(
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):
pass
@dataclass(frozen=True)
class ObjectParameterRef:
kind: str
object_id: int
parameter: str
@property
def token(self) -> str:
return f"{self.kind}{self.object_id}.{self.parameter}"
@dataclass(frozen=True)
class RelationFormula:
text: str
target: ObjectParameterRef
expression: str
safe_expression: str
references: tuple[ObjectParameterRef, ...]
class Vector3:
__slots__ = ("values",)
def __init__(self, values: Iterable[object]) -> None:
items = tuple(values)
if len(items) != 3:
raise TypeError("Vector expression must contain exactly 3 values.")
try:
self.values = (float(items[0]), float(items[1]), float(items[2]))
except (TypeError, ValueError) as exc:
raise TypeError("Vector expression values must be numbers.") from exc
def __iter__(self):
return iter(self.values)
def __len__(self) -> int:
return 3
def __getitem__(self, index: int) -> float:
return self.values[index]
def __repr__(self) -> str:
return f"Vector3({self.values!r})"
def __add__(self, other: object) -> "Vector3":
right = _coerce_vector(other)
return Vector3((self.values[0] + right[0], self.values[1] + right[1], self.values[2] + right[2]))
def __radd__(self, other: object) -> "Vector3":
return self.__add__(other)
def __sub__(self, other: object) -> "Vector3":
right = _coerce_vector(other)
return Vector3((self.values[0] - right[0], self.values[1] - right[1], self.values[2] - right[2]))
def __rsub__(self, other: object) -> "Vector3":
left = _coerce_vector(other)
return Vector3((left[0] - self.values[0], left[1] - self.values[1], left[2] - self.values[2]))
def __mul__(self, other: object) -> "Vector3":
scalar = _coerce_number(other)
return Vector3((self.values[0] * scalar, self.values[1] * scalar, self.values[2] * scalar))
def __rmul__(self, other: object) -> "Vector3":
return self.__mul__(other)
def __truediv__(self, other: object) -> "Vector3":
scalar = _coerce_number(other)
if abs(scalar) <= 1e-15:
raise ZeroDivisionError("Vector division by zero.")
return Vector3((self.values[0] / scalar, self.values[1] / scalar, self.values[2] / scalar))
def __neg__(self) -> "Vector3":
return Vector3((-self.values[0], -self.values[1], -self.values[2]))
def parse_relation_formula(text: str) -> RelationFormula:
normalized = " ".join(str(text or "").strip().split())
if not normalized:
raise RelationFormulaError("请输入关系式。")
if normalized.count("=") != 1:
raise RelationFormulaError("关系式必须且只能包含一个等号,例如 Face87.直径 = Face85.直径。")
left, expression = (part.strip() for part in normalized.split("=", 1))
if not left or not expression:
raise RelationFormulaError("关系式左侧和右侧都不能为空。")
target_match = RELATION_REF_PATTERN.fullmatch(left)
if target_match is None:
raise RelationFormulaError("关系式左侧必须是 FaceID.参数 或 EdgeID.参数,例如 Face87.直径。")
target = _ref_from_match(target_match)
references: list[ObjectParameterRef] = []
def replace_ref(match: re.Match[str]) -> str:
references.append(_ref_from_match(match))
return f"__ref{len(references) - 1}"
# 用户输入的 Face85.直径 不能直接丢给 eval;先替换成内部占位符,
# 后面只允许这些占位符和白名单 AST 节点参与计算。
safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression)
safe_expression = _replace_unit_literals(safe_expression)
try:
tree = ast.parse(safe_expression, mode="eval")
except SyntaxError as exc:
raise RelationFormulaError(f"关系式右侧语法错误:{exc.msg}") from exc
_validate_expression_tree(tree, len(references))
return RelationFormula(
text=f"{target.token} = {expression}",
target=target,
expression=expression,
safe_expression=safe_expression,
references=tuple(references),
)
def evaluate_relation_formula(
formula: RelationFormula,
value_resolver: Callable[[ObjectParameterRef], object],
) -> float | Vector3:
namespace: dict[str, object] = {}
for index, ref in enumerate(formula.references):
namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref))
code = compile(formula.safe_expression, "<relation-formula>", "eval")
try:
# 这里仍然使用 Python 表达式能力,但 builtins 为空,AST 也已校验过。
# 关系式只承担参数求值,不允许调用函数、访问属性或执行任意代码。
value = eval(code, {"__builtins__": {}}, namespace)
except ZeroDivisionError as exc:
raise RelationFormulaError("关系式中出现除以 0。") from exc
except Exception as exc:
raise RelationFormulaError(f"关系式计算失败:{exc}") from exc
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:
value = _coerce_formula_value(value)
if isinstance(value, Vector3):
return ", ".join(_format_number(item) for item in value.values)
return _format_number(float(value))
def rewrite_relation_formula_ids(text: str, face_id_map: dict[int, int], edge_id_map: dict[int, int] | None = None) -> str:
edge_id_map = dict(edge_id_map or {})
def replace(match: re.Match[str]) -> str:
kind = str(match.group("kind"))
object_id = int(match.group("object_id"))
parameter = str(match.group("parameter"))
if kind == "Face" and object_id in face_id_map:
object_id = int(face_id_map[object_id])
elif kind == "Edge" and object_id in edge_id_map:
object_id = int(edge_id_map[object_id])
return f"{kind}{object_id}.{parameter}"
return RELATION_REF_PATTERN.sub(replace, text)
def _ref_from_match(match: re.Match[str]) -> ObjectParameterRef:
return ObjectParameterRef(
kind=str(match.group("kind")),
object_id=int(match.group("object_id")),
parameter=str(match.group("parameter")),
)
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:
allowed = (
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Name,
ast.Load,
ast.Constant,
ast.Tuple,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.UAdd,
ast.USub,
)
for node in ast.walk(tree):
if not isinstance(node, allowed):
raise RelationFormulaError("关系式只支持数字、对象参数、括号、向量和 + - * / 运算。")
if isinstance(node, ast.Name):
if not re.fullmatch(r"__ref\d+", node.id):
raise RelationFormulaError(f"未知参数引用:{node.id}")
index = int(node.id.replace("__ref", ""))
if index < 0 or index >= ref_count:
raise RelationFormulaError(f"未知参数引用:{node.id}")
elif isinstance(node, ast.Constant):
if not isinstance(node.value, (int, float)):
raise RelationFormulaError("关系式常量只支持数字。")
if isinstance(node.value, float) and not math.isfinite(node.value):
raise RelationFormulaError("关系式数字不能是 NaN 或无穷大。")
elif isinstance(node, ast.Tuple):
if len(node.elts) != 3:
raise RelationFormulaError("向量必须是 3 个数字,例如 (0, 0, -3.5)。")
def _coerce_formula_value(value: object) -> float | Vector3:
if isinstance(value, Vector3):
return value
if isinstance(value, (tuple, list)):
return Vector3(value)
return _coerce_number(value)
def _coerce_vector(value: object) -> tuple[float, float, float]:
if isinstance(value, Vector3):
return value.values
if isinstance(value, (tuple, list)):
return Vector3(value).values
raise TypeError("Vector operation requires another 3D vector.")
def _coerce_number(value: object) -> float:
if isinstance(value, bool):
raise TypeError("Boolean is not a valid numeric formula value.")
if isinstance(value, (int, float)):
number = float(value)
else:
raise TypeError(f"{value!r} is not a valid numeric formula value.")
if not math.isfinite(number):
raise TypeError("Formula value must be finite.")
return number
def _format_number(value: float) -> str:
if abs(value) < 5e-13:
value = 0.0
return f"{value:.12g}"
+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"]
+20
View File
@@ -4,6 +4,8 @@ import re
from pathlib import Path
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCC.Core.BRep import BRep_Builder
from OCC.Core.BRepTools import breptools
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
@@ -126,6 +128,24 @@ def _write_step(shape: TopoDS_Shape, filename: Path) -> None:
raise IOError(f"Could not write STEP file: {filename}")
def _load_brep_shape(filename: str | Path) -> TopoDS_Shape:
path = Path(filename)
shape = TopoDS_Shape()
builder = BRep_Builder()
if not breptools.Read(shape, str(path), builder) or shape.IsNull():
raise IOError(f"Could not read BREP file: {path}")
return shape
def _write_brep(shape: TopoDS_Shape, filename: str | Path) -> None:
if shape.IsNull():
raise ValueError("Cannot export a null shape.")
path = Path(filename)
path.parent.mkdir(parents=True, exist_ok=True)
if not breptools.Write(shape, str(path)):
raise IOError(f"Could not write BREP file: {path}")
def _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape:
if shape.IsNull():
return shape
+267 -2
View File
@@ -33,6 +33,18 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"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",
],
),
(
"拓扑",
[
@@ -79,6 +91,63 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"first_level_fact_ignored_relation_depths",
"first_level_fact_ignored_relation_note",
"first_level_fact_summary",
"first_level_planar_relation_status",
"first_level_planar_relation_scope",
"first_level_planar_relation_count",
"first_level_planar_relation_parallel_count",
"first_level_planar_relation_perpendicular_count",
"first_level_planar_relation_angled_count",
"first_level_planar_relation_face_face_count",
"first_level_planar_relation_edge_axis_count",
"first_level_planar_relation_rows",
"first_level_planar_relation_observed_modes",
"first_level_planar_relation_summary",
"first_level_planar_relation_note",
"first_level_same_domain_status",
"first_level_same_domain_scope",
"first_level_same_domain_relation",
"first_level_same_domain_relation_label",
"first_level_same_domain_surface",
"first_level_same_domain_face_ids",
"first_level_same_domain_face_count",
"first_level_same_domain_fragment_face_ids",
"first_level_same_domain_fragment_face_count",
"first_level_same_domain_summary",
"first_level_same_domain_note",
"first_level_coaxial_cylinder_status",
"first_level_coaxial_cylinder_scope",
"first_level_coaxial_cylinder_count",
"first_level_coaxial_cylinder_face_ids",
"first_level_coaxial_cylinder_face_count",
"first_level_coaxial_cylinder_candidate_face_ids",
"first_level_coaxial_cylinder_candidate_face_count",
"first_level_coaxial_cylinder_rows",
"first_level_coaxial_cylinder_summary",
"first_level_coaxial_cylinder_note",
"face_push_pull_planar_relation_constraint_requested",
"face_push_pull_planar_relation_constraint_label",
"face_push_pull_planar_constraint_status",
"face_push_pull_planar_constraint_summary",
"face_push_pull_planar_constraint_relation_count",
"face_push_pull_planar_constraint_parallel_count",
"face_push_pull_planar_constraint_perpendicular_count",
"face_push_pull_planar_constraint_angled_count",
"face_push_pull_planar_constraint_adjacent_face_count",
"face_push_pull_planar_constraint_non_planar_face_ids",
"face_push_pull_planar_constraint_observed_modes",
"face_push_pull_planar_constraint_blockers",
"face_push_pull_planar_constraint_warnings",
"edge_length_planar_relation_constraint_requested",
"edge_length_planar_relation_constraint_label",
"edge_length_planar_constraint_status",
"edge_length_planar_constraint_summary",
"edge_length_planar_constraint_side_face_count",
"edge_length_planar_constraint_parallel_count",
"edge_length_planar_constraint_perpendicular_count",
"edge_length_planar_constraint_angled_count",
"edge_length_planar_constraint_observed_modes",
"edge_length_planar_constraint_blockers",
"edge_length_planar_constraint_warnings",
"selected_edge_ids",
"selected_edge_count",
"first_level_vertex_points",
@@ -146,6 +215,11 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"existing_fillet_radius_estimate",
"existing_fillet_angular_span",
"existing_fillet_arc_length_estimate",
"existing_chamfer_distance_estimate",
"existing_chamfer_cross_edge_length_estimate",
"existing_chamfer_long_edge_length_estimate",
"existing_chamfer_support_angle_degrees",
"existing_chamfer_face_angle_degrees",
"current_depth",
"target_depth",
"delta_depth",
@@ -219,6 +293,25 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"shell_movement_alignment",
"shell_note",
"shell_region_note",
"open_shell_context_status",
"open_shell_kind",
"open_shell_axis",
"open_shell_side",
"open_shell_open_direction",
"open_shell_wall_thickness_estimate",
"open_shell_open_side_area_ratio",
"open_shell_closed_side_area_ratio",
"open_shell_current_face_role",
"open_shell_current_face_id",
"open_shell_rim_face_ids",
"open_shell_closed_side_face_ids",
"open_shell_inner_face_ids",
"open_shell_thin_wall_face_ids",
"open_shell_related_face_ids",
"open_shell_highlight_face_ids",
"open_shell_limited_action",
"open_shell_blockers",
"open_shell_note",
],
),
(
@@ -293,6 +386,17 @@ INFO_GROUPS: list[tuple[str, list[str]]] = [
"existing_fillet_kind",
"existing_fillet_status",
"existing_fillet_note",
"feature_existing_chamfer_face_ids",
"feature_existing_chamfer_support_face_ids",
"feature_existing_chamfer_end_face_ids",
"feature_existing_chamfer_short_edge_ids",
"feature_existing_chamfer_long_edge_ids",
"existing_chamfer_kind",
"existing_chamfer_status",
"existing_chamfer_risk",
"existing_chamfer_warnings",
"existing_chamfer_blockers",
"existing_chamfer_note",
"feature_edit_actions",
"resize_status",
"resize_strategy",
@@ -437,6 +541,13 @@ INFO_LABELS = {
"associated_feature_count": "关联特征数",
"associated_feature_face_ids": "关联特征 Face",
"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_candidate": "识别候选",
"recognition_confidence": "识别置信度",
@@ -448,6 +559,21 @@ INFO_LABELS = {
"recognition_user_priority_reason": "优先级说明",
"recognition_evidence": "识别依据",
"recognition_evidence_keys": "识别依据项",
"recognition_external_relation_score_bonus": "Analysis Situs 关系加权",
"external_recognition_relation_summary": "Analysis Situs 关系摘要",
"external_recognition_relation_types": "Analysis Situs 关系类型",
"external_recognition_relation_count": "Analysis Situs 关系数",
"external_recognition_relation_face_count": "Analysis Situs 关系 Face 数",
"external_recognition_relation_source": "Analysis Situs 关系来源",
"analysis_situs_feature_hint_status": "Analysis Situs 特征提示状态",
"analysis_situs_feature_hint_preferred": "Analysis Situs 推荐语义",
"analysis_situs_feature_hint_label": "Analysis Situs 推荐语义名称",
"analysis_situs_feature_hint_score": "Analysis Situs 特征提示分",
"analysis_situs_feature_hint_summary": "Analysis Situs 特征提示",
"analysis_situs_feature_hint_related_face_ids": "Analysis Situs 相关 Face",
"analysis_situs_slot_hint_score": "Analysis Situs 槽提示分",
"analysis_situs_boss_hint_score": "Analysis Situs 凸台提示分",
"analysis_situs_fillet_hint_score": "Analysis Situs 圆角提示分",
"recognition_ready_actions": "当前可改",
"recognition_limited_actions": "当前受限修改",
"recognition_blockers": "识别限制",
@@ -496,6 +622,63 @@ INFO_LABELS = {
"first_level_fact_ignored_relation_depths": "暂不传播层级",
"first_level_fact_ignored_relation_note": "暂不传播说明",
"first_level_fact_summary": "一级事实摘要",
"first_level_planar_relation_status": "一级平面关系状态",
"first_level_planar_relation_scope": "一级平面关系范围",
"first_level_planar_relation_count": "一级平面关系数",
"first_level_planar_relation_parallel_count": "一级平行关系数",
"first_level_planar_relation_perpendicular_count": "一级垂直关系数",
"first_level_planar_relation_angled_count": "一级斜交关系数",
"first_level_planar_relation_face_face_count": "Face-Face 平面关系数",
"first_level_planar_relation_edge_axis_count": "Face-Edge 方向关系数",
"first_level_planar_relation_rows": "一级平面关系列表",
"first_level_planar_relation_observed_modes": "已识别平面关系",
"first_level_planar_relation_summary": "一级平面关系摘要",
"first_level_planar_relation_note": "一级平面关系说明",
"first_level_same_domain_status": "一级同域状态",
"first_level_same_domain_scope": "一级同域范围",
"first_level_same_domain_relation": "一级同域关系",
"first_level_same_domain_relation_label": "一级同域关系名称",
"first_level_same_domain_surface": "一级同域曲面",
"first_level_same_domain_face_ids": "一级同域 Face",
"first_level_same_domain_face_count": "一级同域 Face 数",
"first_level_same_domain_fragment_face_ids": "一级同域碎片 Face",
"first_level_same_domain_fragment_face_count": "一级同域碎片数",
"first_level_same_domain_summary": "一级同域摘要",
"first_level_same_domain_note": "一级同域说明",
"first_level_coaxial_cylinder_status": "一级同轴圆柱状态",
"first_level_coaxial_cylinder_scope": "一级同轴圆柱范围",
"first_level_coaxial_cylinder_count": "一级同轴圆柱关系数",
"first_level_coaxial_cylinder_face_ids": "一级同轴圆柱 Face",
"first_level_coaxial_cylinder_face_count": "一级同轴圆柱 Face 数",
"first_level_coaxial_cylinder_candidate_face_ids": "一级圆柱候选 Face",
"first_level_coaxial_cylinder_candidate_face_count": "一级圆柱候选数",
"first_level_coaxial_cylinder_rows": "一级同轴圆柱关系列表",
"first_level_coaxial_cylinder_summary": "一级同轴圆柱摘要",
"first_level_coaxial_cylinder_note": "一级同轴圆柱说明",
"face_push_pull_planar_relation_constraint_requested": "Face保持关系已请求",
"face_push_pull_planar_relation_constraint_label": "Face保持关系约束",
"face_push_pull_planar_constraint_status": "Face保持关系状态",
"face_push_pull_planar_constraint_summary": "Face保持关系摘要",
"face_push_pull_planar_constraint_relation_count": "Face保持关系数",
"face_push_pull_planar_constraint_parallel_count": "Face保持平行关系数",
"face_push_pull_planar_constraint_perpendicular_count": "Face保持垂直关系数",
"face_push_pull_planar_constraint_angled_count": "Face保持斜交关系数",
"face_push_pull_planar_constraint_adjacent_face_count": "Face保持相邻面数",
"face_push_pull_planar_constraint_non_planar_face_ids": "Face保持非平面相邻面",
"face_push_pull_planar_constraint_observed_modes": "Face保持已识别关系",
"face_push_pull_planar_constraint_blockers": "Face保持关系阻止原因",
"face_push_pull_planar_constraint_warnings": "Face保持关系警告",
"edge_length_planar_relation_constraint_requested": "已请求保持一级平面关系",
"edge_length_planar_relation_constraint_label": "一级平面关系约束",
"edge_length_planar_constraint_status": "端面推拉约束状态",
"edge_length_planar_constraint_summary": "端面推拉约束摘要",
"edge_length_planar_constraint_side_face_count": "端面推拉侧面数",
"edge_length_planar_constraint_parallel_count": "端面推拉平行关系数",
"edge_length_planar_constraint_perpendicular_count": "端面推拉垂直关系数",
"edge_length_planar_constraint_angled_count": "端面推拉斜交关系数",
"edge_length_planar_constraint_observed_modes": "端面推拉已识别关系",
"edge_length_planar_constraint_blockers": "端面推拉阻止原因",
"edge_length_planar_constraint_warnings": "端面推拉警告",
"selected_edge_ids": "当前 Edge",
"selected_edge_count": "当前 Edge 数",
"first_level_vertex_points": "一级端点 Vertex",
@@ -671,6 +854,25 @@ INFO_LABELS = {
"shell_movement_alignment": "壳体移动方向匹配度",
"shell_note": "壳体识别说明",
"shell_region_note": "壳体识别说明",
"open_shell_context_status": "开口壳体上下文状态",
"open_shell_kind": "开口壳体类型",
"open_shell_axis": "开口方向轴",
"open_shell_side": "开口侧",
"open_shell_open_direction": "开口方向",
"open_shell_wall_thickness_estimate": "开口壳体壁厚估算",
"open_shell_open_side_area_ratio": "开口侧面积占比",
"open_shell_closed_side_area_ratio": "封闭侧面积占比",
"open_shell_current_face_role": "当前 Face 壳体角色",
"open_shell_current_face_id": "当前开口壳体 Face",
"open_shell_rim_face_ids": "开口边界 Face",
"open_shell_closed_side_face_ids": "封闭侧 Face",
"open_shell_inner_face_ids": "内壁/内底 Face",
"open_shell_thin_wall_face_ids": "薄壁 Face",
"open_shell_related_face_ids": "开口壳体相关 Face",
"open_shell_highlight_face_ids": "开口壳体高亮 Face",
"open_shell_limited_action": "受限壳体操作",
"open_shell_blockers": "开口壳体限制原因",
"open_shell_note": "开口壳体说明",
"u_range": "U 参数范围",
"v_range": "V 参数范围",
"first_parameter": "起始参数",
@@ -723,6 +925,22 @@ INFO_LABELS = {
"existing_fillet_kind": "已有圆角类型",
"existing_fillet_status": "已有圆角识别状态",
"existing_fillet_radius_estimate": "已有圆角半径估算",
"feature_existing_chamfer_face_ids": "已有倒角 Face",
"feature_existing_chamfer_support_face_ids": "已有倒角支撑 Face",
"feature_existing_chamfer_end_face_ids": "已有倒角端面 Face",
"feature_existing_chamfer_short_edge_ids": "已有倒角短边 Edge",
"feature_existing_chamfer_long_edge_ids": "已有倒角长边 Edge",
"existing_chamfer_kind": "已有倒角类型",
"existing_chamfer_status": "已有倒角识别状态",
"existing_chamfer_risk": "已有倒角风险",
"existing_chamfer_warnings": "已有倒角警告",
"existing_chamfer_blockers": "已有倒角阻止原因",
"existing_chamfer_distance_estimate": "已有倒角距离估算",
"existing_chamfer_cross_edge_length_estimate": "已有倒角截面边长估算",
"existing_chamfer_long_edge_length_estimate": "已有倒角长度估算",
"existing_chamfer_support_angle_degrees": "已有倒角支撑面夹角",
"existing_chamfer_face_angle_degrees": "已有倒角面角度",
"existing_chamfer_note": "已有倒角识别说明",
"feature_reference_radius": "特征参考半径",
"feature_reference_diameter": "特征参考直径",
"feature_cone_small_radius": "锥孔小端半径",
@@ -887,13 +1105,27 @@ EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
SELECTION_MODE_LABELS = {
"Part": "零件",
"Part": "Part",
"Solid": "Solid",
"Face": "Face",
"Edge": "Edge",
"Feature": "特征",
"Feature": "Feature",
}
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 = {
@@ -979,6 +1211,39 @@ def _smooth_surface_polydata(polydata):
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:
if value is None or value == "":
return ""
File diff suppressed because it is too large Load Diff
+1053 -75
View File
File diff suppressed because it is too large Load Diff
+4104 -143
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
cmake_minimum_required(VERSION 3.16)
project(asitus_probe LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(REPO_ROOT "${CMAKE_CURRENT_LIST_DIR}/../..")
set(ASITUS_ROOT "${REPO_ROOT}/third_party/AnalysisSitus")
set(ASITUS_BUILD "${REPO_ROOT}/third_party/AnalysisSitus_build_algo_occt77")
set(THIRD_PARTY_ROOT "${REPO_ROOT}/third_party/3rdparty")
set(OCCT_ROOT "${THIRD_PARTY_ROOT}/OCCT")
file(GLOB_RECURSE ASITUS_PUBLIC_HEADERS CONFIGURE_DEPENDS
"${ASITUS_ROOT}/src/asiAlgo/*.h"
"${ASITUS_ROOT}/src/asiAlgo/*.hpp"
"${ASITUS_ROOT}/src/asiActiveData/*.h"
"${ASITUS_ROOT}/src/asiActiveData/*.hpp"
)
set(ASITUS_INCLUDE_DIRS)
foreach(header ${ASITUS_PUBLIC_HEADERS})
get_filename_component(header_dir "${header}" DIRECTORY)
list(APPEND ASITUS_INCLUDE_DIRS "${header_dir}")
endforeach()
list(REMOVE_DUPLICATES ASITUS_INCLUDE_DIRS)
file(GLOB OCCT_LIBS CONFIGURE_DEPENDS "${OCCT_ROOT}/win64/vc14/lib/*.lib")
add_executable(recognize_holes recognize_holes.cpp)
target_include_directories(recognize_holes PRIVATE
${ASITUS_INCLUDE_DIRS}
"${OCCT_ROOT}/inc"
"${THIRD_PARTY_ROOT}/eigen-3.4.0"
"${THIRD_PARTY_ROOT}/rapidjson-1.1.0/include"
)
target_link_directories(recognize_holes PRIVATE
"${ASITUS_BUILD}/win64/vc14/lib"
"${OCCT_ROOT}/win64/vc14/lib"
)
target_link_libraries(recognize_holes PRIVATE
asiAlgo
asiActiveData
${OCCT_LIBS}
)
+339
View File
@@ -0,0 +1,339 @@
#include <asiAlgo_AAG.h>
#include <asiAlgo_FeatureAttrAdjacency.h>
#include <asiAlgo_FeatureAttrAngle.h>
#include <asiAlgo_FeatureFaces.h>
#include <asiAlgo_RecognizeDrillHoles.h>
#include <asiAlgo_STEP.h>
#include <BRepAdaptor_Surface.hxx>
#include <BRepCheck_Analyzer.hxx>
#include <GeomAbs_SurfaceType.hxx>
#include <Precision.hxx>
#include <TColStd_MapIteratorOfPackedMapOfInteger.hxx>
#include <TopAbs_ShapeEnum.hxx>
#include <TopExp.hxx>
#include <TopTools_IndexedMapOfShape.hxx>
#include <gp_Ax1.hxx>
#include <gp_Cylinder.hxx>
#include <gp_Dir.hxx>
#include <gp_Pln.hxx>
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <map>
#include <string>
#include <vector>
namespace
{
void printFeature(const asiAlgo_Feature& feature)
{
std::cout << "[";
bool first = true;
for (TColStd_MapIteratorOfPackedMapOfInteger it(feature); it.More(); it.Next())
{
if (!first)
std::cout << ", ";
first = false;
std::cout << it.Key();
}
std::cout << "]";
}
void printPackedMap(const TColStd_PackedMapOfInteger& values)
{
std::cout << "[";
bool first = true;
for (TColStd_MapIteratorOfPackedMapOfInteger it(values); it.More(); it.Next())
{
if (!first)
std::cout << ", ";
first = false;
std::cout << it.Key();
}
std::cout << "]";
}
const char* surfaceTypeName(const GeomAbs_SurfaceType type)
{
switch (type)
{
case GeomAbs_Plane:
return "plane";
case GeomAbs_Cylinder:
return "cylinder";
case GeomAbs_Cone:
return "cone";
case GeomAbs_Sphere:
return "sphere";
case GeomAbs_Torus:
return "torus";
case GeomAbs_BezierSurface:
return "bezier";
case GeomAbs_BSplineSurface:
return "bspline";
case GeomAbs_SurfaceOfRevolution:
return "revolution";
case GeomAbs_SurfaceOfExtrusion:
return "extrusion";
case GeomAbs_OffsetSurface:
return "offset";
default:
return "other";
}
}
void printStringIntMap(const std::map<std::string, int>& values)
{
std::cout << "{";
bool first = true;
for (const auto& item : values)
{
if (!first)
std::cout << ", ";
first = false;
std::cout << "\"" << item.first << "\": " << item.second;
}
std::cout << "}";
}
void printGeometricRelation(
const int faceId,
const int neighborId,
const std::string& type,
const double residual,
const char* source,
const bool first)
{
if (!first)
std::cout << ",\n";
std::cout << " { \"faceIds\": [" << faceId << ", " << neighborId << "]"
<< ", \"type\": \"" << type << "\""
<< ", \"residual\": " << residual
<< ", \"source\": \"" << source << "\" }";
}
}
int main(int argc, char** argv)
{
if (argc < 2)
{
std::cerr << "Usage: recognize_holes <step-file> [max-radius]\n";
return 1;
}
const TCollection_AsciiString filename(argv[1]);
const double radius = argc >= 3 ? std::atof(argv[2]) : Precision::Infinite();
TopoDS_Shape shape;
if (!asiAlgo_STEP::Import(filename, shape))
{
std::cerr << "Failed to import STEP: " << argv[1] << "\n";
return 2;
}
TopTools_IndexedMapOfShape faces;
TopExp::MapShapes(shape, TopAbs_FACE, faces);
BRepCheck_Analyzer analyzer(shape);
Handle(asiAlgo_AAG) aag = new asiAlgo_AAG(
shape,
false,
1.0e-4,
asiAlgo_AAG::CachedMap_All);
asiAlgo_RecognizeDrillHoles recognizer(aag, true);
if (!recognizer.Perform(radius))
{
std::cerr << "Hole recognition failed.\n";
return 3;
}
const asiAlgo_Feature& holeFaceIds = recognizer.GetResultIndices();
std::vector<asiAlgo_Feature> holes;
aag->GetConnectedComponents(holeFaceIds, holes);
std::map<std::string, int> surfaceSummary;
std::map<std::string, int> angleSummary;
std::map<std::string, int> geometricRelationSummary;
for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId)
{
const TopoDS_Face& face = aag->GetFace(faceId);
BRepAdaptor_Surface surface(face);
surfaceSummary[surfaceTypeName(surface.GetType())]++;
}
std::cout << "{\n";
std::cout << " \"validBreP\": " << (analyzer.IsValid() ? "true" : "false") << ",\n";
std::cout << " \"faceCount\": " << faces.Extent() << ",\n";
std::cout << " \"aagNodeCount\": " << aag->GetNumberOfNodes() << ",\n";
std::cout << " \"holeFaceIds\": ";
printFeature(holeFaceIds);
std::cout << ",\n";
std::cout << " \"holeCount\": " << holes.size() << ",\n";
std::cout << " \"holes\": [\n";
for (std::size_t i = 0; i < holes.size(); ++i)
{
std::cout << " { \"index\": " << (i + 1) << ", \"faceIds\": ";
printFeature(holes[i]);
std::cout << " }";
if (i + 1 < holes.size())
std::cout << ",";
std::cout << "\n";
}
std::cout << " ],\n";
std::cout << " \"faces\": [\n";
for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId)
{
const TopoDS_Face& face = aag->GetFace(faceId);
BRepAdaptor_Surface surface(face);
std::cout << " { \"id\": " << faceId
<< ", \"surface\": \"" << surfaceTypeName(surface.GetType()) << "\""
<< ", \"neighbors\": ";
if (aag->HasNeighbors(faceId))
printFeature(aag->GetNeighbors(faceId));
else
std::cout << "[]";
std::cout << " }";
if (faceId < aag->GetNumberOfNodes())
std::cout << ",";
std::cout << "\n";
}
std::cout << " ],\n";
std::cout << " \"adjacency\": [\n";
bool firstAdjacency = true;
std::vector<std::pair<int, int>> tangentPairs;
for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId)
{
if (!aag->HasNeighbors(faceId))
continue;
const asiAlgo_Feature& neighbors = aag->GetNeighbors(faceId);
for (TColStd_MapIteratorOfPackedMapOfInteger it(neighbors); it.More(); it.Next())
{
const int neighborId = it.Key();
if (neighborId <= faceId)
continue;
const asiAlgo_AAG::t_arc arc(faceId, neighborId);
Handle(asiAlgo_FeatureAttrAngle) angleAttr =
Handle(asiAlgo_FeatureAttrAngle)::DownCast(aag->GetArcAttribute(arc));
Handle(asiAlgo_FeatureAttrAdjacency) adjacencyAttr =
Handle(asiAlgo_FeatureAttrAdjacency)::DownCast(aag->GetArcAttribute(arc));
std::string angleType = "adjacent";
double angleRad = 0.0;
if (!angleAttr.IsNull())
{
angleType = asiAlgo_FeatureAngle::ToString(angleAttr->GetAngleType());
angleRad = angleAttr->GetAngleRad();
}
angleSummary[angleType]++;
if (angleType.find("smooth") != std::string::npos)
tangentPairs.emplace_back(faceId, neighborId);
if (!firstAdjacency)
std::cout << ",\n";
firstAdjacency = false;
std::cout << " { \"faceIds\": [" << faceId << ", " << neighborId << "]"
<< ", \"angleType\": \"" << angleType << "\""
<< ", \"angleRad\": " << angleRad
<< ", \"edgeIds\": ";
if (!adjacencyAttr.IsNull())
printPackedMap(adjacencyAttr->GetEdgeIndices());
else
std::cout << "[]";
std::cout << " }";
}
}
std::cout << "\n ],\n";
std::cout << " \"geometricRelations\": [\n";
bool firstRelation = true;
const double angleTol = 1.0e-7;
const double linearTol = 1.0e-4;
const int geometricPairFaceLimit = 800;
const bool runFullGeometricPairScan = aag->GetNumberOfNodes() <= geometricPairFaceLimit;
if (runFullGeometricPairScan)
{
for (int faceId = 1; faceId <= aag->GetNumberOfNodes(); ++faceId)
{
const TopoDS_Face& leftFace = aag->GetFace(faceId);
BRepAdaptor_Surface leftSurface(leftFace);
for (int neighborId = faceId + 1; neighborId <= aag->GetNumberOfNodes(); ++neighborId)
{
const TopoDS_Face& rightFace = aag->GetFace(neighborId);
BRepAdaptor_Surface rightSurface(rightFace);
std::string relationType;
double residual = 0.0;
if (leftSurface.GetType() == GeomAbs_Plane && rightSurface.GetType() == GeomAbs_Plane)
{
const gp_Pln leftPlane = leftSurface.Plane();
const gp_Pln rightPlane = rightSurface.Plane();
const gp_Dir leftDir = leftPlane.Axis().Direction();
const gp_Dir rightDir = rightPlane.Axis().Direction();
if (leftDir.IsParallel(rightDir, angleTol))
{
residual = leftPlane.Distance(rightPlane.Location());
relationType = residual <= linearTol ? "coplanar" : "parallel";
}
else if (leftDir.IsNormal(rightDir, angleTol))
{
residual = std::abs(leftDir.Dot(rightDir));
relationType = "perpendicular";
}
}
else if (leftSurface.GetType() == GeomAbs_Cylinder && rightSurface.GetType() == GeomAbs_Cylinder)
{
const gp_Cylinder leftCylinder = leftSurface.Cylinder();
const gp_Cylinder rightCylinder = rightSurface.Cylinder();
const gp_Ax1 leftAxis = leftCylinder.Axis();
const gp_Ax1 rightAxis = rightCylinder.Axis();
if (leftAxis.IsCoaxial(rightAxis, angleTol, linearTol) ||
leftAxis.IsCoaxial(rightAxis.Reversed(), angleTol, linearTol))
{
residual = std::abs(leftCylinder.Radius() - rightCylinder.Radius());
relationType = "coaxial";
}
else if (leftAxis.Direction().IsParallel(rightAxis.Direction(), angleTol))
{
residual = 0.0;
relationType = "parallel_axis";
}
}
if (!relationType.empty())
{
printGeometricRelation(faceId, neighborId, relationType, residual, "analysis-situs-probe", firstRelation);
firstRelation = false;
geometricRelationSummary[relationType]++;
}
}
}
}
for (const auto& item : tangentPairs)
{
printGeometricRelation(item.first, item.second, "tangent", 0.0, "analysis-situs-aag-angle", firstRelation);
firstRelation = false;
geometricRelationSummary["tangent"]++;
}
std::cout << "\n ],\n";
std::cout << " \"surfaceSummary\": ";
printStringIntMap(surfaceSummary);
std::cout << ",\n";
std::cout << " \"angleSummary\": ";
printStringIntMap(angleSummary);
std::cout << ",\n";
std::cout << " \"geometricRelationMode\": \""
<< (runFullGeometricPairScan ? "all-pairs" : "aag-smooth-only-large-model") << "\",\n";
std::cout << " \"geometricRelationSummary\": ";
printStringIntMap(geometricRelationSummary);
std::cout << "\n";
std::cout << "}\n";
return 0;
}