310 lines
13 KiB
Python
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())
|