feat: 完善 Face 一级编辑与隔离计算

This commit is contained in:
2026-08-05 15:08:16 +08:00
parent a76282d7dd
commit ed1faf51d8
28 changed files with 3740 additions and 876 deletions
+2 -1
View File
@@ -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"
+286
View File
@@ -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())
+4
View File
@@ -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"))
+12
View File
@@ -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())
+136
View File
@@ -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())
+1 -1
View File
@@ -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}")
+162 -29
View File
@@ -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
+18 -1
View File
@@ -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
+18 -18
View File
@@ -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")
+83 -5
View File
@@ -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: "
+103 -9
View File
@@ -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",
+252
View File
@@ -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())