Files
pythonocc-step-editor/scripts/verify_cylindrical_height_resize.py
T

310 lines
13 KiB
Python

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.operations import _is_effectively_full_cylinder
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 _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,
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:
_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"
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())