feat: 完善 Face 一级关系编辑和稳定性校验

This commit is contained in:
2026-08-04 18:15:29 +08:00
parent 5799d5d813
commit a76282d7dd
28 changed files with 6872 additions and 270 deletions
+146 -4
View File
@@ -6,7 +6,9 @@ import sys
import tempfile
from pathlib import Path
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeTorus
from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -15,6 +17,7 @@ if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from step_editor.model import StepModel
from step_editor.geometry_utils import _finalize_boolean_result
from step_editor.step_io import _write_step
@@ -207,6 +210,17 @@ def _write_torus_model(path: Path) -> None:
_write_step(BRepPrimAPI_MakeTorus(8.0, 2.0).Shape(), path)
def _write_hollow_cylinder_model(path: Path) -> None:
outer = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
inner = BRepPrimAPI_MakeCylinder(
gp_Ax2(gp_Pnt(0.0, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
3.0,
80.0,
).Shape()
cut = BRepAlgoAPI_Cut(outer, inner)
_write_step(_finalize_boolean_result(cut, "verify isolated hollow cylinder cut", use_glue=False), path)
def _first_face_by_surface(model: StepModel, surface: str) -> int:
for face_id in range(len(model.faces)):
if model.face_info(face_id).get("surface") == surface:
@@ -214,6 +228,23 @@ def _first_face_by_surface(model: StepModel, surface: str) -> int:
raise SystemExit(f"no {surface} Face was recognized")
def _top_planar_face(model: StepModel) -> int:
best: tuple[float, int] | None = None
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "plane":
continue
center = info.get("area_center") or info.get("bbox_center")
if not isinstance(center, tuple) or len(center) != 3:
continue
z_value = float(center[2])
if best is None or z_value > best[0]:
best = (z_value, face_id)
if best is None:
raise SystemExit("no top planar Face was recognized")
return best[1]
def _first_shell_face(model: StepModel, source_thickness: float = 2.0, tolerance: float = 1e-5) -> int:
candidates: list[tuple[int, int]] = []
for face_id in range(len(model.faces)):
@@ -270,7 +301,78 @@ def _assert_torus_minor_radius(label: str, model: StepModel, target_minor: float
print(f"matched_torus_radii={matches}")
def _assert_hollow_cylinder_height(label: str, model: StepModel, target_height: float) -> None:
bbox_size = model.geometry_stats().get("bbox_size")
if not isinstance(bbox_size, tuple) or len(bbox_size) != 3:
raise SystemExit(f"{label}: output bbox_size is missing")
height = float(bbox_size[2])
if abs(height - target_height) > 1e-4:
raise SystemExit(f"{label}: hollow cylinder height should be {target_height:g}, got {height:g}")
radii: list[float] = []
for face_id in range(len(model.faces)):
info = model.face_info(face_id)
if info.get("surface") != "cylinder":
continue
radius = float(info.get("radius") or 0.0)
if radius <= 0.0:
continue
if not any(abs(radius - existing) <= max(radius, existing, 1.0) * 1e-5 for existing in radii):
radii.append(radius)
radii.sort()
if len(radii) != 2 or abs(radii[0] - 3.0) > 1e-4 or abs(radii[1] - 8.0) > 1e-4:
raise SystemExit(f"{label}: hollow cylinder should preserve inner/outer radii, got {radii}")
print(f"matched_hollow_cylinder_height={height:g}, radii={radii}")
def _assert_hollow_cylinder_extension(label: str, model: StepModel) -> None:
_assert_hollow_cylinder_height(label, model, 167.0)
def main() -> int:
push_pull_probe = StepModel.load(DEFAULT_MODEL)
large_push_plan = push_pull_probe.push_pull_plan(0, 11.0)
if large_push_plan.get("status") == "blocked" or str(large_push_plan.get("risk")) != "high":
raise SystemExit(f"large push/pull should be allowed as an isolated high-risk edit: {large_push_plan}")
_run_worker_case(
label="large push/pull current Face",
operation="push_pull_face",
args=[0, 11.0],
validator=lambda label, model: _assert_plane_position(label, model, 11.0),
)
with tempfile.TemporaryDirectory(prefix="geom_param_isolated_hollow_cylinder_verify_") as temp_dir:
hollow_path = Path(temp_dir) / "hollow_cylinder.step"
_write_hollow_cylinder_model(hollow_path)
hollow_probe = StepModel.load(hollow_path)
hollow_face_id = _top_planar_face(hollow_probe)
hollow_plan = hollow_probe.push_pull_plan(hollow_face_id, 89.0)
if hollow_plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
raise SystemExit(f"hollow cylinder cap should use coaxial-tube plan in worker test: {hollow_plan}")
_run_worker_case(
label="large hollow cylinder cap push/pull",
operation="push_pull_face",
args=[hollow_face_id, 89.0],
validator=_assert_hollow_cylinder_extension,
input_path=hollow_path,
)
_run_worker_case(
label="hollow cylinder cap inward push/pull",
operation="push_pull_face",
args=[hollow_face_id, -39.0],
validator=lambda label, model: _assert_hollow_cylinder_height(label, model, 39.0),
input_path=hollow_path,
)
hollow_height_probe = StepModel.load(hollow_path)
hollow_cylinder_face_id = _first_face_by_surface(hollow_height_probe, "cylinder")
height_plan = hollow_height_probe.cylindrical_height_plan(hollow_cylinder_face_id, 167.0)
if height_plan.get("status") == "blocked":
raise SystemExit(f"hollow cylinder side Face height edit should be isolated, not blocked: {height_plan}")
_run_worker_case(
label="large hollow cylinder side Face height",
operation="resize_cylindrical_height",
args=[hollow_cylinder_face_id, 167.0],
validator=_assert_hollow_cylinder_extension,
input_path=hollow_path,
)
_run_worker_case(
label="面偏移(当前面)",
operation="move_face_plane_offset_local",
@@ -384,8 +486,30 @@ def main() -> int:
def __init__(self) -> None:
self.model = StepModel.load(DEFAULT_MODEL)
self.step_path = DEFAULT_MODEL
self.current_info_values = self.model.face_info(0)
probe = _IsolatedJobProbe()
action_plan = probe._push_pull_plan_for_action(0, 11.0)
if action_plan.get("status") == "blocked" or str(action_plan.get("risk")) != "high":
raise SystemExit(f"UI push/pull action plan should use the model-layer high-risk plan: {action_plan}")
if float(action_plan.get("push_pull_distance_to_owning_axis_span_ratio") or 0.0) <= 1.0:
raise SystemExit(f"UI push/pull action plan should expose model-layer span ratio: {action_plan}")
complex_path = PROJECT_ROOT / "assets" / "models" / "geom_extract.step"
if complex_path.exists():
complex_probe = _IsolatedJobProbe()
complex_probe.model = StepModel.load(complex_path)
complex_probe.step_path = complex_path
complex_probe.selected_face_id = 1
complex_probe.current_info_values = complex_probe.model.quick_face_info(1)
complex_plan = complex_probe._push_pull_plan_for_action(1, 89.0)
if not complex_plan.get("ui_deferred_model_plan"):
raise SystemExit(f"complex holed cap UI plan should defer full model plan: {complex_plan}")
if str(complex_plan.get("risk")) != "high":
raise SystemExit(f"deferred complex holed cap UI plan should be high risk: {complex_plan}")
if complex_probe._isolation_for_plan(complex_plan, "push_pull_face", [1, 89.0]) is None:
raise SystemExit(f"deferred complex holed cap UI plan should request isolated execution: {complex_plan}")
if probe._isolated_edit_command(Path("request.json")) != [
sys.executable,
"-m",
@@ -445,9 +569,18 @@ def main() -> int:
)
if "隔离子进程" not in str(result.get("message", "")):
raise SystemExit(f"isolated window job did not report isolated execution: {result}")
after_model = result.get("after_model")
if not isinstance(after_model, StepModel):
raise SystemExit(f"isolated window job should return after_model for UI-thread installation: {result}")
if probe.model.stats().solids != 1:
raise SystemExit(f"isolated window job changed solid count unexpectedly: {probe.model.stats()}")
if not _logical_region_has_width(probe.model, 0, 25.0):
if _logical_region_has_width(probe.model, 0, 25.0):
raise SystemExit("isolated window job replaced the window model before returning to the UI thread")
if after_model.stats().solids != 1:
raise SystemExit(f"isolated window after_model changed solid count unexpectedly: {after_model.stats()}")
if result.get("model_polydata") is None or result.get("edge_polydata") is None:
raise SystemExit("isolated window job should return prebuilt display polydata")
if not _logical_region_has_width(after_model, 0, 25.0):
raise SystemExit("isolated window job did not preserve the original logical Face ID on the edited width")
print("isolated window job ok")
@@ -474,8 +607,17 @@ def main() -> int:
)
if "隔离子进程" not in str(owning_result.get("message", "")):
raise SystemExit(f"isolated owning window job did not report isolated execution: {owning_result}")
_assert_face_width("面宽(整体窗口任务)", owning_probe.model, 25.0)
if not _logical_region_has_width(owning_probe.model, 0, 25.0):
owning_after_model = owning_result.get("after_model")
if not isinstance(owning_after_model, StepModel):
raise SystemExit(
f"isolated owning window job should return after_model for UI-thread installation: {owning_result}"
)
if _logical_region_has_width(owning_probe.model, 0, 25.0):
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)
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")
_run_main_worker_entry_case()