2026-08-04 18:15:29 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-05 15:08:16 +08:00
|
|
|
import argparse
|
2026-08-04 18:15:29 +08:00
|
|
|
import ast
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
EXPECTED_FACE_ISOLATED_OPERATIONS = {
|
|
|
|
|
"push_pull_face",
|
2026-08-10 15:26:35 +08:00
|
|
|
"push_pull_face_keep_relations",
|
2026-08-04 18:15:29 +08:00
|
|
|
"move_face_plane_offset_local",
|
|
|
|
|
"resize_face_area_local",
|
|
|
|
|
"resize_face_area",
|
|
|
|
|
"resize_face_size_local",
|
2026-08-11 09:50:40 +08:00
|
|
|
"resize_face_size_local_keep_relations",
|
2026-08-04 18:15:29 +08:00
|
|
|
"resize_face_size_owning_scale",
|
|
|
|
|
"move_face_center_local",
|
2026-08-11 09:50:40 +08:00
|
|
|
"move_face_center_local_keep_relations",
|
2026-08-04 18:15:29 +08:00
|
|
|
"resize_shell_thickness",
|
|
|
|
|
"resize_shell_thickness_owning_scale",
|
|
|
|
|
"resize_cylindrical_height",
|
|
|
|
|
"resize_cylindrical_boss_height",
|
|
|
|
|
"resize_cylindrical_height_owning_scale",
|
|
|
|
|
"resize_cone_reference_radius",
|
|
|
|
|
"resize_cone_semi_angle",
|
|
|
|
|
"resize_sphere_radius",
|
|
|
|
|
"resize_torus_radius",
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 15:08:16 +08:00
|
|
|
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",
|
2026-08-10 14:56:50 +08:00
|
|
|
"resize_existing_chamfer",
|
2026-08-05 15:08:16 +08:00
|
|
|
"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
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
def _source_tree(relative_path: str) -> ast.Module:
|
|
|
|
|
return ast.parse((PROJECT_ROOT / relative_path).read_text(encoding="utf-8"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _function_node(tree: ast.Module, name: str) -> ast.FunctionDef:
|
|
|
|
|
for node in ast.walk(tree):
|
|
|
|
|
if isinstance(node, ast.FunctionDef) and node.name == name:
|
|
|
|
|
return node
|
|
|
|
|
raise SystemExit(f"function {name!r} was not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _constant_string_set(node: ast.AST, variable_name: str) -> set[str]:
|
|
|
|
|
for child in ast.walk(node):
|
|
|
|
|
if not isinstance(child, ast.Assign):
|
|
|
|
|
continue
|
|
|
|
|
if not any(isinstance(target, ast.Name) and target.id == variable_name for target in child.targets):
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(child.value, (ast.Set, ast.Tuple, ast.List)):
|
|
|
|
|
raise SystemExit(f"{variable_name} should be a literal collection")
|
|
|
|
|
values: set[str] = set()
|
|
|
|
|
for item in child.value.elts:
|
|
|
|
|
if not isinstance(item, ast.Constant) or not isinstance(item.value, str):
|
|
|
|
|
raise SystemExit(f"{variable_name} contains a non-string item: {ast.dump(item)}")
|
|
|
|
|
values.add(item.value)
|
|
|
|
|
return values
|
|
|
|
|
raise SystemExit(f"{variable_name} assignment was not found")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _worker_operation_set(node: ast.AST) -> set[str]:
|
|
|
|
|
operations: set[str] = set()
|
|
|
|
|
for child in ast.walk(node):
|
|
|
|
|
if not isinstance(child, ast.Compare):
|
|
|
|
|
continue
|
|
|
|
|
if not isinstance(child.left, ast.Name) or child.left.id != "operation":
|
|
|
|
|
continue
|
|
|
|
|
if len(child.ops) != 1 or not isinstance(child.ops[0], ast.Eq):
|
|
|
|
|
continue
|
|
|
|
|
if len(child.comparators) != 1:
|
|
|
|
|
continue
|
|
|
|
|
comparator = child.comparators[0]
|
|
|
|
|
if isinstance(comparator, ast.Constant) and isinstance(comparator.value, str):
|
|
|
|
|
operations.add(comparator.value)
|
|
|
|
|
return operations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_same(label: str, actual: set[str], expected: set[str]) -> None:
|
|
|
|
|
missing = sorted(expected - actual)
|
|
|
|
|
extra = sorted(actual - expected)
|
|
|
|
|
if missing or extra:
|
|
|
|
|
raise SystemExit(f"{label} mismatch: missing={missing}, extra={extra}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION = {
|
|
|
|
|
"push_pull_face": "push_pull_face",
|
2026-08-10 15:26:35 +08:00
|
|
|
"push_pull_face_keep_relations": "push_pull_face_keep_relations",
|
2026-08-04 18:15:29 +08:00
|
|
|
"move_selected_face_plane_position_local": "move_face_plane_offset_local",
|
|
|
|
|
"resize_face_width_local": "resize_face_size_local",
|
|
|
|
|
"resize_face_height_local": "resize_face_size_local",
|
2026-08-11 09:50:40 +08:00
|
|
|
"resize_face_width_keep_relations": "resize_face_size_local_keep_relations",
|
|
|
|
|
"resize_face_height_keep_relations": "resize_face_size_local_keep_relations",
|
2026-08-04 18:15:29 +08:00
|
|
|
"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",
|
2026-08-11 09:50:40 +08:00
|
|
|
"move_selected_face_center_keep_relations": "move_face_center_local_keep_relations",
|
2026-08-04 18:15:29 +08:00
|
|
|
"resize_shell_thickness": "resize_shell_thickness",
|
|
|
|
|
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
|
|
|
|
|
"resize_cylinder_height": "resize_cylindrical_height",
|
|
|
|
|
"resize_boss_height": "resize_cylindrical_boss_height",
|
|
|
|
|
"resize_cylindrical_height_owning_scale": "resize_cylindrical_height_owning_scale",
|
|
|
|
|
"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",
|
2026-08-10 14:56:50 +08:00
|
|
|
"resize_existing_chamfer": "resize_existing_chamfer",
|
2026-08-04 18:15:29 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD = {
|
|
|
|
|
# Pure owning-shape affine translations are intentionally lightweight and do
|
|
|
|
|
# not enter the OCC rebuild/Boolean isolation worker.
|
|
|
|
|
"move_selected_face_center",
|
|
|
|
|
"move_selected_face_plane_position_by_translation",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ACTION_IMPLEMENTATION_METHOD = {
|
2026-08-10 15:26:35 +08:00
|
|
|
"push_pull_face": "_push_pull_face_action",
|
|
|
|
|
"push_pull_face_keep_relations": "_push_pull_face_action",
|
2026-08-04 18:15:29 +08:00
|
|
|
"resize_face_width_local": "_resize_face_size_local",
|
|
|
|
|
"resize_face_height_local": "_resize_face_size_local",
|
2026-08-11 09:50:40 +08:00
|
|
|
"resize_face_width_keep_relations": "_resize_face_size_local",
|
|
|
|
|
"resize_face_height_keep_relations": "_resize_face_size_local",
|
2026-08-04 18:15:29 +08:00
|
|
|
"resize_face_width_owning_scale": "_resize_face_size_owning_scale",
|
|
|
|
|
"resize_face_height_owning_scale": "_resize_face_size_owning_scale",
|
2026-08-11 09:50:40 +08:00
|
|
|
"move_selected_face_center_local": "_move_selected_face_center_local",
|
|
|
|
|
"move_selected_face_center_keep_relations": "_move_selected_face_center_local",
|
2026-08-04 18:15:29 +08:00
|
|
|
"resize_torus_major_radius": "_resize_torus_radius",
|
|
|
|
|
"resize_torus_minor_radius": "_resize_torus_radius",
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-05 15:08:16 +08:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
def _property_spec_actions_for_keys(specs: list[dict[str, object]], keys: set[str]) -> set[str]:
|
|
|
|
|
actions: set[str] = set()
|
|
|
|
|
for spec in specs:
|
|
|
|
|
key = str(spec.get("key", ""))
|
|
|
|
|
if key not in keys:
|
|
|
|
|
continue
|
|
|
|
|
action = str(spec.get("action") or "")
|
|
|
|
|
if action:
|
|
|
|
|
actions.add(action)
|
|
|
|
|
scope_modes = spec.get("scope_modes")
|
|
|
|
|
if isinstance(scope_modes, dict):
|
|
|
|
|
for mode in scope_modes.values():
|
|
|
|
|
if not isinstance(mode, dict):
|
|
|
|
|
continue
|
|
|
|
|
action = str(mode.get("action") or "")
|
|
|
|
|
if action:
|
|
|
|
|
actions.add(action)
|
|
|
|
|
return actions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _face_property_actions_from_specs() -> set[str]:
|
|
|
|
|
from verify_property_editor_specs import _specs
|
|
|
|
|
|
|
|
|
|
plane_info = {
|
|
|
|
|
"surface": "plane",
|
|
|
|
|
"area": 100.0,
|
|
|
|
|
"area_center": (5.0, 5.0, 0.0),
|
|
|
|
|
"bbox_center": (5.0, 5.0, 0.0),
|
|
|
|
|
"local_face_width": 10.0,
|
|
|
|
|
"local_face_height": 10.0,
|
|
|
|
|
"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),
|
|
|
|
|
"normal": (0.0, 0.0, 1.0),
|
|
|
|
|
"topology_relation_depth": 1,
|
|
|
|
|
"topology_relation_status": "ready",
|
|
|
|
|
"same_domain_face_count": 1,
|
|
|
|
|
"first_level_boundary_edge_count": 4,
|
|
|
|
|
"first_level_boundary_vertex_count": 4,
|
|
|
|
|
"first_level_adjacent_face_count": 4,
|
|
|
|
|
}
|
|
|
|
|
shell_info = {
|
|
|
|
|
**plane_info,
|
|
|
|
|
"shell_region_status": "candidate",
|
|
|
|
|
"shell_thickness_estimate": 2.0,
|
|
|
|
|
"shell_current_thickness": 2.0,
|
|
|
|
|
"shell_signed_thickness": 2.0,
|
|
|
|
|
"shell_opposite_face_id": 2,
|
|
|
|
|
"shell_overlap_ratio_estimate": 1.0,
|
|
|
|
|
}
|
|
|
|
|
generic_cylinder_info = {
|
|
|
|
|
"surface": "cylinder",
|
|
|
|
|
"feature_guess": "",
|
|
|
|
|
"diameter": 6.0,
|
|
|
|
|
"radius": 3.0,
|
|
|
|
|
"angular_span": 6.283185307179586,
|
|
|
|
|
"height_estimate": 11.0,
|
|
|
|
|
"same_domain_height_estimate": 11.0,
|
|
|
|
|
"area": 207.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 5.5),
|
|
|
|
|
"axis": (0.0, 0.0, 1.0),
|
|
|
|
|
"axis_point": (0.0, 0.0, 0.0),
|
|
|
|
|
"axis_center": (0.0, 0.0, 5.5),
|
|
|
|
|
}
|
|
|
|
|
boss_info = {
|
|
|
|
|
**generic_cylinder_info,
|
|
|
|
|
"feature_guess": "boss/outer-round candidate",
|
|
|
|
|
"height_estimate": 5.0,
|
|
|
|
|
"same_domain_height_estimate": 5.0,
|
|
|
|
|
"feature_start_end_face_ids": (1,),
|
|
|
|
|
}
|
|
|
|
|
cases = (
|
|
|
|
|
(
|
|
|
|
|
plane_info,
|
|
|
|
|
{
|
|
|
|
|
"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"}),
|
2026-08-10 14:56:50 +08:00
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
**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"},
|
|
|
|
|
),
|
2026-08-04 18:15:29 +08:00
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
"surface": "cone",
|
|
|
|
|
"reference_radius": 5.0,
|
|
|
|
|
"diameter": 10.0,
|
|
|
|
|
"radius": 5.0,
|
|
|
|
|
"semi_angle": 0.5,
|
|
|
|
|
"area": 100.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 0.0),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 0.0),
|
|
|
|
|
"axis": (0.0, 0.0, 1.0),
|
|
|
|
|
"axis_point": (0.0, 0.0, 0.0),
|
|
|
|
|
},
|
|
|
|
|
{"cone_reference_radius", "cone_reference_diameter", "cone_semi_angle_degrees"},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
"surface": "sphere",
|
|
|
|
|
"radius": 5.0,
|
|
|
|
|
"diameter": 10.0,
|
|
|
|
|
"area": 100.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 0.0),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 0.0),
|
|
|
|
|
"center": (0.0, 0.0, 0.0),
|
|
|
|
|
},
|
|
|
|
|
{"sphere_radius", "sphere_diameter"},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
{
|
|
|
|
|
"surface": "torus",
|
|
|
|
|
"major_radius": 8.0,
|
|
|
|
|
"minor_radius": 2.0,
|
|
|
|
|
"major_diameter": 16.0,
|
|
|
|
|
"minor_diameter": 4.0,
|
|
|
|
|
"area": 100.0,
|
|
|
|
|
"area_center": (0.0, 0.0, 0.0),
|
|
|
|
|
"bbox_center": (0.0, 0.0, 0.0),
|
|
|
|
|
"center": (0.0, 0.0, 0.0),
|
|
|
|
|
},
|
|
|
|
|
{"torus_major_radius", "torus_major_diameter", "torus_minor_radius", "torus_minor_diameter"},
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
actions: set[str] = set()
|
|
|
|
|
for info, keys in cases:
|
|
|
|
|
actions.update(_property_spec_actions_for_keys(_specs(info), keys))
|
|
|
|
|
return actions
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _method_isolation_operations(tree: ast.Module, method_name: str) -> set[str]:
|
|
|
|
|
method = _function_node(tree, method_name)
|
|
|
|
|
operations: set[str] = set()
|
|
|
|
|
for child in ast.walk(method):
|
|
|
|
|
if not isinstance(child, ast.Call):
|
|
|
|
|
continue
|
|
|
|
|
callee = child.func
|
|
|
|
|
if not isinstance(callee, ast.Attribute) or callee.attr != "_isolation_for_plan":
|
|
|
|
|
continue
|
|
|
|
|
if len(child.args) < 2:
|
|
|
|
|
raise SystemExit(f"{method_name} calls _isolation_for_plan without an operation argument")
|
|
|
|
|
operation_arg = child.args[1]
|
|
|
|
|
if not isinstance(operation_arg, ast.Constant) or not isinstance(operation_arg.value, str):
|
|
|
|
|
raise SystemExit(f"{method_name} should pass a literal isolated operation name")
|
|
|
|
|
operations.add(operation_arg.value)
|
|
|
|
|
return operations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _assert_property_face_actions_are_isolated(actions_tree: ast.Module) -> None:
|
|
|
|
|
property_actions = _face_property_actions_from_specs()
|
|
|
|
|
covered = set(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION) | PROPERTY_FACE_ACTIONS_ALLOWED_IN_MAIN_THREAD
|
|
|
|
|
missing = sorted(property_actions - covered)
|
|
|
|
|
stale = sorted(set(PROPERTY_FACE_ACTION_TO_ISOLATED_OPERATION) - property_actions)
|
|
|
|
|
if missing or stale:
|
|
|
|
|
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()):
|
2026-08-05 15:08:16 +08:00
|
|
|
if isolated_operation not in EXPECTED_ISOLATED_OPERATIONS:
|
2026-08-04 18:15:29 +08:00
|
|
|
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)}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-05 15:08:16 +08:00
|
|
|
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)
|
|
|
|
|
|
2026-08-04 18:15:29 +08:00
|
|
|
worker_execute = _function_node(_source_tree("step_editor/isolated_edit_worker.py"), "_execute")
|
|
|
|
|
worker_ops = _worker_operation_set(worker_execute)
|
2026-08-05 15:08:16 +08:00
|
|
|
_assert_same("isolated worker operations", worker_ops, EXPECTED_ISOLATED_OPERATIONS)
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
actions_tree = _source_tree("step_editor/window_actions.py")
|
|
|
|
|
isolation_func = _function_node(actions_tree, "_isolation_for_plan")
|
2026-08-05 15:08:16 +08:00
|
|
|
ui_ops = _constant_string_set(isolation_func, "isolated_geometry_operations")
|
|
|
|
|
_assert_same("UI isolation operations", ui_ops, EXPECTED_ISOLATED_OPERATIONS)
|
2026-08-04 18:15:29 +08:00
|
|
|
_assert_same("UI/worker isolation operation contract", ui_ops, worker_ops)
|
2026-08-05 15:08:16 +08:00
|
|
|
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
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
from step_editor.window_actions import WindowActionMixin
|
|
|
|
|
|
|
|
|
|
class _Probe(WindowActionMixin):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
probe = _Probe()
|
|
|
|
|
for risk in ("low", "medium", "high"):
|
2026-08-05 15:08:16 +08:00
|
|
|
for operation in sorted(EXPECTED_ISOLATED_OPERATIONS):
|
2026-08-04 18:15:29 +08:00
|
|
|
isolation = probe._isolation_for_plan(
|
|
|
|
|
{"risk": risk},
|
|
|
|
|
operation,
|
|
|
|
|
[1, 2, 3],
|
|
|
|
|
timeout_seconds=7.5,
|
|
|
|
|
)
|
|
|
|
|
if isolation is None:
|
|
|
|
|
raise SystemExit(f"{operation}/{risk} should request isolated execution")
|
|
|
|
|
if isolation.get("operation") != operation:
|
|
|
|
|
raise SystemExit(f"{operation}/{risk} returned wrong operation: {isolation}")
|
|
|
|
|
if isolation.get("args") != [1, 2, 3]:
|
|
|
|
|
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}")
|
2026-08-05 15:08:16 +08:00
|
|
|
if str(isolation.get("reason") or "") != f"{risk}-risk-isolated-occ-edit":
|
2026-08-04 18:15:29 +08:00
|
|
|
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")
|
2026-08-05 15:08:16 +08:00
|
|
|
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")
|
2026-08-04 18:15:29 +08:00
|
|
|
if probe._isolation_for_plan({"risk": "high"}, "unknown_operation", []) is not None:
|
|
|
|
|
raise SystemExit("unknown operations should not enter isolated execution")
|
|
|
|
|
|
|
|
|
|
from step_editor.model import StepModel
|
|
|
|
|
|
|
|
|
|
complex_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
|
|
|
|
|
if complex_path.exists():
|
|
|
|
|
model = StepModel.load(complex_path)
|
|
|
|
|
complex_face_id = 1
|
|
|
|
|
complex_probe = _Probe()
|
|
|
|
|
complex_probe.model = model
|
|
|
|
|
complex_probe.step_path = complex_path
|
|
|
|
|
complex_probe.selected_face_id = complex_face_id
|
|
|
|
|
complex_probe.current_info_values = model.quick_face_info(complex_face_id)
|
|
|
|
|
checks = (
|
|
|
|
|
("area local", lambda: complex_probe._quick_blocked_face_area_local_plan(complex_face_id, 2000.0), "blocked"),
|
|
|
|
|
(
|
|
|
|
|
"width local",
|
|
|
|
|
lambda: complex_probe._quick_blocked_face_size_local_plan(complex_face_id, 50.0, "width"),
|
|
|
|
|
"blocked",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"offset local",
|
|
|
|
|
lambda: complex_probe._quick_blocked_face_plane_offset_local_plan(complex_face_id, 50.0),
|
|
|
|
|
"blocked",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"center local",
|
|
|
|
|
lambda: complex_probe._quick_blocked_face_center_local_plan(complex_face_id, (0.0, 50.0, 0.0)),
|
|
|
|
|
"blocked",
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"shell thickness",
|
|
|
|
|
lambda: complex_probe._deferred_shell_thickness_plan_if_needed(complex_face_id, 10.0),
|
|
|
|
|
"caution",
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
for label, callback, expected_status in checks:
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
plan = callback()
|
|
|
|
|
elapsed = time.perf_counter() - started
|
|
|
|
|
if plan is None:
|
|
|
|
|
raise SystemExit(f"{label} complex Face UI preflight should return a quick plan")
|
|
|
|
|
if elapsed > 0.05:
|
|
|
|
|
raise SystemExit(f"{label} complex Face UI preflight should be quick, elapsed={elapsed:.3f}s")
|
|
|
|
|
if str(plan.get("status")) != expected_status:
|
|
|
|
|
raise SystemExit(f"{label} complex Face UI preflight returned wrong status: {plan}")
|
|
|
|
|
if expected_status == "blocked" and not plan.get("ui_quick_blocked_local_face_plan"):
|
|
|
|
|
raise SystemExit(f"{label} should be marked as a quick local-Face blocker: {plan}")
|
|
|
|
|
if label == "shell thickness" and not plan.get("ui_deferred_shell_thickness_plan"):
|
|
|
|
|
raise SystemExit(f"{label} should defer full shell-thickness planning: {plan}")
|
|
|
|
|
|
|
|
|
|
uncached_probe = _Probe()
|
|
|
|
|
uncached_probe.model = model
|
|
|
|
|
uncached_probe.step_path = complex_path
|
|
|
|
|
uncached_probe.selected_face_id = complex_face_id
|
|
|
|
|
uncached_probe.current_info_values = {}
|
|
|
|
|
uncached_checks = (
|
|
|
|
|
("area local uncached", lambda: uncached_probe._quick_blocked_face_area_local_plan(complex_face_id, 2000.0)),
|
|
|
|
|
(
|
|
|
|
|
"offset local uncached",
|
|
|
|
|
lambda: uncached_probe._quick_blocked_face_plane_offset_local_plan(complex_face_id, 50.0),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
for label, callback in uncached_checks:
|
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
plan = callback()
|
|
|
|
|
elapsed = time.perf_counter() - started
|
|
|
|
|
if elapsed > 0.05:
|
|
|
|
|
raise SystemExit(f"{label} should use quick_face_info fallback, elapsed={elapsed:.3f}s")
|
|
|
|
|
if plan is None or plan.get("status") != "blocked":
|
|
|
|
|
raise SystemExit(f"{label} should still return a quick blocked plan: {plan}")
|
|
|
|
|
if not plan.get("ui_quick_blocked_local_face_plan"):
|
|
|
|
|
raise SystemExit(f"{label} should be marked as a quick local-Face blocker: {plan}")
|
|
|
|
|
|
|
|
|
|
multi_boundary_face_id = 594
|
|
|
|
|
multi_boundary_probe = _Probe()
|
|
|
|
|
multi_boundary_probe.model = model
|
|
|
|
|
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)
|
2026-08-05 15:08:16 +08:00
|
|
|
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}")
|
2026-08-04 18:15:29 +08:00
|
|
|
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")
|
2026-08-05 15:08:16 +08:00
|
|
|
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):
|
2026-08-04 18:15:29 +08:00
|
|
|
raise SystemExit(
|
2026-08-05 15:08:16 +08:00
|
|
|
f"multi-boundary shallow inward push/pull should explain boundary-shell rebuild semantics: "
|
2026-08-04 18:15:29 +08:00
|
|
|
f"{inward_plan}"
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
if inward_plan.get("target_plane_position") != 56.5:
|
|
|
|
|
raise SystemExit(f"multi-boundary shallow inward push/pull should target 56.5: {inward_plan}")
|
2026-08-04 18:15:29 +08:00
|
|
|
diagnostics = multi_boundary_probe._edit_failure_diagnostics(
|
|
|
|
|
{
|
2026-08-05 15:08:16 +08:00
|
|
|
"operation_name": "拉伸/切除平面",
|
2026-08-04 18:15:29 +08:00
|
|
|
"target": f"Face {multi_boundary_face_id}",
|
|
|
|
|
"parameters": {
|
|
|
|
|
"surface": inward_plan.get("surface"),
|
|
|
|
|
"semantic_distance": -1.0,
|
|
|
|
|
"current_plane_position": inward_plan.get("current_plane_position"),
|
|
|
|
|
"target_plane_position": inward_plan.get("target_plane_position"),
|
|
|
|
|
"edit_strategy_label": inward_plan.get("edit_strategy_label"),
|
|
|
|
|
"push_pull_message": inward_plan.get("message"),
|
|
|
|
|
"push_pull_risk": inward_plan.get("risk"),
|
|
|
|
|
"selected_boundary_wires": inward_plan.get("selected_boundary_wires"),
|
|
|
|
|
"selected_inner_boundary_wires": inward_plan.get("selected_inner_boundary_wires"),
|
|
|
|
|
"selected_has_inner_boundaries": inward_plan.get("selected_has_inner_boundaries"),
|
|
|
|
|
"same_domain_face_count": inward_plan.get("same_domain_face_count"),
|
|
|
|
|
"first_level_boundary_edge_count": inward_plan.get("first_level_boundary_edge_count"),
|
|
|
|
|
"first_level_boundary_vertex_count": inward_plan.get("first_level_boundary_vertex_count"),
|
|
|
|
|
"first_level_adjacent_face_count": inward_plan.get("first_level_adjacent_face_count"),
|
|
|
|
|
"first_level_topology_note": inward_plan.get("first_level_topology_note"),
|
|
|
|
|
"topology_ignored_relation_note": inward_plan.get("topology_ignored_relation_note"),
|
|
|
|
|
"planar_cap_extension_kind": inward_plan.get("planar_cap_extension_kind"),
|
|
|
|
|
"planar_cap_extension_method": inward_plan.get("planar_cap_extension_method"),
|
|
|
|
|
},
|
|
|
|
|
"isolation": {
|
|
|
|
|
"operation": "push_pull_face",
|
|
|
|
|
"args": [multi_boundary_face_id, -1.0],
|
|
|
|
|
"timeout_seconds": 180.0,
|
2026-08-05 15:08:16 +08:00
|
|
|
"reason": f"{inward_plan.get('risk')}-risk-isolated-occ-edit",
|
2026-08-04 18:15:29 +08:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-08-05 15:08:16 +08:00
|
|
|
for required in ("诊断信息", "操作: 拉伸/切除平面 / Face 594", "一级关系证据", "隔离保护"):
|
2026-08-04 18:15:29 +08:00
|
|
|
if required not in diagnostics:
|
|
|
|
|
raise SystemExit(f"edit failure diagnostics should include {required!r}: {diagnostics}")
|
2026-08-05 15:08:16 +08:00
|
|
|
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}")
|
2026-08-04 18:15:29 +08:00
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
"Face UI isolation contract ok: "
|
2026-08-05 15:08:16 +08:00
|
|
|
f"{len(EXPECTED_ISOLATED_OPERATIONS)} operations are shared by UI and worker."
|
2026-08-04 18:15:29 +08:00
|
|
|
)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|