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())