feat: 完善 Face 一级编辑与隔离计算
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import sys
|
||||
import time
|
||||
@@ -30,6 +31,41 @@ EXPECTED_FACE_ISOLATED_OPERATIONS = {
|
||||
"resize_torus_radius",
|
||||
}
|
||||
|
||||
EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS = {
|
||||
"resize_cylindrical_hole",
|
||||
"resize_cylindrical_owning_scale",
|
||||
"move_cylindrical_hole_axis",
|
||||
"suppress_cylindrical_hole",
|
||||
"resize_cylindrical_depth",
|
||||
"resize_cylindrical_depth_owning_scale",
|
||||
"move_cylindrical_slot_axis",
|
||||
"resize_cylindrical_slot_width",
|
||||
"resize_cylindrical_slot_depth",
|
||||
"resize_cylindrical_slot_arc_length",
|
||||
"resize_cylindrical_slot_angular_span",
|
||||
"resize_cylindrical_slot_total_length",
|
||||
"resize_cylindrical_slot_center_distance",
|
||||
}
|
||||
|
||||
EXPECTED_EDGE_ISOLATED_OPERATIONS = {
|
||||
"resize_general_edge_length",
|
||||
"move_edge_endpoint",
|
||||
"move_edge_center",
|
||||
"move_circular_edge_axis_center",
|
||||
"resize_ellipse_edge_axis_radius",
|
||||
"resize_existing_fillet",
|
||||
"fillet_edge",
|
||||
"chamfer_edge",
|
||||
"chamfer_edge_asymmetric",
|
||||
"chamfer_edge_distance_angle",
|
||||
}
|
||||
|
||||
EXPECTED_ISOLATED_OPERATIONS = (
|
||||
EXPECTED_FACE_ISOLATED_OPERATIONS
|
||||
| EXPECTED_HOLE_SLOT_ISOLATED_OPERATIONS
|
||||
| EXPECTED_EDGE_ISOLATED_OPERATIONS
|
||||
)
|
||||
|
||||
|
||||
def _source_tree(relative_path: str) -> ast.Module:
|
||||
return ast.parse((PROJECT_ROOT / relative_path).read_text(encoding="utf-8"))
|
||||
@@ -123,6 +159,37 @@ ACTION_IMPLEMENTATION_METHOD = {
|
||||
"resize_torus_minor_radius": "_resize_torus_radius",
|
||||
}
|
||||
|
||||
FEATURE_ACTION_TO_ISOLATED_OPERATION = {
|
||||
"resize_hole": "resize_cylindrical_hole",
|
||||
"resize_cylindrical_owning_scale": "resize_cylindrical_owning_scale",
|
||||
"move_cylindrical_hole_axis": "move_cylindrical_hole_axis",
|
||||
"suppress_hole": "suppress_cylindrical_hole",
|
||||
"resize_hole_depth": "resize_cylindrical_depth",
|
||||
"resize_hole_depth_owning_scale": "resize_cylindrical_depth_owning_scale",
|
||||
"move_cylindrical_slot_axis": "move_cylindrical_slot_axis",
|
||||
"_resize_slot_metric_owning_scale": "resize_cylindrical_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",
|
||||
}
|
||||
|
||||
EDGE_ACTION_TO_ISOLATED_OPERATION = {
|
||||
"resize_existing_fillet": "resize_existing_fillet",
|
||||
"fillet_edge": "fillet_edge",
|
||||
"chamfer_edge": "chamfer_edge",
|
||||
"chamfer_edge_asymmetric": "chamfer_edge_asymmetric",
|
||||
"chamfer_edge_distance_angle": "chamfer_edge_distance_angle",
|
||||
"resize_edge_length": "resize_general_edge_length",
|
||||
"resize_any_edge_length": "resize_general_edge_length",
|
||||
"_resize_ellipse_edge_axis_radius": "resize_ellipse_edge_axis_radius",
|
||||
"move_circular_edge_axis_center": "move_circular_edge_axis_center",
|
||||
"move_edge_center_point": "move_edge_center",
|
||||
"_move_edge_endpoint": "move_edge_endpoint",
|
||||
}
|
||||
|
||||
|
||||
def _property_spec_actions_for_keys(specs: list[dict[str, object]], keys: set[str]) -> set[str]:
|
||||
actions: set[str] = set()
|
||||
@@ -285,7 +352,7 @@ def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None
|
||||
raise SystemExit(f"Face property action contract mismatch: missing={missing}, stale={stale}")
|
||||
|
||||
for action, isolated_operation in sorted(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION.items()):
|
||||
if isolated_operation not in EXPECTED_FACE_ISOLATED_OPERATIONS:
|
||||
if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS:
|
||||
raise SystemExit(f"{action} maps to non-worker isolated operation {isolated_operation!r}")
|
||||
method_name = ACTION_IMPLEMENTATION_METHOD.get(action, action)
|
||||
actual_operations = _method_isolation_operations(actions_tree, method_name)
|
||||
@@ -296,17 +363,74 @@ def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
def _assert_property_face_mapping_methods_are_isolated(actions_tree: ast.Module) -> None:
|
||||
for action, isolated_operation in sorted(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION.items()):
|
||||
if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS:
|
||||
raise SystemExit(f"{action} maps to non-worker isolated operation {isolated_operation!r}")
|
||||
method_name = ACTION_IMPLEMENTATION_METHOD.get(action, action)
|
||||
actual_operations = _method_isolation_operations(actions_tree, method_name)
|
||||
if isolated_operation not in actual_operations:
|
||||
raise SystemExit(
|
||||
f"{action} should enter isolated operation {isolated_operation!r} through {method_name}; "
|
||||
f"actual={sorted(actual_operations)}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_hole_slot_actions_are_isolated(actions_tree: ast.Module) -> None:
|
||||
for method_name, isolated_operation in sorted(FEATURE_ACTION_TO_ISOLATED_OPERATION.items()):
|
||||
if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS:
|
||||
raise SystemExit(f"{method_name} maps to non-worker isolated operation {isolated_operation!r}")
|
||||
actual_operations = _method_isolation_operations(actions_tree, method_name)
|
||||
if isolated_operation not in actual_operations:
|
||||
raise SystemExit(
|
||||
f"{method_name} should enter isolated operation {isolated_operation!r}; "
|
||||
f"actual={sorted(actual_operations)}"
|
||||
)
|
||||
|
||||
|
||||
def _assert_edge_actions_are_isolated(actions_tree: ast.Module) -> None:
|
||||
for method_name, isolated_operation in sorted(EDGE_ACTION_TO_ISOLATED_OPERATION.items()):
|
||||
if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS:
|
||||
raise SystemExit(f"{method_name} maps to non-worker isolated operation {isolated_operation!r}")
|
||||
actual_operations = _method_isolation_operations(actions_tree, method_name)
|
||||
if isolated_operation not in actual_operations:
|
||||
raise SystemExit(
|
||||
f"{method_name} should enter isolated operation {isolated_operation!r}; "
|
||||
f"actual={sorted(actual_operations)}"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Verify UI/worker isolation contracts for risky geometry edits.")
|
||||
parser.add_argument(
|
||||
"--static-only",
|
||||
action="store_true",
|
||||
help="Only check AST-level operation contracts; skip OCCT-dependent property/model probes.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
worker_execute = _function_node(_source_tree("step_editor/isolated_edit_worker.py"), "_execute")
|
||||
worker_ops = _worker_operation_set(worker_execute)
|
||||
_assert_same("isolated worker operations", worker_ops, EXPECTED_FACE_ISOLATED_OPERATIONS)
|
||||
_assert_same("isolated worker operations", worker_ops, EXPECTED_ISOLATED_OPERATIONS)
|
||||
|
||||
actions_tree = _source_tree("step_editor/window_actions.py")
|
||||
isolation_func = _function_node(actions_tree, "_isolation_for_plan")
|
||||
ui_ops = _constant_string_set(isolation_func, "isolated_face_operations")
|
||||
_assert_same("UI isolation operations", ui_ops, EXPECTED_FACE_ISOLATED_OPERATIONS)
|
||||
ui_ops = _constant_string_set(isolation_func, "isolated_geometry_operations")
|
||||
_assert_same("UI isolation operations", ui_ops, EXPECTED_ISOLATED_OPERATIONS)
|
||||
_assert_same("UI/worker isolation operation contract", ui_ops, worker_ops)
|
||||
_assert_property_face_actions_are_isolated(actions_tree)
|
||||
if args.static_only:
|
||||
_assert_property_face_mapping_methods_are_isolated(actions_tree)
|
||||
else:
|
||||
_assert_property_face_actions_are_isolated(actions_tree)
|
||||
_assert_hole_slot_actions_are_isolated(actions_tree)
|
||||
_assert_edge_actions_are_isolated(actions_tree)
|
||||
|
||||
if args.static_only:
|
||||
print(
|
||||
"Static UI/worker isolation contract passed: "
|
||||
f"{len(EXPECTED_ISOLATED_OPERATIONS)} operations are shared by UI and worker."
|
||||
)
|
||||
return 0
|
||||
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
|
||||
@@ -315,7 +439,7 @@ def main() -> int:
|
||||
|
||||
probe = _Probe()
|
||||
for risk in ("low", "medium", "high"):
|
||||
for operation in sorted(EXPECTED_FACE_ISOLATED_OPERATIONS):
|
||||
for operation in sorted(EXPECTED_ISOLATED_OPERATIONS):
|
||||
isolation = probe._isolation_for_plan(
|
||||
{"risk": risk},
|
||||
operation,
|
||||
@@ -330,13 +454,13 @@ def main() -> int:
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong args: {isolation}")
|
||||
if float(isolation.get("timeout_seconds") or 0.0) != 7.5:
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong timeout: {isolation}")
|
||||
if str(isolation.get("reason") or "") != f"{risk}-risk-face-occ-edit":
|
||||
if str(isolation.get("reason") or "") != f"{risk}-risk-isolated-occ-edit":
|
||||
raise SystemExit(f"{operation}/{risk} returned wrong reason: {isolation}")
|
||||
|
||||
if probe._isolation_for_plan({"risk": "blocked"}, "push_pull_face", [0, 1]) is not None:
|
||||
raise SystemExit("blocked plans should not enter isolated execution")
|
||||
if probe._isolation_for_plan({"risk": "high"}, "resize_cylindrical_hole", [0, 20]) is not None:
|
||||
raise SystemExit("non-Face feature operations should not be covered by the Face isolation contract")
|
||||
if probe._isolation_for_plan({"risk": "high"}, "resize_cylindrical_hole", [0, 20]) is None:
|
||||
raise SystemExit("hole/slot feature operations should use the shared isolated geometry contract")
|
||||
if probe._isolation_for_plan({"risk": "high"}, "unknown_operation", []) is not None:
|
||||
raise SystemExit("unknown operations should not enter isolated execution")
|
||||
|
||||
@@ -418,30 +542,35 @@ def main() -> int:
|
||||
multi_boundary_probe.step_path = complex_path
|
||||
multi_boundary_probe.selected_face_id = multi_boundary_face_id
|
||||
multi_boundary_probe.current_info_values = model.quick_face_info(multi_boundary_face_id)
|
||||
outward_plan = multi_boundary_probe._push_pull_plan_for_action(multi_boundary_face_id, 32.5)
|
||||
if abs(float(outward_plan.get("current_plane_position") or 0.0) - 57.5) > 1e-9:
|
||||
raise SystemExit(f"multi-boundary outward quick plan should use the displayed Face offset: {outward_plan}")
|
||||
if abs(float(outward_plan.get("target_plane_position") or 0.0) - 90.0) > 1e-9:
|
||||
raise SystemExit(f"multi-boundary outward quick plan should target Face offset 90: {outward_plan}")
|
||||
if tuple(float(item) for item in outward_plan.get("outward_direction", ())) != (0.0, -1.0, 0.0):
|
||||
raise SystemExit(f"multi-boundary outward quick plan should use normal, not reversed oriented_normal: {outward_plan}")
|
||||
started = time.perf_counter()
|
||||
inward_plan = multi_boundary_probe._push_pull_plan_for_action(multi_boundary_face_id, -1.0)
|
||||
elapsed = time.perf_counter() - started
|
||||
if elapsed > 0.05:
|
||||
raise SystemExit(f"multi-boundary inward push/pull UI preflight should be quick, elapsed={elapsed:.3f}s")
|
||||
if inward_plan.get("status") != "blocked":
|
||||
raise SystemExit(f"multi-boundary inward push/pull UI preflight should be blocked: {inward_plan}")
|
||||
if not inward_plan.get("ui_quick_blocked_push_pull_plan"):
|
||||
raise SystemExit(f"multi-boundary inward push/pull should be marked as a quick blocker: {inward_plan}")
|
||||
message = str(inward_plan.get("message") or "")
|
||||
if "二级关系" not in message or "通用 OCCT 布尔" not in message:
|
||||
if inward_plan.get("status") != "caution":
|
||||
raise SystemExit(f"multi-boundary shallow inward push/pull UI preflight should be caution: {inward_plan}")
|
||||
if inward_plan.get("risk") not in {"medium", "high"}:
|
||||
raise SystemExit(f"multi-boundary shallow inward push/pull should be medium/high risk: {inward_plan}")
|
||||
if inward_plan.get("ui_quick_blocked_push_pull_plan"):
|
||||
raise SystemExit(f"multi-boundary shallow inward push/pull should not be marked as blocked: {inward_plan}")
|
||||
message = str(inward_plan.get("message") or "") + " " + str(inward_plan.get("warnings") or "")
|
||||
if "多内孔" not in message or ("一级边界侧壁" not in message and "后台" not in message):
|
||||
raise SystemExit(
|
||||
f"multi-boundary inward push/pull blocker should explain topology depth and Boolean risk: "
|
||||
f"multi-boundary shallow inward push/pull should explain boundary-shell rebuild semantics: "
|
||||
f"{inward_plan}"
|
||||
)
|
||||
for required in ("Face 594", "内边界", "一级边界 Edge", "一级相邻 Face"):
|
||||
if required not in message:
|
||||
raise SystemExit(
|
||||
f"multi-boundary inward push/pull blocker should include readable topology evidence "
|
||||
f"{required!r}: {inward_plan}"
|
||||
)
|
||||
if inward_plan.get("target_plane_position") != 56.5:
|
||||
raise SystemExit(f"multi-boundary shallow inward push/pull should target 56.5: {inward_plan}")
|
||||
diagnostics = multi_boundary_probe._edit_failure_diagnostics(
|
||||
{
|
||||
"operation_name": "推拉平面",
|
||||
"operation_name": "拉伸/切除平面",
|
||||
"target": f"Face {multi_boundary_face_id}",
|
||||
"parameters": {
|
||||
"surface": inward_plan.get("surface"),
|
||||
@@ -467,19 +596,23 @@ def main() -> int:
|
||||
"operation": "push_pull_face",
|
||||
"args": [multi_boundary_face_id, -1.0],
|
||||
"timeout_seconds": 180.0,
|
||||
"reason": "high-risk-face-occ-edit",
|
||||
"reason": f"{inward_plan.get('risk')}-risk-isolated-occ-edit",
|
||||
},
|
||||
}
|
||||
)
|
||||
for required in ("诊断信息", "操作: 推拉平面 / Face 594", "一级关系证据", "可能原因", "二级", "隔离保护"):
|
||||
for required in ("诊断信息", "操作: 拉伸/切除平面 / Face 594", "一级关系证据", "隔离保护"):
|
||||
if required not in diagnostics:
|
||||
raise SystemExit(f"edit failure diagnostics should include {required!r}: {diagnostics}")
|
||||
if multi_boundary_probe._isolation_for_plan(inward_plan, "push_pull_face", [multi_boundary_face_id, -1.0]) is not None:
|
||||
raise SystemExit(f"blocked multi-boundary inward push/pull should not enter isolated execution: {inward_plan}")
|
||||
isolation = multi_boundary_probe._isolation_for_plan(inward_plan, "push_pull_face", [multi_boundary_face_id, -1.0])
|
||||
if isolation is None:
|
||||
raise SystemExit(f"multi-boundary shallow inward push/pull should enter isolated execution: {inward_plan}")
|
||||
expected_reason = f"{inward_plan.get('risk')}-risk-isolated-occ-edit"
|
||||
if str(isolation.get("reason") or "") != expected_reason:
|
||||
raise SystemExit(f"multi-boundary shallow inward push/pull returned wrong isolation reason: {isolation}")
|
||||
|
||||
print(
|
||||
"Face UI isolation contract ok: "
|
||||
f"{len(EXPECTED_FACE_ISOLATED_OPERATIONS)} operations are shared by UI and worker."
|
||||
f"{len(EXPECTED_ISOLATED_OPERATIONS)} operations are shared by UI and worker."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user