Compare commits
16 Commits
12250603dd
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b4feab24d2 | |||
| 4e7877e05c | |||
| a3eb7e2476 | |||
| 722256a41f | |||
| a633b5a338 | |||
| 70b59c1de6 | |||
| 066d28660b | |||
| 7d814d2939 | |||
| 19364d81b5 | |||
| 6cb99a1273 | |||
| 9b44918648 | |||
| 3aadd6a34e | |||
| 3a851d0967 | |||
| 61f0b76e59 | |||
| 415e8a0f27 | |||
| 83c44265ba |
+14
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",)),
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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",),
|
||||
),
|
||||
(
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1180
-80
File diff suppressed because it is too large
Load Diff
@@ -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:节点属性面板参数。
|
||||
context:FlowEditor 上下文,常见字段包括 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
@@ -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()
|
||||
|
||||
@@ -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]))
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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 ""
|
||||
|
||||
+1034
-72
File diff suppressed because it is too large
Load Diff
+1053
-75
File diff suppressed because it is too large
Load Diff
+4104
-143
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user