feat: 完善 Face 一级关系编辑和稳定性校验
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder
|
||||
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.step_io import _write_step
|
||||
|
||||
|
||||
def _write_solid_cylinder(path: Path) -> None:
|
||||
_write_step(BRepPrimAPI_MakeCylinder(3.0, 11.0).Shape(), path)
|
||||
|
||||
|
||||
def _write_hollow_cylinder(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 hollow cylinder height", use_glue=False), path)
|
||||
|
||||
|
||||
def _write_multi_hole_cylinder(path: Path) -> None:
|
||||
shape = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
for x_value in (-3.0, 3.0):
|
||||
cutter = BRepPrimAPI_MakeCylinder(
|
||||
gp_Ax2(gp_Pnt(x_value, 0.0, -1.0), gp_Dir(0.0, 0.0, 1.0)),
|
||||
1.2,
|
||||
80.0,
|
||||
).Shape()
|
||||
shape = _finalize_boolean_result(
|
||||
BRepAlgoAPI_Cut(shape, cutter),
|
||||
"verify multi-hole cylinder cut",
|
||||
use_glue=False,
|
||||
)
|
||||
_write_step(shape, path)
|
||||
|
||||
|
||||
def _write_top_notched_cylinder(path: Path) -> None:
|
||||
cylinder = BRepPrimAPI_MakeCylinder(8.0, 78.0).Shape()
|
||||
notch = BRepPrimAPI_MakeBox(gp_Pnt(2.0, -1.0, 60.0), gp_Pnt(4.5, 1.0, 90.0)).Shape()
|
||||
cut = BRepAlgoAPI_Cut(cylinder, notch)
|
||||
_write_step(_finalize_boolean_result(cut, "verify top-notched cylinder height", use_glue=False), path)
|
||||
|
||||
|
||||
def _first_cylinder_face(model: StepModel) -> int:
|
||||
for face_id in range(len(model.faces)):
|
||||
info = model.face_info(face_id)
|
||||
if info.get("surface") == "cylinder":
|
||||
return face_id
|
||||
raise SystemExit("no cylindrical Face was found")
|
||||
|
||||
|
||||
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 planar cap Face was found")
|
||||
return best[1]
|
||||
|
||||
|
||||
def _bbox_height(model: StepModel) -> float:
|
||||
bbox_size = model.geometry_stats().get("bbox_size")
|
||||
if not isinstance(bbox_size, tuple) or len(bbox_size) != 3:
|
||||
raise SystemExit(f"bbox_size is missing: {model.geometry_stats()}")
|
||||
return float(bbox_size[2])
|
||||
|
||||
|
||||
def _assert_height(model: StepModel, target_height: float, label: str) -> None:
|
||||
height = _bbox_height(model)
|
||||
if abs(height - target_height) > 1e-4:
|
||||
raise SystemExit(f"{label}: height should be {target_height:g}, got {height:g}")
|
||||
|
||||
|
||||
def _assert_radii(model: StepModel, expected: tuple[float, ...], label: str) -> None:
|
||||
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 - item) <= max(radius, item, 1.0) * 1e-5 for item in radii):
|
||||
radii.append(radius)
|
||||
radii.sort()
|
||||
expected_values = list(expected)
|
||||
if len(radii) != len(expected_values) or any(abs(a - b) > 1e-4 for a, b in zip(radii, expected_values)):
|
||||
raise SystemExit(f"{label}: radii should be {expected_values}, got {radii}")
|
||||
|
||||
|
||||
def _run_isolated_worker(
|
||||
input_path: Path,
|
||||
operation: str,
|
||||
args: list[object],
|
||||
label: str,
|
||||
*,
|
||||
timeout_seconds: float = 90.0,
|
||||
) -> tuple[StepModel, str, float]:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_isolated_") as temp_dir:
|
||||
temp_root = Path(temp_dir)
|
||||
output_path = temp_root / "output.step"
|
||||
request_path = temp_root / "request.json"
|
||||
request_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"input_path": str(input_path),
|
||||
"output_path": str(output_path),
|
||||
"operation": operation,
|
||||
"args": args,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
started = time.perf_counter()
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)],
|
||||
cwd=PROJECT_ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
elapsed = time.perf_counter() - started
|
||||
response_path = request_path.with_suffix(".response.json")
|
||||
response = json.loads(response_path.read_text(encoding="utf-8")) if response_path.exists() else {}
|
||||
if completed.returncode != 0 or not response.get("ok"):
|
||||
raise SystemExit(
|
||||
f"{label}: isolated worker failed: returncode={completed.returncode}, "
|
||||
f"stdout={completed.stdout!r}, stderr={completed.stderr!r}, response={response}"
|
||||
)
|
||||
if elapsed > 45.0:
|
||||
raise SystemExit(f"{label}: isolated worker took too long: {elapsed:.3f}s")
|
||||
if not output_path.exists():
|
||||
raise SystemExit(f"{label}: isolated worker did not export output STEP")
|
||||
return StepModel.load(output_path), str(response.get("message") or ""), elapsed
|
||||
|
||||
|
||||
def _run_height_case(
|
||||
path: Path,
|
||||
target_height: float,
|
||||
label: str,
|
||||
expected_method: str | None = None,
|
||||
) -> StepModel:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cylinder_face(model)
|
||||
plan = model.cylindrical_height_plan(face_id, target_height)
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label}: height plan should not be blocked: {plan}")
|
||||
if int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0) <= 0:
|
||||
raise SystemExit(f"{label}: height plan should carry cylindrical first-level topology: {plan}")
|
||||
result = model.resize_cylindrical_height(face_id, target_height)
|
||||
_assert_height(model, target_height, label)
|
||||
if "verified_face=" not in result:
|
||||
raise SystemExit(f"{label}: result should include cylindrical height verification: {result}")
|
||||
if expected_method is not None and expected_method not in result:
|
||||
raise SystemExit(f"{label}: result should use {expected_method}: {result}")
|
||||
isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker(
|
||||
path,
|
||||
"resize_cylindrical_height",
|
||||
[face_id, target_height],
|
||||
f"{label} isolated",
|
||||
)
|
||||
_assert_height(isolated_model, target_height, f"{label} isolated")
|
||||
if "verified_face=" not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated result should include cylindrical height verification: {isolated_message}")
|
||||
if expected_method is not None and expected_method not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated result should use {expected_method}: {isolated_message}")
|
||||
print(f"{label} ok: {result}")
|
||||
print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}")
|
||||
return model
|
||||
|
||||
|
||||
def _run_owning_scale_case(path: Path, target_height: float, label: str) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _first_cylinder_face(model)
|
||||
plan = model.cylindrical_height_owning_scale_plan(face_id, target_height)
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label}: owning-scale plan should not be blocked: {plan}")
|
||||
result = model.resize_cylindrical_height_owning_scale(face_id, target_height)
|
||||
_assert_height(model, target_height, label)
|
||||
if "verified_axis_span=" not in result:
|
||||
raise SystemExit(f"{label}: result should include axis-span verification: {result}")
|
||||
print(f"{label} ok: {result}")
|
||||
|
||||
|
||||
def _run_cap_push_pull_case(path: Path, distance: float, target_height: float, label: str) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _top_planar_face(model)
|
||||
plan = model.push_pull_plan(face_id, distance)
|
||||
if plan.get("status") == "blocked":
|
||||
raise SystemExit(f"{label}: cap push/pull plan should not be blocked: {plan}")
|
||||
if plan.get("cylindrical_cap_extension_kind") != "coaxial-tube":
|
||||
raise SystemExit(f"{label}: expected coaxial-tube cap recognition, got: {plan}")
|
||||
result = model.push_pull_face(face_id, distance)
|
||||
if "cylindrical cap analytic rebuild" not in result:
|
||||
raise SystemExit(f"{label}: cap push/pull should use analytic rebuild: {result}")
|
||||
_assert_height(model, target_height, label)
|
||||
_assert_radii(model, (3.0, 8.0), label)
|
||||
isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker(
|
||||
path,
|
||||
"push_pull_face",
|
||||
[face_id, distance],
|
||||
f"{label} isolated",
|
||||
)
|
||||
if "cylindrical cap analytic rebuild" not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated cap push/pull should use analytic rebuild: {isolated_message}")
|
||||
_assert_height(isolated_model, target_height, f"{label} isolated")
|
||||
_assert_radii(isolated_model, (3.0, 8.0), f"{label} isolated")
|
||||
print(f"{label} ok: {result}")
|
||||
print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}")
|
||||
|
||||
|
||||
def _run_prismatic_cap_push_pull_case(path: Path, distance: float, target_height: float, label: str) -> None:
|
||||
model = StepModel.load(path)
|
||||
face_id = _top_planar_face(model)
|
||||
result = model.push_pull_face(face_id, distance)
|
||||
if "prismatic cap analytic rebuild" not in result:
|
||||
raise SystemExit(f"{label}: multi-hole cap push/pull should use prismatic rebuild: {result}")
|
||||
_assert_height(model, target_height, label)
|
||||
_assert_radii(model, (1.2, 8.0), label)
|
||||
isolated_model, isolated_message, isolated_elapsed = _run_isolated_worker(
|
||||
path,
|
||||
"push_pull_face",
|
||||
[face_id, distance],
|
||||
f"{label} isolated",
|
||||
)
|
||||
if "prismatic cap analytic rebuild" not in isolated_message:
|
||||
raise SystemExit(f"{label}: isolated multi-hole cap push/pull should use prismatic rebuild: {isolated_message}")
|
||||
_assert_height(isolated_model, target_height, f"{label} isolated")
|
||||
_assert_radii(isolated_model, (1.2, 8.0), f"{label} isolated")
|
||||
print(f"{label} ok: {result}")
|
||||
print(f"{label} isolated ok: elapsed={isolated_elapsed:.3f}s, result={isolated_message}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with tempfile.TemporaryDirectory(prefix="geom_param_cylinder_height_") as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
solid_path = root / "solid_cylinder.step"
|
||||
hollow_path = root / "hollow_cylinder.step"
|
||||
multi_hole_path = root / "multi_hole_cylinder.step"
|
||||
notch_path = root / "top_notched_cylinder.step"
|
||||
_write_solid_cylinder(solid_path)
|
||||
_write_hollow_cylinder(hollow_path)
|
||||
_write_multi_hole_cylinder(multi_hole_path)
|
||||
_write_top_notched_cylinder(notch_path)
|
||||
|
||||
_run_height_case(solid_path, 50.0, "solid cylinder side Face height", "analytic rebuild")
|
||||
_run_owning_scale_case(solid_path, 50.0, "solid cylinder owning-axis height")
|
||||
hollow_model = _run_height_case(hollow_path, 167.0, "hollow cylinder side Face height", "analytic rebuild")
|
||||
_assert_radii(hollow_model, (3.0, 8.0), "hollow cylinder side Face height")
|
||||
_run_cap_push_pull_case(hollow_path, 89.0, 167.0, "hollow cylinder cap Face large push/pull")
|
||||
_run_prismatic_cap_push_pull_case(
|
||||
multi_hole_path,
|
||||
89.0,
|
||||
167.0,
|
||||
"multi-hole cylinder cap Face large push/pull",
|
||||
)
|
||||
_run_height_case(notch_path, 98.0, "top-notched cylinder side Face height", "profile-prism")
|
||||
|
||||
print("cylindrical Face height resize ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user