feat: 完善 Face 一级编辑与隔离计算
This commit is contained in:
@@ -49,6 +49,7 @@ def main() -> int:
|
||||
state.selected_kind = "feature"
|
||||
state.selected_part_id = int(root["part_id"])
|
||||
state.selected_solid_id = int(root["solid_id"])
|
||||
state.feature_detection_level = "associated-only"
|
||||
context = state._feature_context_info(SOURCE_FACE_ID)
|
||||
specs, _used = state._editable_property_specs(context)
|
||||
rows = state._feature_context_property_specs(specs, context)
|
||||
@@ -59,7 +60,7 @@ def main() -> int:
|
||||
}
|
||||
expected = {
|
||||
("凸台/外圆候选 · 直径", 394, "resize_boss"),
|
||||
("凸台/外圆候选 · 高度", 394, "resize_boss_height"),
|
||||
("凸台/外圆候选 · 高度", 394, "resize_cylindrical_height_owning_scale"),
|
||||
("圆柱孔候选 · 直径", 1591, "resize_hole"),
|
||||
("圆柱孔候选 · 盲孔/盲槽深度", 1591, "resize_hole_depth"),
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
|
||||
|
||||
from step_editor.geometry_utils import _finalize_boolean_result
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.operations import _is_effectively_full_cylinder
|
||||
from step_editor.step_io import _write_step
|
||||
|
||||
|
||||
@@ -113,6 +114,18 @@ def _assert_radii(model: StepModel, expected: tuple[float, ...], label: str) ->
|
||||
raise SystemExit(f"{label}: radii should be {expected_values}, got {radii}")
|
||||
|
||||
|
||||
def _assert_split_same_domain_cylinder_identity() -> None:
|
||||
if not _is_effectively_full_cylinder(
|
||||
{
|
||||
"angular_span": 3.141592653589793,
|
||||
"same_domain_angular_span": 6.283185307179586,
|
||||
}
|
||||
):
|
||||
raise SystemExit("split same-domain cylinder should be treated as a full cylinder")
|
||||
if _is_effectively_full_cylinder({"angular_span": 3.141592653589793}):
|
||||
raise SystemExit("single partial cylinder should not be treated as a full cylinder")
|
||||
|
||||
|
||||
def _run_isolated_worker(
|
||||
input_path: Path,
|
||||
operation: str,
|
||||
@@ -263,6 +276,7 @@ def _run_prismatic_cap_push_pull_case(path: Path, distance: float, target_height
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_assert_split_same_domain_cylinder_identity()
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
solid_path = root / "solid_cylinder.step"
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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))
|
||||
SCRIPTS_ROOT = PROJECT_ROOT / "scripts"
|
||||
if str(SCRIPTS_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_ROOT))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
from verify_edge_coordinate_edit import (
|
||||
_add,
|
||||
_distance,
|
||||
_nearest_expected_edge,
|
||||
_source_edge_frame,
|
||||
_tuple3,
|
||||
)
|
||||
from verify_edge_length_resize import _edge_length, _line_edge_ids_near_length
|
||||
from verify_edge_round_chamfer import (
|
||||
_cylindrical_faces_near_radius,
|
||||
_first_adjacent_reference_face,
|
||||
_first_editable_line_edge,
|
||||
_first_existing_fillet_face,
|
||||
_verify_chamfer_topology,
|
||||
_write_box_model,
|
||||
_write_filleted_box_model,
|
||||
)
|
||||
from verify_ellipse_edge_resize import _ellipse_edge_ids, _write_ellipse_face_model
|
||||
from verify_hole_resize import (
|
||||
_first_hole_face,
|
||||
_nearest_hole_by_diameter,
|
||||
_write_through_hole_model,
|
||||
)
|
||||
from verify_hole_slot_isolated_edit import _assert_one_solid, _run_worker, _write_request
|
||||
|
||||
|
||||
DEFAULT_CUBE = PROJECT_ROOT / "assets" / "models" / "cube_10mm.step"
|
||||
|
||||
|
||||
def _load_worker_output(output_path: Path, response: dict[str, object]) -> StepModel:
|
||||
if not output_path.exists():
|
||||
raise SystemExit(f"isolated worker reported success but did not write output STEP: response={response}")
|
||||
return StepModel.load(output_path)
|
||||
|
||||
|
||||
def _verify_edge_length_isolated(temp_dir: Path) -> None:
|
||||
input_path = DEFAULT_CUBE
|
||||
output_path = temp_dir / "edge_length_out.step"
|
||||
request_path = temp_dir / "edge_length_request.json"
|
||||
model = StepModel.load(input_path)
|
||||
edge_ids = _line_edge_ids_near_length(model, 10.0, 1e-5)
|
||||
if not edge_ids:
|
||||
raise SystemExit("no cube line Edge near length 10 was found")
|
||||
edge_id = edge_ids[0]
|
||||
target_length = 15.0
|
||||
|
||||
_write_request(
|
||||
request_path,
|
||||
input_path,
|
||||
output_path,
|
||||
"resize_general_edge_length",
|
||||
[edge_id, target_length, "keep-start", "local-edge-only-deform"],
|
||||
)
|
||||
response = _run_worker(request_path)
|
||||
result_model = _load_worker_output(output_path, response)
|
||||
lengths = [_edge_length(result_model, candidate) for candidate in range(len(result_model.edges))]
|
||||
nearest = min(lengths, key=lambda value: abs(value - target_length))
|
||||
if abs(nearest - target_length) > 1e-5:
|
||||
raise SystemExit(f"isolated Edge length failed: nearest={nearest:g}, response={response}")
|
||||
_assert_one_solid(result_model, "isolated Edge length")
|
||||
print(f"isolated Edge length ok: edge={edge_id}, nearest={nearest:g}")
|
||||
|
||||
|
||||
def _verify_edge_endpoint_isolated(temp_dir: Path) -> None:
|
||||
input_path = DEFAULT_CUBE
|
||||
output_path = temp_dir / "edge_endpoint_out.step"
|
||||
request_path = temp_dir / "edge_endpoint_request.json"
|
||||
model = StepModel.load(input_path)
|
||||
edge_id, start, end, direction, _source_length = _source_edge_frame(model, 10.0, 1e-5)
|
||||
target_start = _add(start, (direction[0] * -3.0, direction[1] * -3.0, direction[2] * -3.0))
|
||||
|
||||
_write_request(request_path, input_path, output_path, "move_edge_endpoint", [edge_id, "start", target_start])
|
||||
response = _run_worker(request_path)
|
||||
result_model = _load_worker_output(output_path, response)
|
||||
matched_edge, endpoint_error, length_error = _nearest_expected_edge(result_model, target_start, end)
|
||||
if endpoint_error > 1e-5 or length_error > 1e-5:
|
||||
raise SystemExit(
|
||||
f"isolated Edge endpoint failed: matched={matched_edge}, "
|
||||
f"endpoint_error={endpoint_error:g}, length_error={length_error:g}, response={response}"
|
||||
)
|
||||
_assert_one_solid(result_model, "isolated Edge endpoint")
|
||||
print(f"isolated Edge endpoint ok: edge={edge_id}, matched={matched_edge}")
|
||||
|
||||
|
||||
def _verify_edge_center_isolated(temp_dir: Path) -> None:
|
||||
input_path = DEFAULT_CUBE
|
||||
output_path = temp_dir / "edge_center_out.step"
|
||||
request_path = temp_dir / "edge_center_request.json"
|
||||
model = StepModel.load(input_path)
|
||||
edge_id, start, end, _direction, _source_length = _source_edge_frame(model, 10.0, 1e-5)
|
||||
current_center = _tuple3(model.edge_info(edge_id).get("length_center"), "source center")
|
||||
delta = (0.0, 0.0, 2.0)
|
||||
target_center = _add(current_center, delta)
|
||||
target_start = _add(start, delta)
|
||||
target_end = _add(end, delta)
|
||||
|
||||
_write_request(request_path, input_path, output_path, "move_edge_center", [edge_id, target_center])
|
||||
response = _run_worker(request_path)
|
||||
result_model = _load_worker_output(output_path, response)
|
||||
matched_edge, endpoint_error, length_error = _nearest_expected_edge(result_model, target_start, target_end)
|
||||
if endpoint_error > 1e-5 or length_error > 1e-5:
|
||||
raise SystemExit(
|
||||
f"isolated Edge center failed: matched={matched_edge}, "
|
||||
f"endpoint_error={endpoint_error:g}, length_error={length_error:g}, response={response}"
|
||||
)
|
||||
_assert_one_solid(result_model, "isolated Edge center")
|
||||
print(f"isolated Edge center ok: edge={edge_id}, matched={matched_edge}")
|
||||
|
||||
|
||||
def _verify_circular_edge_axis_isolated(temp_dir: Path) -> None:
|
||||
input_path = temp_dir / "circular_edge_axis.step"
|
||||
output_path = temp_dir / "circular_edge_axis_out.step"
|
||||
request_path = temp_dir / "circular_edge_axis_request.json"
|
||||
_write_through_hole_model(input_path)
|
||||
model = StepModel.load(input_path)
|
||||
hole_face_id = _first_hole_face(model, blind=False)
|
||||
current_diameter = float(model.face_info(hole_face_id)["diameter"])
|
||||
edge_id = None
|
||||
target_edge_center = None
|
||||
target_axis_center = None
|
||||
for candidate in range(len(model.edges)):
|
||||
if model.edge_info(candidate).get("curve") != "circle":
|
||||
continue
|
||||
current_edge_center = _tuple3(model.edge_info(candidate).get("center"), f"circle Edge {candidate} center")
|
||||
candidate_target = (current_edge_center[0] + 2.0, current_edge_center[1], current_edge_center[2])
|
||||
plan = model.circular_edge_axis_move_plan(candidate, candidate_target)
|
||||
if plan.get("status") != "blocked":
|
||||
edge_id = candidate
|
||||
target_edge_center = candidate_target
|
||||
target_axis_center = _tuple3(plan.get("target_axis_center"), "target axis center")
|
||||
break
|
||||
if edge_id is None or target_edge_center is None or target_axis_center is None:
|
||||
raise SystemExit("no circular Edge with editable adjacent cylinder axis was found")
|
||||
|
||||
_write_request(request_path, input_path, output_path, "move_circular_edge_axis_center", [edge_id, target_edge_center])
|
||||
response = _run_worker(request_path)
|
||||
result_model = _load_worker_output(output_path, response)
|
||||
verified_face, diameter, center, error = _nearest_hole_by_diameter(
|
||||
result_model,
|
||||
current_diameter,
|
||||
target_axis_center,
|
||||
blind=False,
|
||||
)
|
||||
if error > 1e-4 or abs(diameter - current_diameter) > 1e-4:
|
||||
raise SystemExit(
|
||||
f"isolated circular Edge axis failed: face={verified_face}, "
|
||||
f"diameter={diameter:g}, center={center}, error={error:g}, response={response}"
|
||||
)
|
||||
_assert_one_solid(result_model, "isolated circular Edge axis")
|
||||
print(f"isolated circular Edge axis ok: edge={edge_id}, face={verified_face}")
|
||||
|
||||
|
||||
def _verify_ellipse_edge_radius_isolated(temp_dir: Path) -> None:
|
||||
input_path = temp_dir / "ellipse_edge.step"
|
||||
output_path = temp_dir / "ellipse_edge_out.step"
|
||||
request_path = temp_dir / "ellipse_edge_request.json"
|
||||
_write_ellipse_face_model(input_path)
|
||||
model = StepModel.load(input_path)
|
||||
edge_ids = _ellipse_edge_ids(model)
|
||||
if not edge_ids:
|
||||
raise SystemExit("no ellipse Edge was recognized")
|
||||
edge_id = edge_ids[0]
|
||||
target_radius = 7.5
|
||||
plan = model.ellipse_edge_axis_radius_plan(edge_id, target_radius, axis_kind="major")
|
||||
|
||||
_write_request(
|
||||
request_path,
|
||||
input_path,
|
||||
output_path,
|
||||
"resize_ellipse_edge_axis_radius",
|
||||
[edge_id, target_radius, "major"],
|
||||
)
|
||||
response = _run_worker(request_path)
|
||||
result_model = _load_worker_output(output_path, response)
|
||||
sampled = result_model._ellipse_edge_axis_radius_sampled_result(plan)
|
||||
if sampled is None:
|
||||
raise SystemExit(f"isolated ellipse Edge radius could not be sampled: response={response}")
|
||||
verified_edge, value, other, error = sampled
|
||||
if error > 5e-3 or abs(other - 2.0) > 5e-3:
|
||||
raise SystemExit(
|
||||
f"isolated ellipse Edge radius failed: edge={verified_edge}, "
|
||||
f"value={value:g}, other={other:g}, error={error:g}, response={response}"
|
||||
)
|
||||
print(f"isolated ellipse Edge radius ok: edge={edge_id}, verified={verified_edge}, value={value:g}")
|
||||
|
||||
|
||||
def _verify_edge_fillet_isolated(temp_dir: Path) -> None:
|
||||
input_path = temp_dir / "edge_fillet.step"
|
||||
output_path = temp_dir / "edge_fillet_out.step"
|
||||
request_path = temp_dir / "edge_fillet_request.json"
|
||||
_write_box_model(input_path)
|
||||
model = StepModel.load(input_path)
|
||||
edge_id = _first_editable_line_edge(model)
|
||||
target_radius = 1.0
|
||||
|
||||
_write_request(request_path, input_path, output_path, "fillet_edge", [edge_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)
|
||||
if not matches:
|
||||
raise SystemExit(f"isolated Edge fillet failed: response={response}")
|
||||
_assert_one_solid(result_model, "isolated Edge fillet")
|
||||
print(f"isolated Edge fillet ok: edge={edge_id}, matches={matches[:3]}")
|
||||
|
||||
|
||||
def _verify_edge_chamfers_isolated(temp_dir: Path) -> None:
|
||||
cases = (
|
||||
("chamfer_edge", [1.0], "chamfer"),
|
||||
("chamfer_edge_asymmetric", [0.8, 1.2, None], "asymmetric_chamfer"),
|
||||
("chamfer_edge_distance_angle", [1.0, 45.0, None], "distance_angle_chamfer"),
|
||||
)
|
||||
for operation, tail_args, label in cases:
|
||||
input_path = temp_dir / f"{label}.step"
|
||||
output_path = temp_dir / f"{label}_out.step"
|
||||
request_path = temp_dir / f"{label}_request.json"
|
||||
_write_box_model(input_path)
|
||||
model = StepModel.load(input_path)
|
||||
edge_id = _first_editable_line_edge(model)
|
||||
reference_face_id = _first_adjacent_reference_face(model, edge_id)
|
||||
args = [edge_id, *tail_args]
|
||||
if operation != "chamfer_edge":
|
||||
args[-1] = reference_face_id
|
||||
before = model.stats()
|
||||
_write_request(request_path, input_path, output_path, operation, args)
|
||||
response = _run_worker(request_path)
|
||||
result_model = _load_worker_output(output_path, response)
|
||||
after = result_model.stats()
|
||||
_verify_chamfer_topology(label, edge_id, {"resize_strategy": operation}, before, after, str(response.get("message")), [])
|
||||
_assert_one_solid(result_model, f"isolated {label}")
|
||||
|
||||
|
||||
def _verify_existing_fillet_isolated(temp_dir: Path) -> None:
|
||||
input_path = temp_dir / "existing_fillet.step"
|
||||
output_path = temp_dir / "existing_fillet_out.step"
|
||||
request_path = temp_dir / "existing_fillet_request.json"
|
||||
source_radius = 1.0
|
||||
target_radius = 1.5
|
||||
_write_filleted_box_model(input_path, source_radius)
|
||||
model = StepModel.load(input_path)
|
||||
face_id = _first_existing_fillet_face(model, source_radius, 2e-4)
|
||||
|
||||
_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 not matches or old_matches:
|
||||
raise SystemExit(
|
||||
f"isolated existing fillet failed: matches={matches}, old_matches={old_matches}, response={response}"
|
||||
)
|
||||
_assert_one_solid(result_model, "isolated existing fillet")
|
||||
print(f"isolated existing fillet ok: face={face_id}, matches={matches[:3]}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_edge_isolated_") as temp:
|
||||
temp_dir = Path(temp)
|
||||
_verify_edge_length_isolated(temp_dir)
|
||||
_verify_edge_endpoint_isolated(temp_dir)
|
||||
_verify_edge_center_isolated(temp_dir)
|
||||
_verify_circular_edge_axis_isolated(temp_dir)
|
||||
_verify_ellipse_edge_radius_isolated(temp_dir)
|
||||
_verify_edge_fillet_isolated(temp_dir)
|
||||
_verify_edge_chamfers_isolated(temp_dir)
|
||||
_verify_existing_fillet_isolated(temp_dir)
|
||||
print("Edge isolated edit suite passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -250,6 +250,9 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera
|
||||
plan = model.existing_fillet_resize_plan(face_id, target_radius)
|
||||
if plan["status"] == "blocked":
|
||||
raise SystemExit(f"existing fillet plan was blocked: {plan['message']}")
|
||||
support_face_ids = tuple(plan.get("feature_existing_fillet_support_face_ids", ()))
|
||||
if len(support_face_ids) < 2:
|
||||
raise SystemExit(f"existing fillet should expose at least two support Faces, got {support_face_ids}")
|
||||
result = model.resize_existing_fillet(face_id, target_radius)
|
||||
after = model.stats()
|
||||
matches = _cylindrical_faces_near_radius(model, target_radius, tolerance)
|
||||
@@ -265,6 +268,7 @@ def _run_existing_fillet_case(source_radius: float, target_radius: float, tolera
|
||||
print(f"strategy={plan.get('resize_strategy')}")
|
||||
print(f"before={before}")
|
||||
print(f"after={after}")
|
||||
print(f"support_face_ids={support_face_ids}")
|
||||
print(f"source_radius={source_radius:.6f} target_radius={target_radius:.6f}")
|
||||
print(f"matched_faces={matches}")
|
||||
print(result.encode("ascii", "backslashreplace").decode("ascii"))
|
||||
|
||||
@@ -86,6 +86,10 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"planar Face on curved Solid disables local-only deformation",
|
||||
("verify_face_mixed_surface_guard.py",),
|
||||
),
|
||||
(
|
||||
"freeform Face stays read-only with a clear blocker",
|
||||
("verify_face_freeform_guard.py",),
|
||||
),
|
||||
(
|
||||
"cylindrical side Face height edits are checked",
|
||||
("verify_cylindrical_height_resize.py",),
|
||||
@@ -130,6 +134,14 @@ CASES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"Face logical selection stays on the edited face",
|
||||
("verify_face_logical_selection_retention.py",),
|
||||
),
|
||||
(
|
||||
"Face selection identity UI prefers logical IDs",
|
||||
("verify_selection_identity_ui.py",),
|
||||
),
|
||||
(
|
||||
"Face feature parameters stay stable after full recognition cache",
|
||||
("verify_face_feature_parameter_consistency.py",),
|
||||
),
|
||||
(
|
||||
"Face invalid positive targets are blocked",
|
||||
("verify_face_invalid_target_guards.py",),
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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.model import StepModel
|
||||
from step_editor.window_state import WindowStateMixin, _feature_dimension_keys
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
|
||||
class _Probe(WindowStateMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _feature_rows(model: StepModel, face_id: int) -> tuple[str, ...]:
|
||||
info = model.quick_face_info(face_id)
|
||||
probe = object.__new__(_Probe)
|
||||
probe.model = model
|
||||
probe.operation_in_progress = False
|
||||
probe.scan_in_progress = False
|
||||
probe.load_in_progress = False
|
||||
probe.selected_face_id = face_id
|
||||
probe.selected_edge_id = None
|
||||
probe.selected_kind = "feature"
|
||||
probe.selected_part_id = int(info["part_id"])
|
||||
probe.selected_solid_id = int(info["solid_id"])
|
||||
probe.feature_detection_level = "current-only"
|
||||
probe.manual_bottom_face_id = None
|
||||
probe.manual_slot_pair_face_id = None
|
||||
context = probe._feature_context_info(face_id)
|
||||
specs, _used = probe._editable_property_specs(context)
|
||||
rows = probe._feature_context_property_specs(specs, context)
|
||||
return tuple(str(row.get("key")) for row in rows if row.get("parameter_role") == "dimension")
|
||||
|
||||
|
||||
def _assert_contains(keys: tuple[str, ...], expected: tuple[str, ...], label: str) -> None:
|
||||
missing = [key for key in expected if key not in keys]
|
||||
if missing:
|
||||
raise AssertionError(f"{label} missing {missing}, got {keys}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
model = StepModel.load(MODEL_PATH)
|
||||
if FACE_ID >= len(model.faces):
|
||||
raise AssertionError(f"test model has no Face {FACE_ID}")
|
||||
|
||||
quick_info = model.quick_face_info(FACE_ID)
|
||||
if quick_info.get("surface") != "plane":
|
||||
raise AssertionError(f"Face {FACE_ID} should be planar in the baseline model: {quick_info}")
|
||||
|
||||
before_cached_rows = _feature_rows(model, FACE_ID)
|
||||
_assert_contains(before_cached_rows, BASE_FACE_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")
|
||||
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")
|
||||
if before_cached_rows != after_cached_rows:
|
||||
raise AssertionError(
|
||||
"Face 594 current-only feature rows changed after full recognition cache: "
|
||||
f"before={before_cached_rows}, after={after_cached_rows}"
|
||||
)
|
||||
|
||||
print("Face feature parameter consistency ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Surface
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace
|
||||
from OCC.Core.GeomAbs import GeomAbs_BSplineSurface
|
||||
from OCC.Core.GeomAPI import GeomAPI_PointsToBSplineSurface
|
||||
from OCC.Core.TColgp import TColgp_Array2OfPnt
|
||||
from OCC.Core.gp import gp_Pnt
|
||||
|
||||
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.model import StepModel
|
||||
from step_editor.step_io import _write_step
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
|
||||
|
||||
class _PropertySpecProbe(WindowStateMixin):
|
||||
def __init__(self, face_id: int, selected_kind: str = "face") -> None:
|
||||
self.model = object()
|
||||
self.operation_in_progress = False
|
||||
self.scan_in_progress = False
|
||||
self.load_in_progress = False
|
||||
self.selected_face_id = face_id
|
||||
self.selected_edge_id = None
|
||||
self.selected_kind = selected_kind
|
||||
self.selected_part_id = 1
|
||||
self.selected_solid_id = -1
|
||||
self.manual_bottom_face_id = None
|
||||
self.manual_slot_pair_face_id = None
|
||||
|
||||
|
||||
def _write_b_spline_patch(path: Path) -> None:
|
||||
points = TColgp_Array2OfPnt(1, 4, 1, 4)
|
||||
for row in range(1, 5):
|
||||
x = float(row - 1) * 10.0
|
||||
for column in range(1, 5):
|
||||
y = float(column - 1) * 10.0
|
||||
z = 1.2 * ((row - 2.5) ** 2 - (column - 2.5) ** 2)
|
||||
points.SetValue(row, column, gp_Pnt(x, y, z))
|
||||
surface = GeomAPI_PointsToBSplineSurface(points).Surface()
|
||||
face = BRepBuilderAPI_MakeFace(surface, 1e-6).Face()
|
||||
_write_step(face, path)
|
||||
|
||||
|
||||
def _freeform_face_id(model: StepModel) -> int:
|
||||
for face_id, face in enumerate(model.faces):
|
||||
surf = BRepAdaptor_Surface(face)
|
||||
if surf.GetType() == GeomAbs_BSplineSurface:
|
||||
return face_id
|
||||
raise SystemExit("B-spline Face was not found")
|
||||
|
||||
|
||||
def _specs(info: dict[str, object], selected_kind: str = "face") -> list[dict[str, object]]:
|
||||
probe = _PropertySpecProbe(int(info.get("face_id", 0)), selected_kind=selected_kind)
|
||||
specs, _used = probe._editable_property_specs(info)
|
||||
return specs
|
||||
|
||||
|
||||
def _property_rows(info: dict[str, object], selected_kind: str = "face") -> list[dict[str, object]]:
|
||||
probe = _PropertySpecProbe(int(info.get("face_id", 0)), selected_kind=selected_kind)
|
||||
return probe._property_editor_specs(info, info)
|
||||
|
||||
|
||||
def _assert_no_editable_actions(specs: list[dict[str, object]], label: str) -> None:
|
||||
editable = [
|
||||
str(spec.get("key"))
|
||||
for spec in specs
|
||||
if bool(spec.get("editable")) and bool(spec.get("enabled")) and bool(spec.get("action"))
|
||||
]
|
||||
if editable:
|
||||
raise SystemExit(f"{label} should not expose editable actions for a freeform Face: {editable}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_freeform_guard_") as temp_dir:
|
||||
path = Path(temp_dir) / "freeform_patch.step"
|
||||
_write_b_spline_patch(path)
|
||||
model = StepModel.load(path)
|
||||
face_id = _freeform_face_id(model)
|
||||
|
||||
quick_info = model.quick_face_info(face_id)
|
||||
info = model.face_info(face_id)
|
||||
for label, selected_info in (("quick", quick_info), ("full", info)):
|
||||
if selected_info.get("surface") != "b-spline surface":
|
||||
raise SystemExit(f"{label} info should identify a B-spline surface: {selected_info}")
|
||||
if selected_info.get("freeform_face_status") != "blocked":
|
||||
raise SystemExit(f"{label} info should block freeform Face editing: {selected_info}")
|
||||
if selected_info.get("recognition_risk") != "blocked":
|
||||
raise SystemExit(f"{label} recognition risk should be blocked: {selected_info}")
|
||||
blockers = str(selected_info.get("freeform_face_blockers") or "")
|
||||
if "自由曲面" not in blockers or "历史参数" not in blockers:
|
||||
raise SystemExit(f"{label} blocker should explain the missing stable history parameter: {selected_info}")
|
||||
|
||||
editable_specs = _specs(info)
|
||||
editable_keys = {str(spec.get("key")) for spec in editable_specs}
|
||||
if "freeform_surface_edit_semantics" not in editable_keys:
|
||||
raise SystemExit(f"freeform Face should expose a read-only edit semantics row: {editable_specs}")
|
||||
forbidden = {
|
||||
"area",
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
"cone_reference_radius",
|
||||
"sphere_radius",
|
||||
"torus_major_radius",
|
||||
}
|
||||
leaked = sorted(forbidden & editable_keys)
|
||||
if leaked:
|
||||
raise SystemExit(f"freeform Face should not expose parametric edit rows: {leaked}")
|
||||
_assert_no_editable_actions(editable_specs, "face mode editable specs")
|
||||
|
||||
feature_rows = _property_rows(info, selected_kind="feature")
|
||||
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}")
|
||||
_assert_no_editable_actions(feature_rows, "feature mode rows")
|
||||
|
||||
print(
|
||||
"freeform Face guard ok: "
|
||||
f"face_id={face_id}, surface={info.get('surface')}, "
|
||||
f"risk={info.get('recognition_risk')}, specs={sorted(editable_keys)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -202,7 +202,7 @@ def main() -> int:
|
||||
|
||||
specs = _specs(face_id, info)
|
||||
semantics = _spec(specs, "face_edit_semantics")
|
||||
if "不能只改当前面" not in str(semantics.get("current_text") or ""):
|
||||
if "不能局部重建" not in str(semantics.get("current_text") or ""):
|
||||
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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -53,9 +53,26 @@ def main() -> int:
|
||||
},
|
||||
("existing_fillet_radius_estimate",),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane"},
|
||||
(
|
||||
"area",
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
),
|
||||
)
|
||||
assert_keys(
|
||||
{"surface": "plane", "shell_region_status": "candidate"},
|
||||
("shell_thickness_estimate",),
|
||||
(
|
||||
"area",
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
"shell_thickness_estimate",
|
||||
),
|
||||
)
|
||||
assert_keys(
|
||||
{
|
||||
|
||||
@@ -129,9 +129,9 @@ def _verify_holed_planar_summary(root: Path) -> None:
|
||||
_assert(not str(info.get("recognition_blockers") or ""), f"holed Face blockers should stay empty: {info}")
|
||||
ready_actions = str(info.get("recognition_ready_actions") or "")
|
||||
limited_actions = str(info.get("recognition_limited_actions") or "")
|
||||
_assert("平面推拉" in ready_actions, f"holed Face should expose push/pull as ready: {info}")
|
||||
_assert("平面拉伸/切除" in ready_actions, f"holed Face should expose push/pull as ready: {info}")
|
||||
_assert(
|
||||
"当前面局部尺寸/中心/偏移" in limited_actions,
|
||||
"局部重建尺寸/中心/偏移" in limited_actions,
|
||||
f"holed Face should expose local deformation as limited: {info}",
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -374,49 +374,49 @@ def main() -> int:
|
||||
input_path=hollow_path,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面偏移(当前面)",
|
||||
label="偏移曲面(局部重建)",
|
||||
operation="move_face_plane_offset_local",
|
||||
args=[0, 10.0],
|
||||
validator=_assert_plane_position,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面积(当前面)",
|
||||
label="面积(局部重建)",
|
||||
operation="resize_face_area_local",
|
||||
args=[0, 225.0],
|
||||
validator=_assert_face_area,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面积(整体)",
|
||||
label="面积(缩放特征)",
|
||||
operation="resize_face_area",
|
||||
args=[0, 400.0],
|
||||
validator=lambda label, model: _assert_face_area(label, model, 400.0),
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面宽(当前面)",
|
||||
label="U向尺寸(局部重建)",
|
||||
operation="resize_face_size_local",
|
||||
args=[0, 25.0, "width"],
|
||||
validator=_assert_face_width,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面高(当前面)",
|
||||
label="V向尺寸(局部重建)",
|
||||
operation="resize_face_size_local",
|
||||
args=[0, 25.0, "height"],
|
||||
validator=_assert_face_height,
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面宽(整体)",
|
||||
label="U向尺寸(缩放特征)",
|
||||
operation="resize_face_size_owning_scale",
|
||||
args=[0, 25.0, "width"],
|
||||
validator=lambda label, model: _assert_face_width(label, model, 25.0),
|
||||
)
|
||||
_run_worker_case(
|
||||
label="面高(整体)",
|
||||
label="V向尺寸(缩放特征)",
|
||||
operation="resize_face_size_owning_scale",
|
||||
args=[0, 25.0, "height"],
|
||||
validator=lambda label, model: _assert_face_height(label, model, 25.0),
|
||||
)
|
||||
_run_worker_case(
|
||||
label="中心(当前面)",
|
||||
label="中心(局部重建)",
|
||||
operation="move_face_center_local",
|
||||
args=[0, [15.0, 5.0, 0.0]],
|
||||
validator=_assert_face_center,
|
||||
@@ -430,7 +430,7 @@ def main() -> int:
|
||||
if str(shell_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"shell thickness isolation case should be high risk, got {shell_plan}")
|
||||
_run_worker_case(
|
||||
label="薄壁厚度(当前面)",
|
||||
label="壳体厚度(拉伸/切除)",
|
||||
operation="resize_shell_thickness",
|
||||
args=[shell_face_id, 4.0],
|
||||
validator=_assert_shell_thickness,
|
||||
@@ -440,7 +440,7 @@ def main() -> int:
|
||||
if str(shell_owning_plan.get("risk")) != "high":
|
||||
raise SystemExit(f"shell thickness owning isolation case should be high risk, got {shell_owning_plan}")
|
||||
_run_worker_case(
|
||||
label="薄壁厚度(整体)",
|
||||
label="壳体厚度(缩放特征)",
|
||||
operation="resize_shell_thickness_owning_scale",
|
||||
args=[shell_face_id, 4.0],
|
||||
validator=_assert_shell_thickness,
|
||||
@@ -534,16 +534,16 @@ def main() -> int:
|
||||
delattr(sys, "frozen")
|
||||
|
||||
for title in (
|
||||
"面宽(当前面)",
|
||||
"面高(当前面)",
|
||||
"面宽(整体)",
|
||||
"面高(整体)",
|
||||
"薄壁厚度(整体)缩放所属对象",
|
||||
"U向尺寸(局部重建)",
|
||||
"V向尺寸(局部重建)",
|
||||
"U向尺寸(缩放特征)",
|
||||
"V向尺寸(缩放特征)",
|
||||
"壳体厚度(缩放特征)缩放所属对象",
|
||||
):
|
||||
if not probe._quick_edit_title_supports_isolation(title):
|
||||
raise SystemExit(f"{title}: quick edit title should support isolated execution")
|
||||
context = {
|
||||
"operation_name": "面宽(当前面)",
|
||||
"operation_name": "U向尺寸(局部重建)",
|
||||
"target": "Face 0",
|
||||
"parameters": {"part_id": 1, "face_id": 0},
|
||||
"target_kind": "face",
|
||||
@@ -596,7 +596,7 @@ def main() -> int:
|
||||
owning_result = owning_probe._run_isolated_edit_job(
|
||||
context={
|
||||
**context,
|
||||
"operation_name": "面宽(整体)",
|
||||
"operation_name": "U向尺寸(缩放特征)",
|
||||
},
|
||||
isolation=owning_isolation,
|
||||
snapshot=owning_probe.model.snapshot(),
|
||||
@@ -616,7 +616,7 @@ def main() -> int:
|
||||
raise SystemExit("isolated owning window job replaced the window model before returning to the UI thread")
|
||||
if owning_result.get("model_polydata") is None or owning_result.get("edge_polydata") is None:
|
||||
raise SystemExit("isolated owning window job should return prebuilt display polydata")
|
||||
_assert_face_width("面宽(整体窗口任务)", owning_after_model, 25.0)
|
||||
_assert_face_width("U向尺寸(整体窗口任务)", owning_after_model, 25.0)
|
||||
if not _logical_region_has_width(owning_after_model, 0, 25.0):
|
||||
raise SystemExit("isolated owning window job did not preserve the original logical Face ID on the edited width")
|
||||
print("isolated owning window job ok")
|
||||
|
||||
@@ -19,6 +19,11 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.model import StepModel
|
||||
from step_editor.window_actions import WindowActionMixin
|
||||
|
||||
|
||||
class _WindowActionProbe(WindowActionMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _wire_count(face) -> int:
|
||||
@@ -240,17 +245,54 @@ def main() -> int:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap inward push/pull plan should be quick: {multi_inward_elapsed:.3f}s"
|
||||
)
|
||||
if multi_inward_plan.get("status") != "blocked":
|
||||
if multi_inward_plan.get("status") == "blocked":
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap inward push/pull should be blocked until second-level propagation exists: "
|
||||
f"multi-boundary cap shallow inward push/pull should be allowed by boundary shell rebuild: "
|
||||
f"{multi_inward_plan}"
|
||||
)
|
||||
multi_inward_message = str(multi_inward_plan.get("message") or "")
|
||||
if "二级关系" not in multi_inward_message or "通用 OCCT 布尔" not in multi_inward_message:
|
||||
if multi_inward_plan.get("planar_cap_extension_method") != "boundary-shell-rebuild":
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap inward blocker should explain topology depth and slow Boolean risk: "
|
||||
f"multi-boundary cap shallow inward should use boundary shell rebuild: {multi_inward_plan}"
|
||||
)
|
||||
multi_inward_message = str(multi_inward_plan.get("message") or "")
|
||||
if "向内收缩" not in multi_inward_message and "重建侧壁" not in multi_inward_message:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap shallow inward message should explain boundary shell semantics: "
|
||||
f"{multi_inward_plan}"
|
||||
)
|
||||
multi_inward_result_model = StepModel.load(path)
|
||||
started = time.perf_counter()
|
||||
multi_inward_result = multi_inward_result_model.push_pull_face(multi_face_id, -1.0)
|
||||
multi_inward_result_elapsed = time.perf_counter() - started
|
||||
if multi_inward_result_elapsed > 15.0:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap shallow inward push/pull took too long: "
|
||||
f"{multi_inward_result_elapsed:.3f}s; result={multi_inward_result}"
|
||||
)
|
||||
if "boundary-shell rebuild" not in multi_inward_result or "actual=56.5" not in multi_inward_result:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap shallow inward should move to 56.5 by boundary shell rebuild: {multi_inward_result}"
|
||||
)
|
||||
if multi_inward_result_model.stats().solids != 1:
|
||||
raise SystemExit(f"multi-boundary cap shallow inward should keep one Solid: {multi_inward_result_model.stats()}")
|
||||
inward_retained_ids = multi_inward_result_model.face_ids_for_logical_id(multi_logical_id)
|
||||
if len(inward_retained_ids) != 1:
|
||||
raise SystemExit(f"multi-boundary cap shallow inward logical Face should follow the moved cap: {inward_retained_ids}")
|
||||
inward_info = multi_inward_result_model.quick_face_info(inward_retained_ids[0])
|
||||
if int(inward_info.get("inner_boundary_wires", 0) or 0) < 5:
|
||||
raise SystemExit(f"multi-boundary cap shallow inward should keep inner wires: {inward_info}")
|
||||
|
||||
multi_deep_inward_plan = multi_model.push_pull_plan(multi_face_id, -77.5)
|
||||
if multi_deep_inward_plan.get("status") != "blocked":
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap deep inward push/pull should still be blocked: {multi_deep_inward_plan}"
|
||||
)
|
||||
multi_deep_inward_message = str(multi_deep_inward_plan.get("message") or "")
|
||||
if "材料厚度" not in multi_deep_inward_message and "二级关系" not in multi_deep_inward_message:
|
||||
raise SystemExit(
|
||||
f"multi-boundary cap deep inward blocker should explain cut-through or topology depth: "
|
||||
f"{multi_deep_inward_plan}"
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
multi_result = multi_model.push_pull_face(multi_face_id, 34.5)
|
||||
@@ -351,6 +393,42 @@ def main() -> int:
|
||||
raise SystemExit(
|
||||
f"large multi-boundary cap isolated worker used the wrong path: {multi_isolated_message}"
|
||||
)
|
||||
isolated_output_model = StepModel.load(output_path)
|
||||
probe = _WindowActionProbe()
|
||||
direction = multi_plan.get("outward_direction") or multi_plan.get("plane_direction")
|
||||
context = {
|
||||
"operation_name": "拉伸/切除平面",
|
||||
"target_kind": "face",
|
||||
"target_id": multi_face_id,
|
||||
"target_logical_id": multi_face_id,
|
||||
"parameters": {
|
||||
"part_id": multi_plan.get("part_id"),
|
||||
"solid_id": multi_plan.get("solid_id"),
|
||||
"surface": "plane",
|
||||
"outward_direction": direction,
|
||||
"current_plane_position": multi_plan.get("current_plane_position"),
|
||||
"target_plane_position": multi_plan.get("target_plane_position"),
|
||||
"bbox_diagonal": multi_plan.get("bbox_diagonal"),
|
||||
"resize_strategy": multi_plan.get("resize_strategy"),
|
||||
},
|
||||
}
|
||||
blocker = probe._face_target_integrity_blocker(isolated_output_model, context)
|
||||
if blocker:
|
||||
raise SystemExit(f"window-layer isolated target check should accept the moved cap: {blocker}")
|
||||
probe._preserve_isolated_face_logical_id(isolated_output_model, context, multi_isolated_message)
|
||||
retained_after_isolation = isolated_output_model.face_ids_for_logical_id(multi_face_id)
|
||||
if not retained_after_isolation:
|
||||
raise SystemExit("window-layer isolated logical Face retention lost the moved cap")
|
||||
if not probe._face_target_plane_position_matches(
|
||||
isolated_output_model,
|
||||
retained_after_isolation,
|
||||
float(multi_plan["target_plane_position"]),
|
||||
direction,
|
||||
float(multi_plan["bbox_diagonal"]),
|
||||
):
|
||||
raise SystemExit(
|
||||
f"window-layer isolated logical Face retention points at the wrong cap: {retained_after_isolation}"
|
||||
)
|
||||
|
||||
print(
|
||||
"large stepped cap push/pull ok: "
|
||||
|
||||
@@ -9,7 +9,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
from step_editor.ui_helpers import _format_value
|
||||
from step_editor.ui_helpers import INFO_LABELS, _format_value
|
||||
|
||||
|
||||
class _PropertySpecProbe(WindowStateMixin):
|
||||
@@ -181,7 +181,20 @@ def _assert_actionable_rows_first(info: dict[str, object], expected_keys: tuple[
|
||||
|
||||
|
||||
def _collect_legacy_face_terms(value: object, path: str = "specs") -> list[str]:
|
||||
legacy_terms = ("面内尺寸 1/2", "面内尺寸 1", "面内尺寸 2", "面位置", "偏移距离")
|
||||
legacy_terms = (
|
||||
"面内尺寸 1/2",
|
||||
"面内尺寸 1",
|
||||
"面内尺寸 2",
|
||||
"面位置",
|
||||
"偏移距离",
|
||||
"面偏移",
|
||||
"面宽",
|
||||
"面高",
|
||||
"推拉当前面",
|
||||
"只改当前面",
|
||||
"调整整个特征",
|
||||
"移动整个特征",
|
||||
)
|
||||
hits: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
@@ -229,6 +242,13 @@ def _assert_no_legacy_face_source_terms() -> None:
|
||||
re.compile(r"面内尺寸 2"),
|
||||
re.compile(r"(?<![端平底])面位置"),
|
||||
re.compile(r"偏移距离"),
|
||||
re.compile(r"面偏移"),
|
||||
re.compile(r"面宽"),
|
||||
re.compile(r"(?<!侧)面高(?!度)"),
|
||||
re.compile(r"推拉当前面"),
|
||||
re.compile(r"只改当前面"),
|
||||
re.compile(r"调整整个特征"),
|
||||
re.compile(r"移动整个特征"),
|
||||
)
|
||||
hits: list[str] = []
|
||||
for file_path in files:
|
||||
@@ -309,13 +329,19 @@ def _assert_holed_plane_local_scopes_disabled() -> None:
|
||||
raise SystemExit("Face plane-offset push/pull scope should remain available for a holed planar Face")
|
||||
|
||||
semantics = _spec(specs, "face_edit_semantics")
|
||||
if "不能只改当前面" not in str(semantics.get("current_text") or ""):
|
||||
if "不能局部重建" not in str(semantics.get("current_text") or ""):
|
||||
raise SystemExit(f"Face edit semantics should summarize the local blocker: {semantics}")
|
||||
if "has inner boundary" not in str(semantics.get("disabled_tip") or ""):
|
||||
raise SystemExit(f"Face edit semantics tip should include the blocker: {semantics}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if INFO_LABELS.get("face_id") != "当前拓扑 Face ID":
|
||||
raise SystemExit("raw face_id label should make clear that it is the current topological Face ID")
|
||||
for key in ("selection_title", "selection_display_id", "selection_topological_face_id"):
|
||||
if key not in INFO_LABELS:
|
||||
raise SystemExit(f"{key} should have a user-facing label")
|
||||
|
||||
relation_depths_text = _format_value(("second-level", "third-level", "deeper"))
|
||||
if "second-level" not in relation_depths_text or "deeper" not in relation_depths_text:
|
||||
raise SystemExit(f"relation-depth tuple should render as text: {relation_depths_text!r}")
|
||||
@@ -353,9 +379,9 @@ def main() -> int:
|
||||
},
|
||||
"plane Face",
|
||||
)
|
||||
_assert_label(plane_specs, "local_face_width", "面宽")
|
||||
_assert_label(plane_specs, "local_face_height", "面高")
|
||||
_assert_label(plane_specs, "face_target_normal_position", "面偏移")
|
||||
_assert_label(plane_specs, "local_face_width", "U向尺寸")
|
||||
_assert_label(plane_specs, "local_face_height", "V向尺寸")
|
||||
_assert_label(plane_specs, "face_target_normal_position", "偏移曲面")
|
||||
_assert_actionable_rows_first(
|
||||
plane_info,
|
||||
(
|
||||
@@ -378,9 +404,18 @@ def main() -> int:
|
||||
for fragment in ("Face 区域 1 个", "边界 Edge 4 条", "共享边相邻 Face 4 个"):
|
||||
if fragment not in topology_text:
|
||||
raise SystemExit(f"plane feature topology row should explain first-level counts, got {topology_spec}")
|
||||
if "face_target_normal_position" not in plane_feature_keys:
|
||||
expected_plane_feature_keys = {
|
||||
"area",
|
||||
"local_face_width",
|
||||
"local_face_height",
|
||||
"face_center_position",
|
||||
"face_target_normal_position",
|
||||
}
|
||||
missing_plane_feature_keys = expected_plane_feature_keys - plane_feature_keys
|
||||
if missing_plane_feature_keys:
|
||||
raise SystemExit(
|
||||
"plane feature mode should expose the current Face offset parameter; "
|
||||
"plain plane feature mode should expose the same first-level editable Face parameters; "
|
||||
f"missing {sorted(missing_plane_feature_keys)}, "
|
||||
f"got {sorted(plane_feature_keys)}"
|
||||
)
|
||||
if "no_editable_feature_dimensions" in plane_feature_keys:
|
||||
@@ -470,6 +505,12 @@ def main() -> int:
|
||||
mode,
|
||||
("5%", "5 倍", "会被阻止"),
|
||||
)
|
||||
shell_local = _scope_mode(shell_specs, "shell_thickness_estimate", "local")
|
||||
if shell_local.get("label") != "拉伸/切除":
|
||||
raise SystemExit(f"shell thickness local scope should expose an edit semantic, got {shell_local}")
|
||||
shell_owning = _scope_mode(shell_specs, "shell_thickness_estimate", "owning")
|
||||
if shell_owning.get("label") != "缩放特征":
|
||||
raise SystemExit(f"shell thickness owning scope should expose an edit semantic, got {shell_owning}")
|
||||
|
||||
cylinder_keys = _spec_keys(
|
||||
{
|
||||
@@ -549,9 +590,62 @@ def main() -> int:
|
||||
raise SystemExit(f"generic cylinder height should default to owning-axis resize: {generic_height}")
|
||||
if generic_height.get("action") != "resize_cylindrical_height_owning_scale":
|
||||
raise SystemExit(f"generic cylinder height should use owning-axis action by default: {generic_height}")
|
||||
if generic_height.get("scope_text") != "调整整个特征":
|
||||
if generic_height.get("scope_text") != "缩放特征":
|
||||
raise SystemExit(f"generic cylinder height should show owning scope by default: {generic_height}")
|
||||
|
||||
split_full_cylinder_specs = _specs(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 3.141592653589793,
|
||||
"same_domain_angular_span": 6.283185307179586,
|
||||
"is_full_cylinder": True,
|
||||
"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),
|
||||
}
|
||||
)
|
||||
split_full_height_local = _scope_mode(split_full_cylinder_specs, "cylinder_height", "local")
|
||||
if not bool(split_full_height_local.get("enabled")):
|
||||
raise SystemExit(
|
||||
"split same-domain full cylinder should keep local height push/pull enabled: "
|
||||
f"{split_full_height_local}"
|
||||
)
|
||||
|
||||
split_full_hole_keys = _spec_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
"feature_guess": "hole/groove candidate",
|
||||
"diameter": 6.0,
|
||||
"radius": 3.0,
|
||||
"angular_span": 3.141592653589793,
|
||||
"same_domain_angular_span": 6.283185307179586,
|
||||
"is_full_cylinder": True,
|
||||
"height_estimate": 11.0,
|
||||
"same_domain_height_estimate": 11.0,
|
||||
"area": 188.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),
|
||||
"slot_chord_width_estimate": 6.0,
|
||||
"slot_sagitta_depth_estimate": 3.0,
|
||||
"slot_arc_length_estimate": 9.42477796076938,
|
||||
"feature_bottom_face_ids": (),
|
||||
}
|
||||
)
|
||||
_assert_contains(split_full_hole_keys, {"diameter", "hole_cylinder_radius"}, "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}")
|
||||
|
||||
slot_keys = _spec_keys(
|
||||
{
|
||||
"surface": "cylinder",
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
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.info_panel import InfoPanelMixin
|
||||
from step_editor.ui_helpers import INFO_LABELS
|
||||
from step_editor.window_state import WindowStateMixin
|
||||
|
||||
|
||||
class _SelectionProbe(InfoPanelMixin, WindowStateMixin):
|
||||
pass
|
||||
|
||||
|
||||
class _LogicalFaceModel:
|
||||
def __init__(self, logical_id: int) -> None:
|
||||
self.logical_id = logical_id
|
||||
|
||||
def face_region_logical_id(self, _face_id: int) -> int:
|
||||
return self.logical_id
|
||||
|
||||
def face_logical_id(self, _face_id: int) -> int:
|
||||
return self.logical_id
|
||||
|
||||
|
||||
class _FeatureContextModel(_LogicalFaceModel):
|
||||
faces = [object()] * 800
|
||||
face_part_ids = [1] * 800
|
||||
face_solid_ids = [0] * 800
|
||||
|
||||
def quick_face_info(self, face_id: int) -> dict[str, object]:
|
||||
return {
|
||||
"kind": "face",
|
||||
"face_id": face_id,
|
||||
"topological_face_id": face_id,
|
||||
"logical_face_id": self.logical_id,
|
||||
"surface": "plane",
|
||||
"part_id": 1,
|
||||
"solid_id": 0,
|
||||
"area": 100.0,
|
||||
"area_center": (5.0, 5.0, 0.0),
|
||||
"local_face_width": 10.0,
|
||||
"local_face_height": 10.0,
|
||||
"feature_source_face_id": face_id,
|
||||
"feature_highlight_face_ids": (face_id,),
|
||||
}
|
||||
|
||||
def cached_feature_info(self, _face_id: int) -> dict[str, object] | None:
|
||||
return None
|
||||
|
||||
def face_first_level_topology(self, face_id: int) -> dict[str, object]:
|
||||
return {
|
||||
"topology_relation_depth": 1,
|
||||
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
|
||||
"topology_relation_scope": "selected same-domain region + direct shared-edge adjacent Faces",
|
||||
"topology_relation_boundary": "shared-edge",
|
||||
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
|
||||
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
|
||||
"same_domain_face_ids": (face_id,),
|
||||
"same_domain_face_count": 1,
|
||||
"same_domain_region_kind": "single-face",
|
||||
"first_level_boundary_edge_ids": (1, 2, 3, 4),
|
||||
"first_level_boundary_edge_count": 4,
|
||||
"first_level_boundary_vertex_count": 4,
|
||||
"first_level_adjacent_face_ids": (10, 11, 12, 13),
|
||||
"first_level_adjacent_face_count": 4,
|
||||
"first_level_face_ids": (face_id, 10, 11, 12, 13),
|
||||
"first_level_face_count": 5,
|
||||
"first_level_topology_note": "已识别当前 Face 区域 1 个 Face、边界 Edge 4 条、边界 Vertex 4 个、共享边一级相邻 Face 4 个。",
|
||||
}
|
||||
|
||||
def face_first_level_facts(self, face_id: int, scope: str = "auto") -> dict[str, object]:
|
||||
return {
|
||||
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
|
||||
"first_level_fact_status": "ready",
|
||||
"first_level_fact_relation_depth": 1,
|
||||
"first_level_fact_scope": scope,
|
||||
"first_level_fact_relation_boundary": "shared-edge",
|
||||
"first_level_fact_subject_face_ids": (face_id,),
|
||||
"first_level_fact_subject_face_count": 1,
|
||||
"first_level_fact_boundary_edge_count": 4,
|
||||
"first_level_fact_boundary_vertex_count": 4,
|
||||
"first_level_fact_adjacent_face_ids": (10, 11, 12, 13),
|
||||
"first_level_fact_adjacent_face_count": 4,
|
||||
"first_level_fact_included_face_count": 5,
|
||||
"first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"),
|
||||
"first_level_fact_summary": "一级事实=当前 Face、边界和共享边相邻 Face。",
|
||||
}
|
||||
|
||||
def _recognition_summary_fields(self, _info: dict[str, object]) -> dict[str, object]:
|
||||
return {"recognition_summary": "识别摘要包含一级事实。"}
|
||||
|
||||
|
||||
class _FeatureRefreshProbe(WindowStateMixin):
|
||||
def __init__(self) -> None:
|
||||
self.model = _FeatureContextModel(594)
|
||||
self.selected_kind = "feature"
|
||||
self.selected_face_id = 637
|
||||
self.selected_edge_id = None
|
||||
self.selected_part_id = 1
|
||||
self.selected_solid_id = 0
|
||||
self.selected_pick_position = (1.0, 2.0, 3.0)
|
||||
self.feature_detection_level = "current-only"
|
||||
self.set_info_payload: dict[str, object] | None = None
|
||||
self.highlighted_face_ids: tuple[int, ...] = ()
|
||||
self.synced_id: tuple[str, int] | None = None
|
||||
|
||||
def set_info(self, info: dict[str, object]) -> None:
|
||||
self.set_info_payload = dict(info)
|
||||
self.current_info_values = dict(info)
|
||||
|
||||
def _highlight_faces(self, face_ids: list[int] | tuple[int, ...] | None = None, **_kwargs) -> None:
|
||||
self.highlighted_face_ids = tuple(int(item) for item in (face_ids or ()))
|
||||
|
||||
def _update_selected_object_title(self) -> None:
|
||||
return
|
||||
|
||||
def _sync_id_picker(self, kind: str, target_id: int) -> None:
|
||||
self.synced_id = (kind, int(target_id))
|
||||
|
||||
|
||||
def _probe(kind: str, selected_face_id: int, info: dict[str, object]) -> _SelectionProbe:
|
||||
probe = object.__new__(_SelectionProbe)
|
||||
probe.model = None
|
||||
probe.selected_kind = kind
|
||||
probe.selected_part_id = None
|
||||
probe.selected_solid_id = None
|
||||
probe.selected_face_id = selected_face_id
|
||||
probe.selected_edge_id = None
|
||||
probe.current_info_values = dict(info)
|
||||
return probe
|
||||
|
||||
|
||||
def _assert(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(message)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for key in ("selection_title", "selection_display_id", "selection_topological_face_id"):
|
||||
_assert(key in INFO_LABELS, f"{key} should have a readable UI label")
|
||||
_assert(INFO_LABELS.get("face_id") == "当前拓扑 Face ID", "raw face_id should be labelled as topological")
|
||||
|
||||
info = {
|
||||
"feature_type": "可拉伸/切除平面候选",
|
||||
"selection_display_id": 594,
|
||||
"selection_topological_face_id": 637,
|
||||
"face_region_logical_id": 594,
|
||||
"logical_face_id": 594,
|
||||
}
|
||||
|
||||
feature_probe = _probe("feature", 637, info)
|
||||
_assert(
|
||||
feature_probe._selected_id_text() == "face 594",
|
||||
f"feature copy ID should prefer logical Face ID, got {feature_probe._selected_id_text()!r}",
|
||||
)
|
||||
feature_title = feature_probe._selected_object_title_suffix()
|
||||
_assert(
|
||||
"Face 594" in feature_title and "拓扑 637" in feature_title,
|
||||
f"feature title should show logical and topological IDs: {feature_title}",
|
||||
)
|
||||
|
||||
face_probe = _probe("face", 637, info)
|
||||
_assert(
|
||||
face_probe._selected_id_text() == "face 594",
|
||||
f"Face copy ID should prefer logical Face ID, got {face_probe._selected_id_text()!r}",
|
||||
)
|
||||
face_title = face_probe._selected_object_title_suffix()
|
||||
_assert(
|
||||
"Face 594" in face_title and "拓扑 637" in face_title,
|
||||
f"Face title should show logical and topological IDs: {face_title}",
|
||||
)
|
||||
|
||||
fallback_probe = _probe("feature", 637, {"face_region_logical_id": 594})
|
||||
_assert(fallback_probe._selected_id_text() == "face 594", "logical Face ID fallback should be copied")
|
||||
|
||||
selection_only_probe = _probe("feature", 637, {"selection_display_id": 594})
|
||||
_assert(
|
||||
selection_only_probe._selected_id_text() == "face 594",
|
||||
"selection_display_id should be enough for copied Face ID",
|
||||
)
|
||||
selection_only_title = selection_only_probe._selected_object_title_suffix()
|
||||
_assert(
|
||||
"Face 594" in selection_only_title and "拓扑 637" in selection_only_title,
|
||||
f"selection_display_id should be enough for feature title: {selection_only_title}",
|
||||
)
|
||||
|
||||
face_selection_only_probe = _probe("face", 637, {"selection_display_id": 594})
|
||||
face_selection_only_title = face_selection_only_probe._selected_object_title_suffix()
|
||||
_assert(
|
||||
"Face 594" in face_selection_only_title and "拓扑 637" in face_selection_only_title,
|
||||
f"selection_display_id should be enough for Face title: {face_selection_only_title}",
|
||||
)
|
||||
|
||||
raw_probe = _probe("feature", 637, {})
|
||||
_assert(raw_probe._selected_id_text() == "face 637", "topological Face ID fallback should still work")
|
||||
|
||||
helper_probe = _probe("feature", 637, {})
|
||||
helper_probe.model = _LogicalFaceModel(594)
|
||||
fields = helper_probe._selection_identity_fields(637, "特征来源 Face")
|
||||
_assert(fields.get("selection_display_id") == 594, f"helper should expose logical Face ID: {fields}")
|
||||
_assert(
|
||||
fields.get("selection_topological_face_id") == 637,
|
||||
f"helper should keep current topological Face ID: {fields}",
|
||||
)
|
||||
_assert(
|
||||
fields.get("selection_title") == "特征来源 Face 594(当前拓扑 Face 637)",
|
||||
f"helper title should explain both IDs: {fields}",
|
||||
)
|
||||
|
||||
refresh_probe = _FeatureRefreshProbe()
|
||||
context = refresh_probe._feature_context_info(637)
|
||||
_assert(context.get("selection_display_id") == 594, f"feature context should include logical ID: {context}")
|
||||
_assert(
|
||||
context.get("selection_topological_face_id") == 637,
|
||||
f"feature context should include topological ID: {context}",
|
||||
)
|
||||
refresh_probe._on_feature_detection_level_changed()
|
||||
payload = refresh_probe.set_info_payload or {}
|
||||
_assert(payload.get("selection_display_id") == 594, f"detection refresh should call set_info with identity: {payload}")
|
||||
_assert(payload.get("pick_position") == (1.0, 2.0, 3.0), f"detection refresh should preserve pick: {payload}")
|
||||
_assert(refresh_probe.highlighted_face_ids == (637,), f"detection refresh should rehighlight source Face")
|
||||
|
||||
source_probe = _FeatureRefreshProbe()
|
||||
source_probe.selected_face_id = 100
|
||||
source_probe.selected_pick_position = (9.0, 9.0, 9.0)
|
||||
source_probe._activate_property_source_feature(
|
||||
{
|
||||
"source_face_id": 637,
|
||||
"source_feature_info": {
|
||||
"feature_type": "可拉伸/切除平面候选",
|
||||
"part_id": 1,
|
||||
"solid_id": 0,
|
||||
"feature_highlight_face_ids": (637,),
|
||||
},
|
||||
}
|
||||
)
|
||||
source_payload = source_probe.set_info_payload or {}
|
||||
_assert(source_probe.synced_id == ("Feature", 594), f"associated jump should sync logical ID: {source_probe.synced_id}")
|
||||
_assert(source_payload.get("selection_display_id") == 594, f"associated jump should keep logical ID: {source_payload}")
|
||||
_assert("pick_position" not in source_payload, f"associated jump should not reuse stale pick position: {source_payload}")
|
||||
|
||||
print("selection identity UI ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user